feat(calendar): auto-add bot self as attendee and note user-only search (#1991)

When creating an event as a bot, resolve the bot's own open_id via
/bot/v3/info and add it to the attendee list, mirroring how a user is
auto-joined to their own events; warn and proceed without it if the
lookup fails. Also note in the +create skill doc that the user-search
API is user-only, so resolving a name to open_id needs --as user.
This commit is contained in:
calendar-assistant
2026-07-22 14:36:01 +08:00
committed by GitHub
parent 409a3172da
commit fc2761d16b
3 changed files with 155 additions and 6 deletions

View File

@@ -67,6 +67,25 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
return attendees, nil
}
// selfAttendeeId resolves the open_id of the identity running the command so it
// can be auto-added to the attendee list, mirroring how a human user is joined
// to their own events. For a user it comes from config; for a bot it is fetched
// from /bot/v3/info. If the bot lookup fails, we warn and return "" so the event
// is still created with the explicitly requested attendees.
func selfAttendeeId(runtime *common.RuntimeContext) string {
if !runtime.IsBot() {
return runtime.UserOpenId()
}
info, err := runtime.BotInfo()
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"[calendar +create] warning: could not resolve bot identity to add it as an attendee (%v); proceeding without the bot\n",
err)
return ""
}
return info.OpenID
}
func attendeesIncludeRoom(attendees []map[string]string) bool {
for _, attendee := range attendees {
if attendee["type"] == "resource" || attendee["room_id"] != "" {
@@ -176,7 +195,9 @@ var CalendarCreate = common.Shortcut{
eventData := buildEventData(runtime, startTs, endTs)
attendeesStr := runtime.Str("attendee-ids")
if attendeesStr != "" {
// Note: dry-run doesn't network resolve the current user's open_id.
// Note: dry-run doesn't network resolve the running identity's own
// open_id (user from config, bot from /bot/v3/info), so the auto-joined
// self attendee is not shown here.
attendees, err := parseAttendees(attendeesStr, "")
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
@@ -228,11 +249,8 @@ var CalendarCreate = common.Shortcut{
// Add attendees if specified
if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" {
currentUserId := ""
if !runtime.IsBot() {
currentUserId = runtime.UserOpenId()
}
attendees, err := parseAttendees(attendeesStr, currentUserId)
selfId := selfAttendeeId(runtime)
attendees, err := parseAttendees(attendeesStr, selfId)
if err != nil {
return withParam(err, "--attendee-ids")
}

View File

@@ -251,6 +251,136 @@ func TestCreate_WithAttendees_Success(t *testing.T) {
}
}
func TestCreate_WithAttendees_AsBot_AddsBotSelf(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"bot": map[string]interface{}{
"open_id": "ou_botself",
"app_name": "Test Bot",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_bot",
"summary": "Bot Sync",
"start_time": map[string]interface{}{
"timestamp": "1742515200",
},
"end_time": map[string]interface{}{
"timestamp": "1742518800",
},
},
},
},
})
attendeesStub := &httpmock.Stub{
Method: "POST",
URL: "/events/evt_bot/attendees",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(attendeesStub)
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Bot Sync",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--attendee-ids", "ou_user1",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attendeesStub.CapturedBody == nil {
t.Fatal("attendees API was not called")
}
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_botself")) {
t.Fatalf("expected bot open_id ou_botself in attendees request, got: %s", attendeesStub.CapturedBody)
}
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_user1")) {
t.Fatalf("expected requested attendee ou_user1 in attendees request, got: %s", attendeesStub.CapturedBody)
}
}
func TestCreate_WithAttendees_AsBot_BotInfoFails_ProceedsWithoutBot(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 99991663, "msg": "app ticket invalid",
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_nobot",
"summary": "Bot Sync",
"start_time": map[string]interface{}{
"timestamp": "1742515200",
},
"end_time": map[string]interface{}{
"timestamp": "1742518800",
},
},
},
},
})
attendeesStub := &httpmock.Stub{
Method: "POST",
URL: "/events/evt_nobot/attendees",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(attendeesStub)
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Bot Sync",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--attendee-ids", "ou_user1",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attendeesStub.CapturedBody == nil {
t.Fatal("attendees API was not called")
}
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_user1")) {
t.Fatalf("expected requested attendee ou_user1 in attendees request, got: %s", attendeesStub.CapturedBody)
}
if bytes.Contains(attendeesStub.CapturedBody, []byte("ou_botself")) {
t.Fatalf("bot open_id should be absent when /bot/v3/info fails, got: %s", attendeesStub.CapturedBody)
}
}
func TestCreate_WithAttendees_APIError_RollsBack(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())

View File

@@ -44,6 +44,7 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
> 失败保护:若添加参会人失败(如 open_id 错误CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
> 搜索用户接口不支持 bot 身份,需用 `--as user` 进行搜索。
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
## 高级用法(完整 API 命令)