fix(vc): don't fail +detail for in-progress meetings (#1930)

An ongoing meeting has no minute/note yet, so the recording lookup in
+detail returned an unclassified error that was surfaced as a hard error,
making the whole command exit 1 / ok:false even though meeting.get had
already succeeded.

Detect the in-progress state up front (same start/end heuristic as
+meeting-events, reading raw timestamps) and skip the recording call,
returning the meeting metadata with an informational hint instead of an
error. Recording failures for ended meetings are likewise degraded to a
hint rather than failing the command.

Also note in the vc-agent skill that sending an in-meeting message only
needs meeting_id and must not pre-fetch +detail / +recording / +notes.
This commit is contained in:
calendar-assistant
2026-07-17 15:54:26 +08:00
committed by GitHub
parent 47dc003601
commit 27df16d3b2
3 changed files with 116 additions and 24 deletions

View File

@@ -79,27 +79,42 @@ func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, mee
result.NoteID = v
}
// Step 2: query minute_token via recording API
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
if minuteErr != nil {
// Recording API failed — surface the error but keep data from step 1
result.Error = fmt.Sprintf("failed to query minutes: %v", minuteErr)
minuteHint = ""
}
if minuteToken != "" {
result.MinuteToken = minuteToken
// Step 2: query minute_token via recording API — only meaningful once the
// meeting has ended. While it is still in progress the note/minute are not
// generated yet, so skip the recording call and surface an informational
// hint instead of letting an unclassified recording error fail the command.
inProgress := meetingInProgress(meeting)
var minuteHint string
if inProgress {
minuteHint = "meeting is still in progress; note and minute are not generated yet"
} else {
minuteToken, hint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
minuteHint = hint
if minuteErr != nil {
// Recording lookup is a best-effort supplement; step 1 already
// succeeded, so degrade the failure to a hint rather than failing
// the whole command.
minuteHint = fmt.Sprintf("failed to query minutes: %v", minuteErr)
}
if minuteToken != "" {
result.MinuteToken = minuteToken
}
}
// Add hints for empty resources (not errors, just informational)
var emptyFields []string
if result.NoteID == "" {
emptyFields = append(emptyFields, "note_id")
}
if result.MinuteToken == "" && minuteErr == nil && minuteHint == "" {
emptyFields = append(emptyFields, "minute_token")
}
if len(emptyFields) > 0 {
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
// Add hints for empty resources (not errors, just informational). For an
// in-progress meeting the "not found" wording is noise, so we only emit the
// single in-progress hint below.
if !inProgress {
var emptyFields []string
if result.NoteID == "" {
emptyFields = append(emptyFields, "note_id")
}
if result.MinuteToken == "" && minuteHint == "" {
emptyFields = append(emptyFields, "minute_token")
}
if len(emptyFields) > 0 {
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
}
}
if minuteHint != "" {
if result.Hint != "" {
@@ -112,6 +127,36 @@ func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, mee
return result
}
// meetingTimeField reads a meeting time field as a string regardless of whether
// the API returned it as a JSON string or number. VC serializes int64
// timestamps as strings, but coercing via %v keeps parsing robust either way;
// float64(0) renders as "0", which parseFlexibleTime treats as "absent".
func meetingTimeField(meeting map[string]any, key string) string {
v, ok := meeting[key]
if !ok || v == nil {
return ""
}
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
// meetingInProgress reports whether a meeting is still ongoing, using the same
// start/end heuristic as +meeting-events (meetingEventsMeetingFromPayload): a
// meeting is ongoing when it has a start time but no end time, or its end time
// is not after its start time. It reads the RAW timestamp fields, not the
// FormatTime-rendered result strings, because parseFlexibleTime only accepts
// Unix timestamps or RFC3339. Empty or "0" values are treated as absent.
func meetingInProgress(meeting map[string]any) bool {
start, hasStart := parseFlexibleTime(meetingTimeField(meeting, "start_time"))
end, hasEnd := parseFlexibleTime(meetingTimeField(meeting, "end_time"))
if !hasStart {
return false
}
if !hasEnd {
return true
}
return !end.After(start)
}
// VCDetail gets meeting details including note_id and minute_token.
var VCDetail = common.Shortcut{
Service: "vc",

View File

@@ -269,11 +269,58 @@ func TestFetchMeetingDetail_RecordingAPIErrorButNoteOK(t *testing.T) {
if result.MinuteToken != "" {
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
}
if !strings.Contains(result.Error, "failed to query minutes") || !strings.Contains(result.Error, "weird API error") {
t.Errorf("error = %q, want contains 'failed to query minutes' and 'weird API error'", result.Error)
if result.Error != "" {
t.Errorf("error = %q, want empty: a recording lookup failure must not fail the command", result.Error)
}
if strings.Contains(result.Hint, "minute_token") {
t.Errorf("hint = %q, should not mention minute_token when there is an error", result.Hint)
if !strings.Contains(result.Hint, "failed to query minutes") || !strings.Contains(result.Hint, "weird API error") {
t.Errorf("hint = %q, want contains 'failed to query minutes' and 'weird API error'", result.Hint)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// TestFetchMeetingDetail_MeetingInProgress pins the in-progress behavior: when a
// meeting is still ongoing (end_time not after start_time), +detail must not
// call the recording API at all — it returns meeting metadata with an
// informational hint and no error. Deliberately register NO recording stub so
// that any recording call would fail on an unmatched request.
func TestFetchMeetingDetail_MeetingInProgress(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m_live",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"meeting": map[string]interface{}{
"id": "m_live",
"topic": "Live Meeting",
"meeting_no": "912052453",
// end_time == start_time signals an ongoing meeting.
"start_time": "1752000000",
"end_time": "1752000000",
}},
},
})
if err := botExec(t, "detail-live", f, func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchMeetingDetail(context.Background(), rctx, "m_live")
if result.Topic != "Live Meeting" {
t.Errorf("topic = %q, want 'Live Meeting'", result.Topic)
}
if result.Error != "" {
t.Errorf("error = %q, want empty for an in-progress meeting", result.Error)
}
if result.MinuteToken != "" {
t.Errorf("minute_token = %q, want empty for an in-progress meeting", result.MinuteToken)
}
if !strings.Contains(result.Hint, "in progress") {
t.Errorf("hint = %q, want to mention the meeting is in progress", result.Hint)
}
if strings.Contains(result.Hint, "not found for this meeting") {
t.Errorf("hint = %q, should not emit not-found noise for an in-progress meeting", result.Hint)
}
return nil
}); err != nil {

View File

@@ -92,7 +92,7 @@ metadata:
### 3. 发送会中文本或会中表情(写操作)
1. 用户明确要求在当前进行中的会议里发送提示、说明、会中表情,或反馈“听不到 / 看不到 / 声音清楚 / 效果不错”时,用 `+meeting-message-send`
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。发消息只需 `meeting_id`,不要先查 `+detail`
3. 身份必须延续:`meeting_id` 来自用户身份发现,就继续 `--as user`;来自应用身份发现或应用机器人入会,就继续 `--as bot`
4. 文本消息使用 `--text`;会中表情 / 反馈使用 `--emoji-type``--emoji-type` 必须从 reference 里的完整列表中选择,大小写敏感。
5. 支持普通 Feishu reaction emoji`LOVE``SMILE``THUMBSUP`)和 4 个 VC 反馈 key`VC_CanNotSee``VC_NoSound``VC_LooksGood``VC_SoundsClear`)。