Back to Skills
wecomteam/wecom-cliCheck passed

SKILL DETAIL

wecomcli-calendar

wecomteam/wecom-cli/wecomcli-calendar

This skill manages WeCom calendar events, including creating appointments, viewing or browsing schedules, searching by keyword, updating or modifying events, canceling events, checking availability, and booking meeting rooms. It focuses on events without online meeting links, including purely offline face-to-face meetings. For events with online meeting links (e.g., requiring a meeting number or join link), use the wecomcli-meeting skill instead. When a user's request is ambiguous (e.g., "meeting" or "arrange a meet") and does not specify whether it is a calendar event or an online meeting, you must first ask for clarification via text before proceeding. This skill does not support recurring events (create, update, or cancel) or responding to/declining invitations; guide users to handle those in the WeCom client.

Installs · 173View source

Installation

npx skills add https://github.com/wecomteam/wecom-cli --skill wecomcli-calendar

Skill files

SKILL.md

Last synced · Aug 27, 2026

references/calendar-agenda.md
# calendar schedules list / get — 查看日程安排

查看近期日程安排或获取日程详情。只读操作,不修改任何日程。

> **只给时间/日期时必须用 `list` [REQUIRED]**:用户只提供了时间/日期(如"19号那条")而没有日程主题关键词时,必须走本文档的 `list` 按时间浏览,禁止把日期当关键词喂给 `search`。

> **模糊查询同时拉会议 [REQUIRED]**:若本次是"会 / xx会 / 有什么会 / 最近有哪些会"等模糊查询(见 [SKILL.md 查询消歧](../SKILL.md)),除拉日程 `list` 外,必须同时 `读取 wecomcli-meeting 技能` 用相同时间范围拉会议 `list`,把两边结果合并、分「(会议)」「(日程)」两部分汇总展示(同一场会议按主题 + 时间去重)——不论日程是否查到都要查会议。明确是日程 / 安排(不带在线会议特征)时只查日程。

## 命令

### list — 读取日程列表

```bash
# 查看今天日程
wecom-cli calendar schedules list --json '{"begin_time": "2026-04-07 00:00:00", "end_time": "2026-04-07 23:59:59"}'

# 查看本周日程
wecom-cli calendar schedules list --json '{"begin_time": "2026-04-06 00:00:00", "end_time": "2026-04-12 23:59:59"}'
```

**参数:**

| 参数 | 类型 | 必填 | 说明 |
|------|------|:----:|------|
| `begin_time` | string | 否 | 查询开始时间(格式 YYYY-MM-DD HH:mm:ss)。必须与 `end_time` 同时传入或同时省略,禁止单独传入其中一个。 |
| `end_time` | string | 否 | 查询结束时间(格式 YYYY-MM-DD HH:mm:ss)。必须与 `begin_time` 同时传入或同时省略,禁止单独传入其中一个;同时传入时,`end_time` 必须晚于 `begin_time`。 |

> **时间参数约束**:`begin_time` 和 `end_time` 必须**同时存在**或**同时为空**,禁止只传其中一个。两者同时传入时,`end_time` 必须严格晚于 `begin_time`,否则视为非法参数。
>
> **查询窗口上限:当前时刻前后 30 天 [REQUIRED]**:`schedules list` 仅支持查询**当前时刻前后 30 天以内**的日程,超出范围的部分服务端不返回。
> - 用户给的时间范围部分或完全超出窗口(`begin_time` 早于「今天 - 30 天」或 `end_time` 晚于「今天 + 30 天」)时,**直接告知用户「日程查询仅支持当前时刻前后 30 天范围内,请重新给一个更短的时间范围」**,等用户重新提供时间后再调用。
>
> **未指定时间时的默认范围策略 [REQUIRED]**:调用前先显式计算好时间范围再传入,不依赖服务端默认值——
> - 用户已明确时间(如"今天"、"本周"、"4月15日到4月20日")→ 直接映射为 `begin_time`/`end_time`。
> - 用户未明确时间(如"查一下我的日程"、"看看我的安排")→ **默认策略:今天起未来 7 天**(`begin_time = 今天 00:00:00`,`end_time = 7 天后 23:59:59`),无需追问。
> - 用户说"最近"或"近期" → 使用"过去 3 天到未来 7 天"(`begin_time = 3 天前 00:00:00`,`end_time = 7 天后 23:59:59`)。
> - 用户只提供了模糊但有意义的范围(如"上个月")→ 解析为对应日期范围。

**返回**:`schedule_list[]` 数组,每项字段如下:

| 字段 | 类型 | 说明 |
|------|------|------|
| `schedule_id` | string | 日程 ID |
| `subject` | string | 日程主题 |
| `begin_time` | string | 开始时间(YYYY-MM-DD HH:mm:ss) |
| `end_time` | string | 结束时间(YYYY-MM-DD HH:mm:ss) |
| `attendees` | object[] | 参与人列表,格式 `[{"userid": "USERID", "name": "englishname(name)"}]` |
| `meeting_room` | object | 会议室信息,含 `meeting_room_id` + `meeting_room_name` |
| `location` | string | 日程地点 |
| `meeting` | object | 在线会议信息(关联了会议时才有),含 `meeting_id`/`meeting_code` |
| `description` | string | 日程描述 |
| `creator_name` | string | 日程创建者名字 |
| `allow_self_join` | bool | 是否允许非参与人主动加入日程 |
| `is_all_day` | bool | 是否全天事件(`true` 是 / `false` 否) |
| `repeat_rule` | object | 重复规则(`is_repeat=false` 时无此字段或为空),见下表 |
| `reminders` | object | 提醒设置,含 `is_remind`(是否开启,bool,`true` 是 / `false` 否)和 `reminder_time`(int[],与开始时间的差值秒数,负数为提前提醒) |
| `timezone` | object | 时区信息,含 `timezone_id`(IANA 标识,如 `Asia/Shanghai`,优先使用)和 `timezone_offset`(UTC 偏移量秒数,`timezone_id` 为空时使用) |

**`repeat_rule` 子字段(list 返回):**

| 字段 | 类型 | 说明 |
|------|------|------|
| `is_repeat` | bool | 是否重复日程 |
| `repeat_type` | string | 重复类型:`daily`/`weekly`/`monthly`/`monthly_on_the_nth_day`/`yearly`/`yearly_on_the_nth_day`/`work_day` |
| `repeat_flag` | string[] | 重复标记,数组,可选值:`leap_month`(闰月)、`never_ends`(永不结束) |
| `repeat_time` | int | 重复次数,`0` 表示无限 |
| `repeat_interval` | int | 重复间隔 |
| `repeat_until` | string | 重复截止时间(格式 YYYY-MM-DD HH:mm:ss) |
| `repeat_week_of_month` | string[] | 每月第几周,数组,可选值:`first`/`second`/`third`/`fourth`/`last` |
| `repeat_day_of_week` | string[] | 每周周几,数组,可选值:`MO`/`TU`/`WE`/`TH`/`FR`/`SA`/`SU` |
| `repeat_month_of_year` | int[] | 每年哪几个月,数组,取值范围:1~12 |
| `repeat_day_of_month` | int[] | 每月哪几天,数组,取值范围:1~31 |
| `is_custom` | bool | 是否自定义重复 |
| `exception` | object[] | 例外日程列表,每项含 `begin_time`/`end_time`/`flag`/`except_schedule_id` |

### get — 读取日程详情

```bash
wecom-cli calendar schedules get --json '{"schedule_ids": ["<schedule_id1>", "<schedule_id2>"]}'
```

**参数:**

| 参数 | 类型 | 必填 | 说明 |
|------|------|:----:|------|
| `schedule_ids` | string[] | 是 | 日程 ID 列表,支持传入一个或多个 |

**返回**:`schedule_list[]` 数组,每项字段如下:

| 字段 | 类型 | 说明 |
|------|------|------|
| `schedule_id` | string | 日程 ID |
| `subject` | string | 日程主题 |
| `begin_time` | string | 开始时间(YYYY-MM-DD HH:mm:ss) |
| `end_time` | string | 结束时间(YYYY-MM-DD HH:mm:ss) |
| `attendees` | object[] | 参与人列表,格式 `[{"userid": "USERID", "name": "englishname(name)"}]`,直接取 `name` 展示,禁止展示 userid |
| `meeting_room` | object | 会议室信息,含 `meeting_room_id` + `meeting_room_name` |
| `location` | string | 日程地点 |
| `meeting` | object | 在线会议信息(关联了会议时才有),含 `meeting_id`/`meeting_code` |
| `description` | string | 日程描述 |
| `creator_name` | string | 日程创建者名字 |
| `allow_self_join` | bool | 是否允许非参与人主动加入日程 |
| `is_all_day` | bool | 是否全天事件(`true` 是 / `false` 否) |
| `repeat_rule` | object | 重复规则(`is_repeat=false` 时无此字段或为空),见下表 |
| `reminders` | object | 提醒设置,含 `is_remind`(是否开启,bool,`true` 是 / `false` 否)和 `reminder_time`(int[],与开始时间的差值秒数,负数为提前提醒) |
| `timezone` | object | 时区信息,含 `timezone_id`(IANA 标识,如 `Asia/Shanghai`,优先使用)和 `timezone_offset`(UTC 偏移量秒数,`timezone_id` 为空时使用) |

**`repeat_rule` 子字段:**

| 字段 | 类型 | 说明 |
|------|------|------|
| `is_repeat` | bool | 是否重复日程 |
| `repeat_type` | string | 重复类型:`daily`/`weekly`/`monthly`/`monthly_on_the_nth_day`/`yearly`/`yearly_on_the_nth_day`/`work_day` |
| `repeat_flag` | string[] | 重复标记,数组,可选值:`leap_month`(闰月)、`never_ends`(永不结束) |
| `repeat_time` | int | 重复次数,`0` 表示无限 |
| `repeat_interval` | int | 重复间隔 |
| `repeat_until` | string | 重复截止时间(格式 YYYY-MM-DD HH:mm:ss) |
| `repeat_week_of_month` | string[] | 每月第几周,数组,可选值:`first`/`second`/`third`/`fourth`/`last` |
| `repeat_day_of_week` | string[] | 每周周几,数组,可选值:`MO`/`TU`/`WE`/`TH`/`FR`/`SA`/`SU` |
| `repeat_month_of_year` | int[] | 每年哪几个月,数组,取值范围:1~12 |
| `repeat_day_of_month` | int[] | 每月哪几天,数组,取值范围:1~31 |
| `is_custom` | bool | 是否自定义重复 |
| `exception` | object[] | 例外日程列表,每项含 `begin_time`/`end_time`/`flag`/`except_schedule_id` |

## 输出格式

将结果整理为顺序的日程列表(**禁止使用 markdown 表格**,每条日程作为独立条目顺序输出,按开始时间升序排序):

```
(会议)
1. 产品评审
   时间:4月7日 10:00-11:00
   参与人:王五、赵六、钱七

(日程)
1. 站会
   时间:4月7日 09:30-09:45
   参与人:张三、李四

共 2 场,其中会议 1 场、日程 1 场
```

> 上例为"模糊会议查询"(日程 + 会议都查)且**两类同时存在**时的呈现:合并日程 `list` 与会议 `list` 的结果,按是否含在线会议链接分成「(会议)」「(日程)」两个部分(来自会议 `list` 或 `meeting.meeting_code` 非空者归会议),同一场会议两边都出现时按"主题 + 时间"去重,末尾给汇总;若本次结果只有单一类别(全是会议或全是日程),则不分部分、不加「(会议)」/「(日程)」标题,按普通列表直接展示;普通"看日程"查询也可不分部分、省略汇总行。

**展示规则:**
- 每个条目 **只展示三项:主题、时间、参与人**(不展示地点、会议室等其他字段)。
- **时间默认省略年份**(只到月日);仅当日程年份与当前年份不同(跨年)时,才在月日前带上年份。
- **昨天 / 今天 / 明天**的日程,时间行在月日前加相对词(如 `明天 6月11日 14:00-15:00`);其余日期按月日展示。
- **超过 10 条时只展示前 10 条**,并在末尾告知"还有 N 条,需要查看更多吗?"。
- 参与人原样取接口返回的 `attendees[].name` 展示(完全与接口返回的格式保持一致,如返回 `zhangsan(张三)` 就展示 `zhangsan(张三)`),禁止展示 userid、schedule_id。
- **会议 / 日程 分两部分展示**:判断依据是该日程是否带有会议链接——`meeting.meeting_code` 有值(非空)归为「会议」,为空 / 不存在归为「日程」。**仅当本次结果中同时存在「会议」和「日程」两类时**,才把结果分成「(会议)」和「(日程)」两个部分分别展示(每部分内按开始时间升序、逐条只列主题/时间/参与人);**当结果只有单一类别时**(全是会议或全是日程),不分部分、不展示「(会议)」/「(日程)」标题,按普通列表直接展示即可。`search`/`list`/`get` 返回均含 `meeting` 字段,可直接判断,无需额外调用其它接口补 `get`。

**模糊"会议"查询的汇总 [REQUIRED]**:当本次是"查会议/xx会"等需归类的查询(见 [SKILL.md 查询消歧](../SKILL.md))时,在列表末尾追加一行汇总:`共 N 场,其中会议 X 场、日程 Y 场`。

**时区标注**:日程 `timezone.timezone_offset != 28800`(非东八区)时,按 [SKILL.md 输出格式规范](../SKILL.md) 的时区标注规则在时间后带上时区,如 `14:00-15:00(纽约时间 UTC-5)`。

### 周期日程标注规则 [REQUIRED]

列表中存在 `repeat_rule.is_repeat=true` 的日程时,必须在该日程的**主题后追加**周期频率标注(不另起新字段,保持每个条目仍只有主题/时间/参与人三项),格式如下:

```
1. 每周站会(每周一次,截止 2026-12-31)
   时间:4月7日(周一)09:00-09:30
   参与人:张三、李四
```

**`repeat_type` 枚举值 → 可读文案映射:**

| `repeat_type` | 含义 | 展示文案示例 |
|:---:|------|------|
| `daily` | 每天 | 每天一次 |
| `weekly` | 每周 | 每周一次 |
| `monthly` | 每月 | 每月一次 |
| `monthly_on_the_nth_day` | 每月第N天 | 每月一次 |
| `yearly` | 每年 | 每年一次 |
| `yearly_on_the_nth_day` | 每年第N天 | 每年一次 |
| `work_day` | 每个工作日 | 每工作日一次 |
| 其他(`is_custom=true`) | 自定义 | 自定义周期 |

**时间范围展示规则:**
- `repeat_until` 非空 → 展示"截止 {repeat_until 的日期部分}"
- `repeat_until` 为空 且 `repeat_time=0` → 展示"无截止"
- `repeat_time > 0` → 展示"共 {repeat_time} 次"

## 典型场景

### 1. 查看今日日程

```
用户:今天有什么安排?
→ 调用 list(today 00:00-23:59)
→ 按开始时间升序,顺序输出每条日程(主题/时间/参与人),超过 10 条只展示前 10 条
```

### 2. 未指定时间范围,使用默认策略

```
用户:帮我看看我的日程安排
→ 未指定时间范围,直接使用默认策略:今天起未来 7 天(无需追问)
   begin_time = 今天 00:00:00,end_time = 7 天后 23:59:59
→ 调用 list,按开始时间升序顺序输出每条日程(主题/时间/参与人)
```

### 3. 查看详情(需要周期规则、会议链接等)

```
用户:这个周会是每周开吗?
→ 先从 list/search 结果中拿到 schedule_id
→ 调用 get 获取详情,展示 repeat_rule
```

## 提示

- 无日程时告知用户"今天日程清空"。
- **查询窗口上限 [REQUIRED]**:`schedules list` 仅覆盖当前时刻前后 30 天以内。用户给的时间范围超出窗口时,直接告知用户超出可查范围、请重新给一个更短的时间范围,等用户重新提供后再调用。
- **顺序列表展示**:每条日程作为独立条目顺序输出,禁止 markdown 表格,每个条目只含主题/时间/参与人。超过 10 条只展示前 10 条,并告知"还有 N 条,需要查看更多吗?"。
- `list` 和 `get` 均返回 `repeat_rule`,可直接判断是否周期日程;`meeting`(含 `meeting_id`/`meeting_code`)在 `search`/`list`/`get` 中均直接返回,判断会议形态无需额外补 `get`。
- **周期日程必须说明 [REQUIRED]**:结果中只要存在 `repeat_rule.is_repeat=true` 的日程,必须在该日程**主题后追加**周期频率(由 `repeat_type` 推导)和时间范围(由 `repeat_until`/`repeat_time` 推导)标注,保持条目仍只含主题/时间/参与人三项。禁止仅展示日程条目而不说明其为周期日程。
- **参与人展示**:`list` 返回的 `attendees` 格式为 `[{"userid": "USERID", "name": "englishname(name)"}]`,直接取 `name` 字段展示,禁止展示 userid,无需反查通讯录。

## 参考

- [wecomcli-calendar](../SKILL.md) — 日程技能主文档
- [calendar-search](calendar-search.md) — 按关键词搜索日程
references/calendar-cancel.md
# calendar schedules cancel — 取消日程

取消用户发起的日程。**暂不支持取消周期日程**,识别到周期日程时应告知用户并引导其在企业微信客户端操作(见下文工作流与注意事项)。

> [!CAUTION]
> 这是**写入操作** — 参数就绪后直接执行。

## 命令

```bash
# 取消普通日程
wecom-cli calendar schedules cancel --json '{"schedule_id": "<schedule_id>"}'
```

## 参数

| 参数 | 类型 | 必填 | 说明 |
|------|------|:----:|------|
| `schedule_id` | string | 是 | 要取消的日程 ID(由 search/list 返回,格式不固定,直接透传即可) |

**返回**:成功时返回空对象 `{}`,这是正常结果,不代表失败。收到空对象即可告知用户取消成功。

## 定位目标时的跨载体消歧(模糊取消)[REQUIRED]

用户说"取消那个会 / 取消 xx 会 / 把那个会取消掉"等模糊表述、未明确是日程还是在线会议时,**不要只在日程里找**——「会」可能是一条纯日程,也可能是含在线会议链接的会议,只查一边会漏定位:

- **明确是日程 / 安排**(说的是"日程 / 安排 / 我的日历"且不带在线会议特征)→ 只在本技能 `search`/`list` 定位,走 `schedule cancel`。
- **明确是在线会议**(提到入会链接 / 会议号 / 视频会议 / 腾讯会议 / 远程参会等专属特征)→ 改用 `读取 wecomcli-meeting 技能` 在会议里定位并 `meeting cancel`。
- **模糊无法判定** → 日程和会议**两边都查**:本技能 `search`/`list` + `读取 wecomcli-meeting 技能` 用同样关键词 / 时间查会议,合并候选、按"主题 + 时间"去重(同一场两边都命中只保留一条),再用文字让用户**选定要取消的唯一一条**;选定后按其归属路由——是纯日程 → `schedule cancel`;是会议(或两边都命中的同一场)→ 改用 `读取 wecomcli-meeting 技能` 走 `meeting cancel`(会连带取消关联日程,禁止再对该日程调用 `schedule cancel`)。

> **与查询消歧的区别**:查询时可以两边都查、都展示;但取消是**写操作,绝不能两边都直接取消**,模糊时必须先让用户确认唯一目标,再执行对应的 cancel。

## 取消日程工作流

```
用户发起取消意图
    |
    +-- 搜索目标日程
    |   +-- 有关键词 → search(不追问时间)
    |   +-- 有时间信息 → list 按时间范围查询
    |   +-- 都没有 → 用文字询问引导用户补全缺失的参数
    |
    +-- 匹配结果处理
    |   +-- 唯一匹配 → 继续
    |   +-- 多条匹配 → 用文字让用户选择目标日程:
    |   |     文字提问:"找到多个匹配日程,请选择要取消的一个:"
    |   |     列出候选(如"项目评审 - 4月8日 14:00 / 项目评审 - 4月15日 14:00",最多 4 条)
    |   +-- 无匹配 → 扩大搜索 / 提示换关键词
    |
    +-- 判定会议关联与周期性:meeting 与 repeat_rule 在 search/list 结果中已直接返回,直接判定,无需补 get
    |   +-- meeting.meeting_code 非空(含在线会议链接)
    |   |     +-- 改期意图(改约/挪到/顺延,即使带"取消")→ 改用 `读取 wecomcli-meeting 技能`,把 meeting_id 传入 meeting update 改时间
    |   |     +-- 纯取消(不办了/不要了)→ 改用 `读取 wecomcli-meeting 技能` 走 meeting cancel(会连带取消关联日程,禁止在此 schedule cancel)
    |   +-- meeting 为空(纯日程)
    |         +-- 普通日程 → 直接执行 cancel
    |         +-- 周期日程(repeat_rule.is_repeat=true)→ 终止操作,用文字告知用户:"目前暂不支持取消周期日程,请在企业微信客户端对该日程进行取消操作",禁止改为整系列直接 cancel 或其他变通方式
    |
    +-- 执行 cancel(不论日程由谁创建,都直接执行,不提前拒绝)→ 依返回结果判断:
          +-- 返回空对象 {} → 取消成功,报告结果
          +-- 返回权限类错误 → 说明当前用户无权取消该日程,告知用户并建议联系日程创建人(creator_name)操作
```

## 典型场景

### 1. 取消普通日程

```
用户:帮我取消明天的项目评审
→ 调用 search(keywords=["项目评审"],明天)
→ 找到 1 条
→ 调用 cancel(schedule_id)
→ 报告:已取消
```

### 2. 取消周期日程(不支持)

```
用户:帮我取消这周五的周会
→ 搜索 → 找到"团队周会"(周期日程,repeat_rule.is_repeat=true)
→ 不调用 cancel → 告知:目前暂不支持取消周期日程,请在企业微信客户端对该日程进行取消操作
```

### 3. 取消非本人创建的日程

不预先按"是否本人创建"拦截,直接执行 cancel,根据返回结果判断。
```
用户:帮我取消明天张三约的评审
→ 搜索 / get → 找到日程(创建人是张三)
→ 不提前拒绝 → 直接调用 cancel(schedule_id)
→ 依返回判断:
    · 返回 {} → 报告:已取消
    · 返回权限错误 → 告知:你无权取消该日程,建议联系创建人张三操作
```

## 注意事项

- **权限判定交给接口**:不预先按"是否本人创建"限制取消——直接执行 `cancel`,根据返回结果判断:返回空对象 `{}` 即取消成功;返回权限类错误则说明当前用户无权取消该日程,告知用户并建议联系创建人操作。
- **直接执行**:参数就绪后直接调用取消接口,无需展示摘要或等待确认。
- **周期日程不支持取消**:检测到目标日程 `repeat_rule.is_repeat=true` 时,直接告知用户目前暂不支持取消周期日程,引导其在企业微信客户端操作,禁止改为整系列直接 cancel 等变通方式(详见 [SKILL.md 已知限制](../SKILL.md))。
- **"取消……改约到……"是改期、不是取消**:同时出现"取消"和"改到/改约/挪到/顺延"时本质是改期,按 SKILL.md「改约 / 重建日程前必须先识别会议关联」走更新流程,禁止拆成 cancel + create(目标 `meeting` 非空时,cancel + create 会丢失会议链接)。仅用户明确"不办了/不要了/直接取消"且无改期诉求时才执行 cancel。
- **关联在线会议的日程不在此取消**:若目标 `meeting` 非空(`search`/`list` 结果即可判定,无需补 `get`;或同一场在会议和日程两边都命中),改用 `读取 wecomcli-meeting 技能` 走 `meeting cancel`,会议取消后该日程会被一并取消,禁止在此对其调用 `schedule cancel`。
- **禁止暴露 userid**:结果展示中参与人只显示人名。

## 参考

- [wecomcli-calendar](../SKILL.md) — 日程技能主文档
- [calendar-search](calendar-search.md) — 搜索日程(用于定位目标日程)
- [calendar-agenda](calendar-agenda.md) — 查看日程安排
references/calendar-create.md
# calendar schedules create — 创建日程

创建日程并按需邀请参与人。

> [!CAUTION]
> 这是**写入操作** — 参数就绪后直接执行。

## 命令

```bash
# 创建日程(含参与人)—— 以"明天下午2点"为例,实际日期需替换为当前时间之后的具体值
wecom-cli calendar schedules create --json '{
  "subject": "产品评审",
  "begin_time": "<明天日期> 14:00:00",
  "end_time": "<明天日期> 15:00:00",
  "attendees": [{"userid": "woxxxa"}, {"userid": "woxxxb"}]
}'

# 仅自己的日程(无参与人)
wecom-cli calendar schedules create --json '{
  "subject": "午餐",
  "begin_time": "<明天日期> 12:00:00",
  "end_time": "<明天日期> 13:00:00"
}'

# 全天日程
wecom-cli calendar schedules create --json '{
  "subject": "年假",
  "begin_time": "<目标日期> 00:00:00",
  "end_time": "<目标日期> 23:59:59",
  "is_all_day": true
}'

# 创建日程并预订会议室(meeting_room_id 来自 rooms search,见 calendar-meeting-room;订房成功后只传 meeting_room_id,无需再把会议室名重复填进 location)
wecom-cli calendar schedules create --json '{
  "subject": "产品评审",
  "begin_time": "<明天日期> 14:00:00",
  "end_time": "<明天日期> 15:00:00",
  "attendees": [{"userid": "woxxxa"}, {"userid": "woxxxb"}],
  "meeting_room_id": "mrmxxxx"
}'
```

## 参数

| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|:----:|--------|------|
| `subject` | string | 是 | — | 日程主题 |
| `begin_time` | string | 是 | — | 开始时间(格式 YYYY-MM-DD HH:mm:ss,**必须晚于当前时间**) |
| `end_time` | string | 是 | — | 结束时间(格式 YYYY-MM-DD HH:mm:ss,必须晚于 `begin_time`)。如果用户没有给出,默认填写开始时间的一小时后 |
| `attendees` | object[] | 否 | `[]` | 参与人列表,格式为 `[{"userid": "woxxx"}, {"userid": "woyyy"}]`。用户提供的是姓名时通过 `读取 wecomcli-contact 技能` 解析为 userid |
| `location` | string | 否 | `""` | 地点(文本)。**用户给的地点是某会议室时**:须先经 `rooms search` 预订该会议室(见步骤 3.5),预订成功后**只传 `meeting_room_id`、不再写 `location`**(会议室名由后端关联返回,无需在 `location` 里重复填充)。**用户给的地点不是会议室时**(如"星巴克""3 楼茶水间""客户现场"):直接写入 `location`,不涉及 `meeting_room_id`。禁止把会议室名仅写进 `location` 却不订房——那样不会真正占用会议室 |
| `meeting_room_id` | string | 否 | — | 会议室 ID,来自 [calendar-meeting-room](calendar-meeting-room.md) 的 `rooms search`。传入即触发后端"建日程 + 占会议室"原子操作。订会议室时只传本字段即可,**不需要再把会议室名重复填进 `location`**。**该 ID 仅工具链使用,禁止出现在用户回复正文** |
| `description` | string | 否 | `""` | 日程描述 |
| `reminders` | object | 否 | — | 提醒设置:`is_remind`(bool,是否提醒)+ `reminder_time`(负数秒数组,表示提前提醒的秒数)。**仅在用户明确表达提醒意图时才传**:① 用户明确说"不提醒 / 不用提醒"→ 传 `is_remind=false`;② 用户明确了提前多久提醒(如"提前 10 分钟""提前 1 小时")→ 传 `is_remind=true`,并把时长换算为对应的负数秒数组(如提前 10 分钟 = `[-600]`、提前 1 小时 = `[-3600]`)。用户未提及提醒时省略本字段,不要自行补默认提醒 |
| `timezone` | object | 否 | 用户 vid 时区 | 时区:`timezone_id`(如 `Asia/Shanghai`)+ `timezone_offset`(秒,如 `28800`) |
| `allow_self_join` | bool | 否 | `true` | 是否允许主动加入 |
| `is_all_day` | bool | 否 | `false` | 是否全天日程 |

**返回**:`schedule_id`(新建日程的唯一标识)。传了 `meeting_room_id` 时额外返回 `meeting_room.{meeting_room_id, meeting_room_name}` 关联字段(展示用 name)。

### 地点(`location`)vs 会议室(`meeting_room_id`)的区别 [CRITICAL]

两者都描述"在哪开",但语义和处理方式不同,按用户给的地点是否为**会议室**分流:

| 用户 query 中的地点 | 处理方式 | 传入字段 |
|--------------------|---------|---------|
| **是某会议室**(如"地点在 1605 会议室""在 A 座创新室开") | 必须先经 `rooms search` 尝试预订该会议室(见步骤 3.5)。预订成功(`status=bookable`)→ 拿到 `meeting_room_id`;不可用 → 走候选/换时间流程 | 预订成功后**只传 `meeting_room_id`**(占用会议室);`location` 留空、不重复填会议室名 |
| **不是会议室**(如"星巴克""3 楼茶水间""客户现场""线上腾讯会议"等自由文本地点) | 直接作为文本地点使用,无需也不要走会议室查询 | 仅传 `location`,不传 `meeting_room_id` |

- **判定原则**:地点文本中含"会议室 / 室 / 房间 / 1605 这类房间号 / 某楼某室"等指向公司可预订会议室的表述,按"会议室"处理;否则按普通文本地点处理。无法判断时,可用文字与用户确认"是否需要预订该会议室"。
- **关键约束**:会议室场景下严禁只写 `location` 不传 `meeting_room_id`——只写文本不会真正占用(预订)会议室,会导致会议室被他人占用。

## 预约日程工作流

> **设计理念**:减少用户决策负担——能推断的不问,必须问的只问一次,决策留给用户而非代劳。

### 步骤 0:日程 / 会议消歧(仅当意图是"开会/约会"且未明确时)

> **触发条件**:用户说"会议/会/开个会/约个会/安排个会/xx会/xx会议"等,但未明确是日程还是会议(会议含在线会议链接、可远程/视频参会)。已明确是纯线下场景(如"约个 1:1""碰个面")时才跳过本步骤。
>
> **注意 1**:用户只说"会议/会/开会"等泛称,本身不构成"明确"——这些词没有表明是日程还是会议,**禁止仅因 query 里有"会议"二字就默认按日程创建、跳过本步骤**,必须先用文字追问。
>
> **注意 2**:仅给出地点/会议室号的表述(如"在 1605 开会""到 A 座会议室碰一下""订个会议室开会")不能据此判定为日程——订会议室与是日程还是会议是两件事,会议室里同样可能要远程接入。此类"只有地点"的表述仍需先用文字询问消歧,不要因为带了地点就跳过本步骤。

用文字直接询问用户创建日程还是会议,禁止默认直接创建日程:

> **问题与选项固定 [CRITICAL]**:消歧确认时,问题与可选项都必须原文照用、严格禁止修改任何内容——问题固定为 `"需要创建日程还是会议?"`,可选项固定为 `日程` / `会议`;不得改写问题措辞、增减或改写选项、翻译,或自行设计其他表述(如"在线会议 / 线上会议 / 视频会议 / 线下会议"等)。

用文字向用户提问:`需要创建日程还是会议?(请回复:日程 / 会议)`

- 用户答「日程」→ 留在本技能,继续步骤 1。
- 用户答「会议」→ 停止本工作流,改用 `读取 wecomcli-meeting 技能` 创建会议(创建会议会同时生成对应日程,无需在本技能再建一条)。

### 步骤 1:上下文提取与信息补全

- **主题**:默认必须用文字询问主题,**禁止从对话语境自行提炼或代填**。仅当用户已明确说明主题(如"建个『产品评审』的日程""主题就叫周会")时,才直接使用用户给出的主题、不再询问;只要用户没点明主题(哪怕能从事由猜出来,如"和张三约下午聊聊"),一律用文字询问:`请问这个日程的主题是?`(可举例"需求对齐 / 方案评审 / 1:1 沟通"等供参考,最多举 4 个,用户也可自行输入)
- **时长**:用户明确说了时长则直接使用;未提供时,统一默认 60 分钟(1 小时),不追问,由 `begin_time + 时长` 推算 `end_time`。
- **参与人**:用户明确指定了参与人则解析使用(人名 → userid,见步骤 2);未提供时必须用文字追问,禁止默认创建个人日程或自行猜测:`需要邀请哪些人参与?`(可列出"仅自己(个人日程)"及根据对话语境补充的 1-3 个候选人名供参考,合计最多 4 个,用户也可自行输入)

### 步骤 2:参与人解析(人名 → userid)

> 上下文中已有合法 userid(`wo` 前缀)则直接使用,无需重复查询。

用户提供的是姓名时,通过 `读取 wecomcli-contact 技能` 将所有姓名批量搜索,逐个关键词独立处理结果:

- **唯一匹配** → 直接使用,无需确认
- **多个匹配** → 用文字让用户选择,不自行猜测:`搜索到多个「{姓名}」,请确认要邀请哪一位?` 并列出候选(如"张三 - 产品部 - 产品经理 / 张三 - 技术部 - 前端工程师",最多列 4 条,超出取前 4 并提示用户缩小范围)
- **无结果** → 用文字提示用户重新输入:`未找到「{姓名}」,请确认姓名是否正确`
- 所有姓名确认完毕后,汇总 userid 一并组装为 `attendees` 数组(格式 `[{"userid": "woxxx"}]`)
- **偏好记忆**:记录用户历史选择(如"张三"总是选"产品部-张三"),后续同名直接复用,减少确认轮次。

### 步骤 3:时间协商与冲突处理

不区分日程类型(不存在"生活类/工作类"之分,也没有"纯个人事项"可跳过的说法),所有创建一律查忙闲:查询对象始终包含当前用户自己(新建场景自己也要纳入,避免把日程排到自己已占用的时段),有其他内部参与人时一并纳入。按用户给出的时间信息分三种处理:

> 边界说明:这里"自己按完整目标时段查"是因为新建日程尚不存在、没有"本日程已占时段"需要排除;这与 update 改已有日程时"对自己/现有参与人扣除原时段重叠、纯自己可跳过"是同一原则(忙闲只为发现本日程之外的冲突)在"日程未建 / 已存在"下的不同表现,不要把 update 的"纯自己跳过"套到新建上。
>
> 查忙闲时 `min_duration_minutes` 设成该日程时长(或直接传 1),否则被默认 30 分钟过滤掉的短空闲段,会让落在其中的短日程误报为冲突。
>
> **推荐时段长度 ≠ 日程时长(精确 / 范围 / 未提供时间三种情况均适用)**:忙闲查询返回的推荐时段只用于确定日程的**开始时间**,其长度不代表日程时长。用户选定时段后,日程时长仍以用户明确指定的为准;用户未明确时长时一律默认 1 小时(`begin_time + 1h`,与步骤 1「时长」一致),禁止把推荐时段的长度直接当作日程时长。

- 精确时间(如"明天下午3点"):先验证晚于当前真实时刻,已过去则提示用户重选未来时间;时间有效后必须先读取 [calendar-freebusy](calendar-freebusy.md) 检查忙闲(查询对象 = 自己 + 其他内部参与人)。任一对象(含自己)占线时,必须用文字让用户在「坚持这个时间 / 换一个时间」之间二选一,禁止自行换时间或劝阻用户改期:`该时间段{姓名}有日程冲突,如何处理?(请回复:坚持这个时间 / 换一个时间)`(仅与自己冲突时 {姓名} 写"你")
- 范围时间(如"明天"、"下午"):读取 [calendar-freebusy](calendar-freebusy.md)(查询对象 = 自己 + 其他内部参与人)拿到空闲 `slots`,让用户从返回的空闲时段中选择,选定后再创建(无需手填候选时刻,直接用 free list 返回的时段)。
- 未提供时间:先用文字列出具体的"日期+时刻"候选项让用户选择(结合当前时间动态推断,所有候选项必须晚于当前时刻,禁止使用"上午/下午/傍晚"等模糊表述,最多列 4 个);用户选定具体时刻后,按上面"精确时间"的方式查忙闲再创建。文字提问如:`日程什么时候开始?`(候选按当前时刻动态生成、均须晚于现在,例如当前 19:40 可列 "明天 09:00 / 明天 14:00 / 明天 16:00 / 后天 09:00")

**时间与日期推断规范:**
- **星期基准**:周一是一周第一天,周日是最后一天。
- **整天范围**:"明天"、"今天"覆盖 00:00:00 ~ 23:59:59。但"今天"作为候选范围时,只能推荐晚于当前时刻的具体时间点;若当天已无合适时段,自动顺延到明天。
- **全天日程(`is_all_day=true`)**:不主动猜测事件类型。仅在以下情形才设 `is_all_day=true`:① 用户明确说是"全天 / 请一天假 / 休一天";② 起止时间实际就是完整一天,即 `begin_time="YYYY-MM-DD 00:00:00"`、`end_time="同一天 23:59:59"`。其余情况(给了具体时刻、或时段不满整天)一律按普通定时日程处理,不设全天。
  - **格式必须落在同一天、从早到晚**:`begin_time="YYYY-MM-DD 00:00:00"`、`end_time="同一天 23:59:59"`。禁止写成次日 0 点(`YYYY-MM-DD+1 00:00:00`)——那样不带 `is_all_day` 会显示成"0 点到 0 点",带了又会多占一天。
  - **跨多天的全天事件**拆成 N 条单日全天,每条仍是同一天 `00:00:00 ~ 23:59:59`,逐条调用 create 分别创建。
- **历史约束**:不能创建已完全过去的日程,推荐的时间必须晚于当前时刻。
- **时间格式**:统一 `YYYY-MM-DD HH:mm:ss`。
- **模糊时间表达**:遇到"上班后"、"下班前"等表达,必须用文字询问引导用户补全,禁止猜测。澄清后将结果沉淀为长期偏好(如"上班后"=9:30),后续同类表达直接复用。
- **时区处理**:默认不传,由服务端使用用户 vid 时区。用户明确指定时区时,传入 `timezone_id`(IANA 时区名称,如 `"America/New_York"`)+ `timezone_offset`(与 UTC 的偏移秒数)。传入的 `begin_time` / `end_time` 按日程时区解释为墙上时间,禁止自行换算。

> **长期记忆**:用户的时间偏好、常见主题偏好等,在首次明确后应记忆,减少后续重复追问。(时长不在此列:用户未指定时一律默认 1 小时、不追问。)

### 步骤 3.5:会议室预订分支(仅当用户有订房意图时触发)

> **触发条件**:用户提到"订会议室 / 在 1605 / 找个会议室 / 某栋办公楼的会议室"等订房意图时才走本步骤;没提则跳过,按普通日程创建。
>
> **前置**:本步骤依赖确定的 `begin_time` / `end_time`,必须在步骤 3 时间敲定之后执行(范围时间先经 freebusy 选定时段)。

会议室的查询接口(楼清单 + 可订性)定义在 [calendar-meeting-room](calendar-meeting-room.md),按其编排执行,拿到 `meeting_room_id` 后回填到本创建的 `meeting_room_id` 参数。

> [!CAUTION]
> **五条硬性规则(不可跳过):**
> 1. **先查询、后推荐、后创建**:要预订会议室时,`meeting_room_id` 必须来自 `rooms search` 返回的真实值,禁止跳过会议室查询直接 create,禁止凭记忆 / 上下文 / 猜测编造 `meeting_room_id`——没有先查到真实 ID 就不允许带 `meeting_room_id` 创建。同样地,在成功调用 `rooms search` 之前,禁止凭记忆 / 上下文 / 想象向用户罗列或推荐任何具体会议室(含用文字给出的候选、正文里的房间名 / 号 / 楼层 / 容量)——要让用户选会议室,必须先查到真实候选再组装选项。
> 2. **存在多个会议室必须让用户选**:当查询结果命中多个可选会议室(`recommendations` 条目数 > 1,或用户未指定具体会议室而返回了多个候选)时,必须用文字让用户从候选中选择,或让用户指定具体会议室,禁止自动替用户挑选(如默认取第一个)。
> 3. **会议室必须订房、且只传 `meeting_room_id`**:只要用户给的地点是会议室("订会议室 / 在 1605 开 / 找个会议室 / xx 楼会议室"等),就必须走 `rooms search` 查到真实会议室并通过 `meeting_room_id` 参数传入创建;严格禁止把会议室名 / 房间号仅塞进 `location` 字段就创建(那样不会真正占用会议室)。预订成功后创建时**只传 `meeting_room_id`**(占用),**不需要再把会议室名重复填进 `location`**(会议室名由后端关联返回)。仅当用户给的是非会议室的普通地点(如"星巴克")时,才只写 `location`、不走订房。
> 4. **优先先订房、后建程**:用户在创建时就提到会议室的,应先把会议室敲定(拿到用户确认的 `meeting_room_id`)再进入步骤 4 创建日程,本步骤(3.5)是步骤 4 的前置阻塞项,避免创建后会议室被抢占。若会议室查询 / 选择尚未完成(如等待用户在候选中选择、等待用户确认换楼或换时间),必须停在本步骤等待,不得提前调用 create。若创建时漏订或事后要换会议室,可走 [calendar-update](calendar-update.md) 传入新 `meeting_room_id` 改订(须先经 `rooms search` 确认 `status=bookable`),不必取消重建。
> 5. **指定会议室查无/不可用时必须先告知、禁止静默替换**:用户指定的会议室在 `target` 中找不到可订项(`target = []` 查无此名,或命中项均为 `unavailable` 该时段被占)时,必须先告知用户"未查到 / 无法预订你指定的『xxx』会议室",再用文字让用户决定改订其他会议室或换时间。严禁静默用其他名称的会议室替代——即使 `recommendations` 仅 1 个候选也须经用户确认。"`recommendations` 仅 1 个可直接使用"只适用于用户未指定具体会议室(`target = []`)的情形。

1. 用户提了楼名 → `buildings list` + LLM 匹配得到 `building_city/name`;没提楼则跳过(后端用当前所在楼兜底)。
2. `rooms search`(带时间 + 可选楼 + 可选 `room_keyword` + `min_capacity = len(attendees) + 1`)。
3. 按结果决策:
   - 用户**指定了具体会议室**(传了 `room_keyword`)且 `target` 中有 `bookable` 项 → 取该项 `target[].room.meeting_room_id`(仅 1 个直接用,多个则用文字让用户选)。
   - 用户**指定的会议室** `target = []`(查无此名)或命中项均 `unavailable`(该时段被占):先告知用户"未查到 / 无法预订你指定的『xxx』会议室",再用文字让用户决定改订其他会议室或换时间。禁止用其他名称的会议室静默替代——`recommendations` 仅 1 个候选也须经用户确认才改订;`recommendations` 为空则告知后问是否跨楼(`expand_to_other_buildings=true` 重试)或换时间。
   - 用户**未指定具体会议室**(`target` 为 `[]`):
     - `recommendations` 有**多个**候选 → 必须用文字让用户选择(候选取前 2~4 个,展示会议室 `name` + 楼层 + 容量,`meeting_room_id` 不得出现在文案中)。
     - `recommendations` 只有 **1 个**候选 → 可直接使用该候选的 `meeting_room_id`。
     - `recommendations` 为空 → 用文字问用户是否跨楼(`expand_to_other_buildings=true` 重试)或换时间。
4. 选定后将用户确认的 `meeting_room_id` 带入步骤 4 的 create,**只传 `meeting_room_id` 即可**(无需再把会议室名重复填进 `location`)。

> **换会议室走 update**:创建后要换会议室时,用 [calendar-update](calendar-update.md) 传入新 `meeting_room_id` 改订即可(须先经 `rooms search` 确认新会议室 `status=bookable`),无需取消重建。

### 步骤 4:执行创建

参数就绪后直接执行 create 命令,无需展示摘要或等待确认。

### 步骤 5:结果反馈

创建成功后拿到返回的 `schedule_id`,调用日程详情查询 `wecom-cli calendar schedules get --json '{"schedule_ids": ["<schedule_id>"]}'`(见 [calendar-agenda](calendar-agenda.md))获取 `subject`、`begin_time`/`end_time`、`attendees[].name`,据此输出。**输出内容只包含三部分:主题、时间、参与人**,禁止输出其他任何内容和额外语句(不展示地点、提醒、`schedule_id` 等字段,也不附加说明、建议或寒暄)。参与人原样取接口返回的 `attendees[].name` 展示(完全与接口返回的格式保持一致,如返回 `zhangsan(张三)` 就展示 `zhangsan(张三)`),禁止暴露 userid;非东八区日程按 [SKILL.md 输出格式规范](../SKILL.md) 的时区标注规则在时间后带上时区。

```
主题:{subject}
时间:{月日} {HH:mm}-{HH:mm}
参与人:{人名1}、{人名2}
```

## 典型场景

### 1. 简单创建

```
用户:帮我和张三约明天下午3点,聊半小时
→ 通过 wecomcli-contact 技能搜索「张三」→ 返回 2 个候选
→ 用文字询问:搜索到多个「张三」,请确认要邀请哪一位?(列出:张三 - 产品部 - 产品经理 / 张三 - 技术部 - 前端工程师)
→ 用户选择后,获得对应 userid
→ 用户未说明主题 → 用文字询问主题(禁止自行起名):请问这个日程的主题是?(可举例:需求对齐 / 1:1 沟通 / 项目同步)
→ 组装参数:subject=<用户选定/填写的主题>,begin_time="<明天日期> 15:00:00",end_time="<明天日期> 15:30:00"
→ 调用 create
```

### 2. 约多人(先查共同空闲)

```
用户:帮我约张三和李四明天下午聊一下
→ 通过 wecomcli-contact 技能批量搜索「张三」「李四」
→ 逐个处理:唯一匹配直接使用,多个匹配则用文字让用户选择
→ 调用 free list 拿明天下午的共同空闲 slots
→ 比较 slots[0].available_count 与 total_count 判断是全员空闲 / 降级 / 全忙
→ 挑前几个时段让用户选择
→ 用户选择方案 → 调用 create
```

详细的共同空闲查询与降级处理流程见 [calendar-freebusy](calendar-freebusy.md)。

## 注意事项

- **参与人 userid**:userid 为 `wo` 前缀的编码字符串(如 `woxxx`),`attendees` 传入时需组装为对象数组格式 `[{"userid": "woxxx"}]`。用户提供的是姓名时通过 `读取 wecomcli-contact 技能` 解析为 userid;禁止把姓名当 userid 拼接,禁止凭记忆或猜测编造。
- **时间约束**:`begin_time` 必须晚于当前时间,否则创建失败;`end_time` 必须晚于 `begin_time`。禁止推荐或传入已过去的时间。
- **无时长上限**:`end_time` 只需晚于 `begin_time`,支持创建时长超过 24 小时、跨天或多天的单条定时日程,无需拆分(全天日程 `is_all_day=true` 仍按前文全天日程规范按单日 `00:00:00~23:59:59` 拆分,这是全天格式要求、与时长无关)。
- **不支持创建周期/重复日程**:API 仅支持创建单次日程。用户希望创建"每周/每月/每天重复"等周期日程时,**直接告知用户目前不支持创建周期日程,并引导用户在企业微信客户端手动预订周期日程**;不要尝试任何变通绕过的做法——包括但不限于:创建多条单次日程模拟周期效果、传入 `repeat_rule` 等参数表中未列出的字段、创建后再用 `update` 改造为周期日程。原因:API 层根本无此能力,伪造的"周期"日程在企微客户端中也无法被识别为周期,反而会造成多条独立日程难以批量管理。
- **会议室预订**:用户给的地点是会议室时,必须先经 [calendar-meeting-room](calendar-meeting-room.md) 的 `rooms search` 查到真实会议室并以 `meeting_room_id` 传入创建(见步骤 3.5),禁止把会议室名仅写进 `location`(那样不会真正占用会议室);订房成功后只传 `meeting_room_id`、不重复填 `location`。仅当用户给的是非会议室的普通文本地点时才只写 `location`、不走订房。
- **直接执行**:参数补全后直接调用创建接口,无需展示摘要或等待确认。
- **禁止暴露 userid**:结果展示中只显示人名。
- **参数补全原则**:缺失的必填参数(`subject` / `begin_time` / `end_time`)以及参与人 `attendees` 必须用文字询问。其中 `subject` **仅在用户已明确说明主题时才算"已提供"可直接用,否则一律视为缺失、必须询问,禁止用对话语境自行提炼代填**。其余非必填参数用户未明确指定时不追问——有默认值的走默认值,无默认值的则不传该字段(如地点不填、提醒不设置)。

## 异常路径

| 异常情况 | 处理方式 |
|---------|---------|
| `begin_time` 早于当前时间 | 提示用户时间已过,请重新选择未来时间,不重试,等待用户修正 |
| `end_time` 不晚于 `begin_time` | 提示用户结束时间必须晚于开始时间,请调整 |
| wecomcli-contact 技能搜索无结果 | 用文字提示用户重新输入:`未找到「{姓名}」,请确认姓名是否正确` |
| wecomcli-contact 技能返回多个候选人 | 用文字询问用户(列出候选姓名 + 部门),等待用户选择后汇总继续 |
| 创建接口返回错误 | 检查参数格式,重新阅读本文档确认用法 |
| `meeting_room_taken`(会议室被抢占) | 查询通过后、create 前会议室被他人占走。用文字告知"{会议室名} 刚被占用",让用户在「换会议室 / 换时间」二选一;选换会议室则重走步骤 3.5 的 `rooms search`,禁止静默重试同一会议室 |
| `meeting_room_not_found`(会议室无效) | `meeting_room_id` 不存在或上下文已过期,重新走步骤 3.5 的 `rooms search` |

## 参考

- [wecomcli-calendar](../SKILL.md) — 日程技能主文档
- [calendar-freebusy](calendar-freebusy.md) — 查询忙闲状态
- [calendar-meeting-room](calendar-meeting-room.md) — 会议室查询(订房时拿 `meeting_room_id`)
references/calendar-freebusy.md
# calendar schedules free list — 查询参与人共同空闲

查询同企业成员在指定时间窗口内的共同空闲时段。直接返回可推荐的时段列表。

## 输出前必检(CRITICAL)

**任何 free list 触发的回复,最终对外文本都必须满足**:
- 不出现 `wo` 前缀字符串(userid 仅用于工具调用,对用户只显示姓名 / 别名)
- 不出现 `mt_` / `td_` / `wo_` / `doc_` / `room_` 等内部 ID 前缀

多人查询时尤其容易在"对齐姓名↔userid"中无意泄露——展示阶段如果你写到 `wo` 字符,
**立即停下重写**,只保留姓名(来自 `available_users[].name`)。

## 命令示例

```bash
# 查询 woxxx 和 woyyy 在 2026-04-07 09:00:00 和 2026-04-07 18:00:00 之间的空闲时段,并且必须是60分钟整块的 
wecom-cli calendar schedules free list --json '{
  "userids": [{"userid": "woxxx"}, {"userid": "woyyy"}],
  "begin_time": "2026-04-07 09:00:00",
  "end_time": "2026-04-07 18:00:00",
  "min_duration_minutes": 60,
  "limit": 5
}'
```

## 参数

| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|:----:|------|------|
| `userids` | object[] | 是 | — | >=1 个成员,对象数组格式 `[{"userid": "woxxx"}]`(`wo` 前缀)。允许单人调用,等价于"某人什么时候有空"查询 |
| `begin_time` | string | 是 | — | 查询窗口起,格式 `YYYY-MM-DD HH:mm:ss`。早于服务端当前时刻的部分会被自动截断 |
| `end_time` | string | 是 | — | 查询窗口止,必须晚于 `begin_time`,且与 `begin_time` 的间隔 ≤ 24 小时 |
| `min_duration_minutes` | int | 否 | `30` | 过滤掉短于该值的空闲段,避免推荐过碎的时间窗 |
| `strategy` | string | 否 | `max_attendees` | 推荐策略,详见下表 |
| `limit` | int | 否 | `10` | 返回时段数量上限 |

### `strategy` 取值

| 值 | 行为 | 状态 |
|----|------|------|
| `max_attendees` | 按最多可参与人数筛选,只返回最高一档人数的所有时段,同档内按时间升序。有共同空闲时即全员到场窗口;无共同空闲时自然降级为次大可达人数。 | 当前唯一实现,默认值 |

## 返回结构

```json
{
  "total_count": 2,
  "strategy": "max_attendees",
  "extra_info": "没有找到所有人都空闲的时段,下面是符合 strategy 规则的时间段",
  "slots": [
    {
      "begin_time": "2026-04-07 13:00:00",
      "end_time":   "2026-04-07 14:00:00",
      "available_users": [
        {"userid": "woxxx", "name": "张三"},
        {"userid": "woyyy", "name": "李四"}
      ],
      "available_count": 2,
      "busy_users": []
    }
  ]
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `total_count` | int | 本次查询的有效人数 |
| `strategy` | string | 服务端实际采用的策略 |
| `extra_info` | string | 服务端提示文案。**降级场景**会说明"没有全员都空闲,下面是按 strategy 筛选的最佳时段"等内容,可作为措辞参考 |
| `slots[]` | array | 推荐的空闲时段,已按策略筛选、已过滤过去时段、已应用 `min_duration_minutes` |
| `slots[].begin_time` | string | 时段起始时间,格式 `YYYY-MM-DD HH:mm:ss`,与请求参数同格式,可直接展示 |
| `slots[].end_time` | string | 时段结束时间,格式 `YYYY-MM-DD HH:mm:ss` |
| `slots[].available_users` | array | 该时段内空闲的人(`userid` + `name`)。**展示时只用 `name`,禁止暴露 userid** |
| `slots[].available_count` | int | 该时段内空闲人数 |
| `slots[].busy_users` | array | 该时段内忙碌的人(`userid` + `name`)。`max_attendees` 全员命中时为空,降级时列出冲突人 |

> 展示时段前心算一次 `end_time - begin_time` 的分钟数,确认 ≥ 请求传入的
> `min_duration_minutes`(默认 30),避免把短时段的时长说宽。


### 指定重要优先人物优先的查询

如果用户希望查询一批人的空闲时间,但是优先其中某个子集(重要人物)必须空闲(不重要的人可以不空闲导致缺席),可以先单独查询重要人物的空闲时间,再查询全员的空闲时间。再推荐一个合适的时间。

### 分支判断(一次请求覆盖三种情况)

拿到响应后,比较 `slots[0].available_count` 与 `total_count`:

| 情况 | 含义 | Agent 行为 |
|------|------|-----------|
| `slots[0].available_count == total_count` | 存在全员共同空闲 | 展示所有可用时段让用户选择 |
| `0 < slots[0].available_count < total_count` | 无全员共同空闲,服务端已降级到"最多人能到"的窗口 | **先告知用户哪些人冲突、几人能参加**,再展示时段,让用户决定继续还是换时间 |
| `slots == []` | 查询窗口内没有任何符合最小粒度的可用时段 | 不要硬推荐,引导用户**扩大时间范围或减少参与人** |

> 一次请求已覆盖正常 / 降级 / 全忙三种语义,**不要发起第二次"降级查询"**。

> **查询"某时段有没有空"时,忙碌也要如实响应**:当用户问的是特定时间段的忙闲(如"张三下午 3 点有空吗""明天上午大家都在吗"),若该时段没有空闲(`slots` 为空)、或被问的人不在该时段的 `available_users` 里,必须明确回复"该时段忙 / 已有安排",并尽量点明是谁忙(取 `busy_users[].name`)、忙在哪一段;不要只报空闲时段,也不要用"无共同空闲"一笔带过而不点明忙碌状态。

### 切片与展示

- **推荐时段按 1 小时维度切分**:`slots` 返回的可用空闲段,若长度超过 1 小时,须在 Agent 侧按 1 小时粒度切成多个候选时段分别推荐(如空闲段 `15:00-18:00` 切为 `15:00-16:00`、`16:00-17:00`、`17:00-18:00`),每个候选统一按整 1 小时呈现;不足 1 小时的空闲段按其实际长度原样展示。查询时建议传 `min_duration_minutes=60`,避免推荐出不足 1 小时的碎片段。
- **候选起点不得越界(起点 ≤ 段终点 − 日程时长)[REQUIRED]**:候选切片的长度只是展示粒度,用户选中后实际占用的是「起点 + 完整日程时长」。因此当日程时长 D 超过 1 小时时,必须剔除那些「起点 + D」会超出本空闲段终点的候选起点——即候选起点必须满足 `起点 ≤ 段终点 − D`,否则实际区间会落到未经忙闲验证的时段、可能与他人冲突。例如 **2 小时**会议、空闲段 `15:00-18:00`:合法起点上限为 `18:00 − 2h = 16:00`,故只保留 `15:00`、`16:00` 两个起点(对应实际区间 `15:00-17:00`、`16:00-18:00`),必须剔除 `17:00`(其实际区间 `17:00-19:00` 已越过 18:00)。当空闲段长度本身小于 D 时,该段不产生任何候选。
- **推荐时段的长度只表示"这段时间可用",不代表日程/会议时长**:切出的 1 小时候选仅用于给用户挑选开始时段,用户选定后,日程/会议的实际时长仍以用户明确指定的为准;用户未明确时长时一律默认 1 小时(见 [calendar-create](calendar-create.md) 与 wecomcli-meeting 创建文档),禁止把推荐时段的长度直接当作时长。

### 输出格式

**情况 1:全员共同空闲**
```
推荐时间:
  方案 1: 04-07 15:00-16:00 — 张三、李四都有空
  方案 2: 04-07 16:00-17:00 — 张三、李四都有空
  方案 3: 04-07 13:00-14:00 — 张三、李四都有空

选哪个方案?或者说"换一批"看其他时间。
```

**情况 2:降级(部分人能参加)**
```
当前时间范围内没有所有人都空闲的时段,最多 2 人能到。

方案 1: 04-07 15:00-16:00 — 张三、李四能参加(王五此时有日程)
方案 2: 04-07 17:00-18:00 — 张三、李四能参加(王五此时有日程)

要按这些时段安排吗?或者换个时间窗口让王五也能参加?
```

**情况 3:全员无空**
```
04-07 13:00-18:00 内,张三、李四、王五 没有任何能凑齐的空闲时段(最小粒度 30 分钟)。

建议:
1. 扩大时间窗口(如延长到傍晚或换一天)
2. 减少参与人
```

> 展示参与人时只用姓名。`available_users[].userid` 仅用于回传到 `schedules create` 的 `attendees`,禁止出现在面向用户的文案里。

## 典型场景

### 1. 有共同空闲时段

```
用户:帮我约张三和李四明天下午聊一下
→ 通过 wecomcli-contact 技能批量搜索「张三」「李四」,解析为 userid
→ 调用 free list(明天 13:00-18:00;`userids` = 自己 + 张三 + 李四——新建日程的共同空闲须把自己也纳入,避免排到自己已占用的时段)
→ slots[0].available_count == total_count == 3,存在全员共同空闲
→ 展示前 3 个时段让用户选择
→ 用户选择 → 调用 create
```

### 2. 部分降级 / 全员无空

```
用户:帮我约王五和赵六、孙七明天上午碰一下
→ 通过 wecomcli-contact 技能批量搜索,解析为 userid
→ 调用 free list(明天 09:00-12:00)
→ 情况 A: slots 为空 → 引导扩大窗口或减少参与人
→ 情况 B: slots[0].available_count = 2 < 3 → 告知冲突的人和"最多 2 人能参加"的时段
  → 用户选"换个时间" → 重新追问范围 → 再次调用
  → 用户选"按 2 人安排" → 调用 create(只把 available_users 中的人作为参与人)
```

### 3. 单人空闲查询

```
用户:李四明天什么时候有空
→ 通过 wecomcli-contact 技能搜索「李四」,解析为 userid
→ 调用 free list(userids 单元素,begin_time/end_time 覆盖明天工作时段)
→ slots 即李四的空闲段
→ 用人话展示时段起止时间
```

### 加人 / 改时间到已有日程时的查询对象(避免自冲突误报)[CRITICAL]

为"已存在的日程"加人或改时间而做忙闲检查时,查询对象**必须排除正被该日程占用、因而必然显示忙碌的人和时间段**,否则会误报冲突。

**核心原则**:对【已在本日程中的人】(日程创建者 / 自己 + 已有参与人)只查"与本日程**当前时段不重叠**"的时间——本日程已占着原时段,对这些人在原时段查到的"忙"是它自己造成的自冲突误报;【新增参与人】才查完整目标时段。据此分三种情况:

- **① 只加人、不改时间** → `userids` 只放**新增参与人**,针对**日程原时段**查询。**不要**把当前用户(创建者 / 自己)和已有参与人放进 `userids`——他们正因这条日程而"忙",纳入后会误判为冲突,而用户本意恰恰是让别人加入自己这个已定时间的日程。
- **② 改时间,且新时段与原时段【不重叠】**(平移 / 改期,如 15:00 改到 17:00)→ `userids` 放"改后仍需参加的人 + 新增参与人",针对**新时段**查询。新旧时段无交集,现有参与人查新时段不会撞上本日程,可正常纳入。
- **③ 改时间,且新时段与原时段【有重叠】**(延长 / 提前等,新时段含部分原时段)→ 不能整段查现有参与人,否则重叠部分会被本日程自己误报为忙:
  - **新增参与人**:查**完整新时段**。
  - **现有参与人及自己**:只查**新时段去掉与原时段重叠后剩下的增量段**(如 15:00-16:00 延到 15:00-17:00,只查 16:00-17:00;如 15:00-16:00 提前到 14:00-16:00,只查 14:00-15:00)。增量段为空(如仅缩短时间)则现有参与人无需查。

> 该约束同样适用于 [calendar-update](calendar-update.md) 的"参与人变更工作流":先按上述规则圈定查询对象和查询时段,再调用 `free list`。

## 查询范围约束

- **必须传未来时间**:`begin_time` 早于服务端当前时刻的部分会被自动截断;传纯历史窗口会得到空 `slots`。
  **调用前先检查**:若用户问"昨天 / 上周 / 上个月某人什么时候有空"等纯过去时间,直接告知用户"过去时段无法查询忙闲"并引导改成未来时间,不要先调 `free list` 拿到空结果再解释。
- **单次窗口 ≤ 24 小时**:`end_time` 必须晚于 `begin_time` 且间隔不超过 24h。跨天 / 多天需求必须拆成多段分别调用,再在 Agent 侧按顺序拼接 slots。
- **未给时间窗口的默认值**:用户只问"X 什么时候有空"没给日期范围时,默认只查当天剩余工作时段 + 明天工作时段(共两个 24h 窗口),不要主动展开 3 天以上——若不够再询问用户。
- **周期日程限制**:仅覆盖最近两个月有修改的周期日程,更早的可能不在结果中。
- **隐私保留**:返回中不包含日程主题、描述、其他参与人;只暴露忙 / 闲的归属人。

## 异常处理

| 异常场景 | 处理方式 |
|---------|---------|
| 接口调用失败 | 告知"忙闲查询暂时不可用",建议用户直接确认时间后创建日程 |
| `slots == []` 且窗口合理 | 引导用户扩大时间窗口或减少参与人,不要重复传同一窗口试错 |

## 参考

- [wecomcli-calendar](../SKILL.md) — 日程技能主文档
- [calendar-create](calendar-create.md) — 创建日程
references/calendar-meeting-room.md
# calendar 会议室查询 — buildings list / rooms search

查询办公楼清单(`buildings list`)和会议室可订性(`rooms search`),用于日程/会议创建或更新时选会议室。两者均为**只读查询**,真正的占用在 [calendar-create](calendar-create.md) 创建时传 `meeting_room_id`、或在 update([日程](calendar-update.md) / [会议](../../wecomcli-meeting/references/meeting-update.md))改订时传 `meeting_room_id` 完成。

> 本文档是会议室查询的唯一信息源,[wecomcli-meeting 技能](../../wecomcli-meeting/SKILL.md) 创建会议时也引用此处。

## 命令

```bash
# 列出我可访问的办公楼
wecom-cli meeting rooms buildings list --json '{}'

# 查会议室可订性(单时段)
wecom-cli meeting rooms search --json '{
  "begin_time": "<日期> 14:00:00",
  "end_time": "<日期> 15:00:00",
  "room_keyword": "1605",
  "floor_name": "16",
  "min_capacity": 4
}'
```

---

## buildings list — 办公楼清单

返回用户可访问的办公楼全量列表,无入参(传 `{}`)。

### 返回结构

```json
{
  "total_count": 3,
  "buildings": [
    { "name": "创新大厦A座", "city": "北京", "is_current": true },
    { "name": "创新大厦B座", "city": "北京", "is_current": false },
    { "name": "滨海科技园",  "city": "上海", "is_current": false }
  ]
}
```

| 字段 | 说明 |
|------|------|
| `total_count` | `buildings` 数组长度 |
| `buildings[].name` | 建筑本名,不含城市前缀 |
| `buildings[].city` | 城市,展示时拼 `${city} ${name}` |
| `buildings[].is_current` | 当前所在楼标记;无法判断时全为 `false` |

> 无内部 building_id;下游 `rooms search` 引用某栋楼时传 `building_city` + `building_name`。

### 用法

- **仅当用户提到楼名时调用**;没提楼则不调用,让 `rooms search` 用当前所在楼兜底。
- 把用户口语楼名(如"北京创新A")匹配到列表条目,得到 `city` + `name`。
- 多候选 → 用文字让用户选(展示用 `${city} ${name}`);无匹配 → 告知不在可访问列表并列出可选项。
- `buildings: []` → 提示"暂无可预订办公地点"。

> **楼栋识别靠模糊匹配 + 确认,不要苛求字面一致,也不要罗列充数:**
> - 用户说的楼名往往与 `buildings list` 的标准名**写法不同**(使用简称、漏字、少写 A/B 座、带或不带城市前缀等)。应把用户表述与返回列表做**模糊匹配**,而不是要求逐字相同。
> - 命中**唯一最接近**的条目 → 用文字确认一句"你是指【${city} ${name}】吗?",确认后用该条目的 `city`+`name` 调 `rooms search`。
> - 命中**多个相近**条目 → 用文字只列这几个(展示用 `${city} ${name}`)让用户选。
> - **确实匹配不到**(用户没给楼线索,或列表里没有相近项)→ 才让用户补充 / 自由输入楼名;**禁止从全量列表里随机挑几个充数,也禁止凭记忆编造列表里没有的楼名**。
> - 展示给用户的楼名、以及最终喂给 `rooms search` 的 `building_name` / `building_city`,都必须**逐字取自 `buildings list` 的返回条目**。

---

## rooms search — 会议室可订性查询

给定单时段 + 可选会议室提示 + 容量需求,返回目标会议室能否预订及同楼候选。

### 参数

| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|:----:|------|------|
| `begin_time` | string | 是 | — | `YYYY-MM-DD HH:mm:ss`,必须晚于当前时刻 |
| `end_time` | string | 是 | — | 晚于 `begin_time`,间隔 ≤ 24h |
| `building_city` | string | 否 | 当前所在楼城市 | 与 `building_name` 同传或同省略 |
| `building_name` | string | 否 | 当前所在楼楼名 | 同上 |
| `room_keyword` | string | 否 | — | 会议室名/号关键词(如 `"1605"`、`"创新室"`) |
| `floor_name` | string | 否 | — | 楼层过滤,按楼层名匹配(如 `"16"`、`"3 楼"`),仅返回该楼层的会议室;用户明确指定楼层时传入,**直接使用用户的原始表述传入,不做归一化/转换**(用户说"16 楼"就传 `"16 楼"`,说"16F"就传 `"16F"`) |
| `min_capacity` | int | 否 | `2` | 容量下限,传 `len(attendees) + 1`(含组织者) |
| `expand_to_other_buildings` | bool | 否 | `false` | `true` 时同城跨楼推荐,仅用户明确要求才传 |
| `limit` | int | 否 | `20` | `recommendations` 上限(最大 100) |

> `building_city/name` 均不传时用当前所在楼兜底;兜底失败返回 `current_building_unknown`。

### 返回结构

传了 `room_keyword` 时 `target` 为命中的目标会议室列表(数组,每项含 `status`:`bookable` / `unavailable` / `not_found`;同一关键词或叠加 `floor_name` 楼层过滤可能命中多间),未传 `room_keyword` 时 `target` 为空数组 `[]`。`recommendations` 为同楼候选。

```json
{
  "inferred_building": { "name": "创新大厦A座", "city": "北京", "source": "user_current" },
  "target": [
    {
      "status": "unavailable",
      "room": { "meeting_room_id": "mrmaaa", "name": "1605", "capacity": 6, "floor": "16F" }
    }
  ],
  "recommendations": [
    { "meeting_room_id": "mrmbbb", "name": "1607", "capacity": 6, "floor": "16F" },
    { "meeting_room_id": "mrmccc", "name": "1608", "capacity": 8, "floor": "16F" }
  ]
}
```

| 字段 | 说明 |
|------|------|
| `inferred_building.name/city` | 实际查询的办公楼,可展示给用户确认 |
| `inferred_building.source` | `user_current`(兜底)或 `from_input`(来自入参) |
| `target` | 目标会议室列表(数组);传 `room_keyword` 时为命中项(可能多间),未传为空数组 `[]` |
| `target[].status` | `bookable` / `unavailable` / `not_found` |
| `target[].room` | `not_found` 时为 `null`,否则为房间元数据 |
| `recommendations[]` | 同楼候选,已按"同楼层优先 → 容量恰好够用"排序 |
| `recommendations[].meeting_room_id` | 会议室 ID,仅工具链使用,禁止出现在用户回复正文 |

### 边界

- `target[].status = unavailable` 时不返回占用方信息。
- 同楼无可用时 `recommendations: []`,由 Agent 决定是否开 `expand_to_other_buildings`。
- `meeting_room_id` 仅在工具调用间流转,对用户只展示会议室 name。

### 错误码

| code | 触发场景 | 处理 |
|------|---------|------|
| `current_building_unknown` | 未传楼且无法兜底 | 调 `buildings list` 让用户选楼后重试 |
| `building_not_found` | 入参楼名查无匹配 | 提示该楼无权限,列出可选项 |
| `time_in_past` | `begin_time` ≤ 当前时刻 | 提示用户改未来时间 |

---

## Agent 侧编排

```
├─ 用户提了楼名 → buildings list → 匹配 → building_city + building_name
│  用户没提楼   → 跳过(rooms search 用当前所在楼兜底)
│
└─ rooms search(begin/end + 可选楼 + 可选 room_keyword + min_capacity = len(attendees)+1)
    ├─ 用户指定了具体会议室(传了 room_keyword)→ target 为命中列表:
    │    ├─ target 中存在 status = bookable 的会议室:
    │    │     ├─ 仅 1 个 → 唯一确定,拿其 target[].room.meeting_room_id 进 create
    │    │     └─ 多个     → 用文字让用户选(禁止自动取第一个)
    │    ├─ target = [](查无此名 / 无命中)→ 先告知"未查到你指定的『xxx』会议室",禁止静默替换;
    │    │     再用文字让用户决定改订其他会议室或换时间(候选仅 1 个也须用户确认);recommendations 为空则告知后问换时间/跨楼
    │    └─ target 中无 bookable、命中项均为 unavailable(被占)→ 先告知"『xxx』该时段已被占用",
    │          再用文字让用户选替代会议室或换时间(同样禁止静默替换)
    ├─ 用户未指定具体会议室(target = []):
    │    ├─ recommendations 多个候选 → 必须用文字让用户选(禁止自动取第一个)
    │    └─ recommendations 仅 1 个    → 可直接使用该候选 meeting_room_id
    └─ recommendations = []        → 问是否跨楼(expand_to_other_buildings=true 重试)或换时间
```

> [!CAUTION]
> **五条硬性规则(下游 create 必须遵守):**
> 1. **先查询、后推荐、后创建**:`meeting_room_id` 必须来自 `rooms search` 的真实返回值,禁止跳过查询直接创建,禁止凭记忆 / 猜测编造。任何向用户展示的候选 / 推荐会议室(含用文字给出的候选、回复正文里提到的会议室名 / 房间号 / 楼层 / 容量)也必须来自本次 `rooms search` 返回的 `target` / `recommendations`——在成功调用 `rooms search` 拿到真实结果之前,禁止凭记忆、上下文、历史会话或想象罗列、推荐、列举任何具体会议室让用户选择。需要让用户选会议室时,先调 `rooms search`,再用其返回的候选组装文字询问。
> 2. **存在多个会议室必须让用户选**:`recommendations` 命中多个候选时,必须用文字让用户选择或指定具体会议室,禁止自动替用户挑选。
> 3. **会议室禁止只写进 `location`**:只要用户提到会议室,就必须经 `rooms search` 查到真实会议室并以 `meeting_room_id` 传入创建。严禁把会议室名 / 房间号仅写进 `location` 字段——那样不会真正占用(预订)会议室。
> 4. **优先先订房、后建程/建会**:用户在创建时就提到会议室的,应先敲定 `meeting_room_id`(含用户确认)再调用 create,会议室查询/选择是 create 的前置阻塞项,避免创建后会议室被抢占。若创建时漏订或事后要换会议室,可通过 `update` 传入新 `meeting_room_id` 改订(须先经 `rooms search` 确认新会议室 `status=bookable`,详见各自的 update 参考),不必取消重建。
> 5. **指定会议室查无/不可用时必须先告知、禁止静默替换**:用户指定的会议室在 `target` 中找不到可订项(`target = []` 查无此名,或命中项均为 `unavailable` 被占)时,必须先告知用户"未查到 / 无法预订你指定的『xxx』会议室",再用文字让用户决定改订其他会议室或换时间。严禁静默用其他名称的会议室替代——即使 `recommendations` 仅 1 个候选也须经用户确认。"`recommendations` 仅 1 个可直接使用"只适用于用户未指定具体会议室(`target = []`)的情形。

- `rooms search` 需要**确定的起止时间**。用户只给了时间范围(如"明天下午")时,先用 [calendar-freebusy](calendar-freebusy.md) 查共同空闲、让用户选定一个具体时段,再拿该时段调 `rooms search`;用户已给精确时间(如"明天 3 点")则直接查。
- 用文字给出的候选必须 2~4 个,展示会议室 `name` + 楼层 + 容量;`meeting_room_id` 仅工具链使用,禁止出现在用户回复正文。
- `meeting_room_taken`(抢订竞态)发生在 create 阶段,处理见 [calendar-create](calendar-create.md)。

## 参考

- [calendar-create](calendar-create.md) — 日程创建(传 `meeting_room_id` 占用会议室)
- [calendar-freebusy](calendar-freebusy.md) — 共同空闲查询
- [wecomcli-calendar.md](../SKILL.md) — 日程技能主文档
references/calendar-search.md
# calendar schedules search — 搜索日程

按关键词、组织人或参与人搜索用户发起和参与的日程。

> [!CAUTION]
> **`schedules search` 必须翻页到底**:返回中只要 `has_more == true`,就必须携带 `next_cursor` 再次调用 search,循环直到 `has_more == false`,否则会漏数据;禁止只取第一页就提前终止。

> **模糊搜索同时搜会议 [REQUIRED]**:若用户搜的是"会 / xx会 / xx会议"等模糊目标(非明确日程,见 [SKILL.md 查询消歧](../SKILL.md)),除按关键词搜日程外,必须同时 `读取 wecomcli-meeting 技能` 用同样关键词搜会议,把两边结果合并、分「(会议)」「(日程)」汇总展示——不论日程是否搜到都要搜会议。明确是日程 / 安排时只搜日程。

## 命令

```bash
# 按关键词搜索
wecom-cli calendar schedules search --json '{"keywords": ["项目评审"]}'

# 按关键词搜索(用户明确指定时间范围)
wecom-cli calendar schedules search --json '{"keywords": ["周会"], "begin_time": "2026-04-07 00:00:00", "end_time": "2026-04-07 23:59:59"}'

# 按组织人搜索
wecom-cli calendar schedules search --json '{"organizer": "woxxx"}'

# 按参与人搜索
wecom-cli calendar schedules search --json '{"has_attendees": [{"userid": "woxxx"}, {"userid": "woyyy"}]}'

# 分页搜索
wecom-cli calendar schedules search --json '{"keywords": ["周会"], "cursor": "CURSOR_TOKEN", "limit": 50}'
```

## 参数

| 参数 | 类型 | 必填 | 说明 |
|------|------|:----:|------|
| `keywords` | string[] | 三选一 | 搜索关键词数组,可匹配日程主题、会议室名称等信息(关键词、组织人、参与人至少传入其一) |
| `organizer` | string | 三选一 | 组织人 userid(关键词、组织人、参与人至少传入其一) |
| `has_attendees` | object[] | 三选一 | 参与人列表,对象数组格式 `[{"userid": "woxxx"}]`(关键词、组织人、参与人至少传入其一)。需传入查询涉及的**所有参与人,包括当前用户自己**,不要只传别人而漏掉自己 |
| `begin_time` | string | 否 | 搜索区间起始时间(格式 YYYY-MM-DD HH:mm:ss) |
| `end_time` | string | 否 | 搜索区间结束时间(格式 YYYY-MM-DD HH:mm:ss) |
| `cursor` | string | 否 | 分页游标,首次请求不传,翻页时传上次返回的 `next_cursor` |
| `limit` | number | 否 | 单页返回数量,最大 50 |

> **必填约束**:`keywords`、`organizer`、`has_attendees` 三者至少传入一个,否则接口报错。

## 返回

```json
{
  "schedules": [
    {
      "schedule_id": "SCHEDULE_ID",
      "subject": "SUBJECT",
      "begin_time": "YYYY-MM-DD HH:mm:ss",
      "end_time": "YYYY-MM-DD HH:mm:ss",
      "attendees": [
        {
          "userid": "USERID1",
          "name": "englishname(name)"
        }
      ],
      "meeting_room": {
        "meeting_room_id": "MEETING_ROOM_ID",
        "meeting_room_name": "MEETING_ROOM_NAME"
      },
      "meeting": {
        "meeting_id": "MEETING_ID",
        "meeting_code": "MEETING_CODE",
        "meeting_link": "MEETING_LINK"
      },
      "location": "LOCATION",
      "description": "CONTENT",
      "creator_name": "NAME",
      "cal_id": "CAL_ID",
      "calendar_name": "CALENDAR_NAME",
      "is_share_cal": false,
      "allow_self_join": false,
      "is_all_day": false,
      "repeat_rule": { "is_repeat": false },
      "reminders": { "is_remind": false, "reminder_time": [-900] },
      "timezone": { "timezone_id": "Asia/Shanghai", "timezone_offset": 28800 }
    }
  ],
  "schedules_count": 1,
  "next_cursor": "xxx",
  "has_more": false
}
```

| 字段 | 说明 |
|------|------|
| `schedules[].schedule_id` | 日程 ID |
| `schedules[].subject` | 日程主题 |
| `schedules[].begin_time` | 开始时间 |
| `schedules[].end_time` | 结束时间 |
| `schedules[].attendees[].userid` | 参与人 userid |
| `schedules[].attendees[].name` | 参与人姓名(格式:`englishname(中文名)`) |
| `schedules[].meeting_room.meeting_room_id` | 会议室 ID |
| `schedules[].meeting_room.meeting_room_name` | 会议室名称 |
| `schedules[].meeting` | 在线会议信息(关联了会议时才有),含 `meeting_id`/`meeting_code`/`meeting_link`;`meeting_code` 非空即「含在线会议链接的会议形态日程」 |
| `schedules[].location` | 日程地点 |
| `schedules[].description` | 日程描述 |
| `schedules[].creator_name` | 日程创建者名字 |
| `schedules[].cal_id` | 所属日历本 ID |
| `schedules[].calendar_name` | 日历本名称 |
| `schedules[].is_share_cal` | 所属日历是否为共享日历(日历创建者非当前用户) |
| `schedules[].allow_self_join` | 是否允许非参与人主动加入日程 |
| `schedules[].is_all_day` | 是否全天事件 |
| `schedules[].repeat_rule` | 周期规则,子字段(含 `is_repeat`/`repeat_type`/`repeat_until`/`exception[]` 等)与 [calendar-agenda](calendar-agenda.md) 的 `repeat_rule` 完全一致;`is_repeat=true` 即周期日程,可直接判定无需补 `get` |
| `schedules[].reminders` | 提醒设置,含 `is_remind`(bool)和 `reminder_time`(int[],与开始时间的差值秒数,负数为提前提醒) |
| `schedules[].timezone` | 时区信息,含 `timezone_id`(IANA 标识,如 `Asia/Shanghai`,优先使用)和 `timezone_offset`(UTC 偏移量秒数,`timezone_id` 为空时使用) |
| `schedules_count` | `schedules` 数组元素数量 |
| `next_cursor` | 下一页游标,翻页时作为 `cursor` 传入 |
| `has_more` | 是否还有更多数据 |

> **参与人姓名**:接口已在 `attendees[].name` 中直接返回姓名,**无需额外调用 wecomcli-contact 技能反查**。展示时直接使用 `name` 字段,禁止展示 `userid`。

> **判定会议形态 / 周期性无需补 `get` [REQUIRED]**:`search` 出参与 `list`/`get` 对齐,已含 `meeting`、`repeat_rule`、`reminders`、`timezone` 等字段——可直接用 `meeting.meeting_code` 非空判定「会议 / 纯日程」(用于分组展示、改约路由)、直接取 `meeting.meeting_id` 传给 `wecomcli-meeting`、直接用 `repeat_rule.is_repeat` 判定周期日程,**不必再补一次 `get`**。

## 搜索策略

**搜索条件策略**:
- 有日程名称/关键词 → 传 `keywords` 数组
- 用户提到"某人组织的日程" → 上下文中已有该人合法 userid 则直接使用,否则通过 `读取 wecomcli-contact 技能` 按姓名获取 userid,传 `organizer`
- 用户提到"某人参与的日程" → 上下文中已有该人合法 userid 则直接使用,否则通过 `读取 wecomcli-contact 技能` 按姓名获取 userid,传 `has_attendees`

**时间范围策略**:`begin_time` / `end_time` 均为选填。用户未明确指定时间时,不传时间参数;仅当用户明确说明时间范围时才传入。

**分页策略**:首次搜索不传 `cursor`;**只要返回 `has_more=true`,就必须携带 `next_cursor` 继续翻页,循环直到 `has_more=false` 把结果取全,禁止只取第一页就提前终止**(否则会漏数据、统计不准)。取全后再展示:超过 10 条时只展示和用户问题最相关的 10 条,并告知"还有 N 条,需要查看更多吗?"。

**接口选择规则**:
1. **有日程主题关键词 → `search`**:用户提到日程主题/关键词时,不追问时间,直接搜索。
2. **无日程主题关键词 → `list`**:用户泛泛说"看看日程",或只给了时间/日期时,一律用 `list` 按时间范围拉取;禁止把日期当 `keywords` 走 `search`。
3. **要详情 → `get`**:`search`/`list` 返回已含 `meeting`、`repeat_rule` 等字段,会议形态与周期性可直接判定,一般无需再调 `get`;仅在只拿到 `schedule_id`(无上下文结果)时用 `get` 补齐。
4. **与某人相关 → 优先 `search`**:寻找与某人相关的日程时,优先用 `search`(传 `has_attendees`/`organizer`,或把人名作为 `keywords`),而非 `list` 拉全量再过滤。

## 典型场景

### 1. 单个结果

```
用户:项目评审是什么时候?
→ 调用 search(keywords=["项目评审"],不传时间)
→ 找到 1 条 → 直接读取 attendees[].name 展示参与者姓名
→ 展示三项:主题、时间、参与人(禁止 markdown 表格)
```

### 2. 多个结果

```
用户:最近有没有周会?
→ 调用 search(keywords=["周会"],不传时间)
→ 找到 3 条 → 用文字列出摘要供用户选择:
    文字提问:"找到多个匹配日程,请选择要查看的一个:"
    列出候选(如"周会 - 4月14日 10:00 / 周会 - 4月21日 10:00 / 周会 - 4月28日 10:00",最多 4 条)
→ 用户选择后调用 get 获取详情
```

### 3. 搜索无结果

```
用户:帮我找一下产品发布会的日程
→ 调用 search(keywords=["产品发布会"],不传时间)→ 无结果
→ 用文字告知用户未找到,提供以下恢复建议:
  1. 更换关键词重试(日程名称可能不完全匹配)
  2. 按组织人搜索(提供日程组织人姓名,将通过 wecomcli-contact 技能解析为 userid 后传 organizer)
  3. 按参与人搜索(提供参与该日程的人员姓名,解析 userid 后传 has_attendees)
  4. 补充时间范围(日程可能不在接口默认返回范围内)
→ 根据用户选择执行对应策略
```

### 4. 用户明确指定时间范围

```
用户:找一下4月份的周会
→ 调用 search(keywords=["周会"],begin_time="2026-04-01 00:00:00",end_time="2026-04-30 23:59:59")
→ 展示结果
```

### 5. 按组织人搜索

```
用户:帮我找一下张三组织的日程
→ 通过 wecomcli-contact 技能搜索"张三"获取 userid(如 woxxx)
→ 调用 search(organizer="woxxx")
→ 展示结果,参与人直接读 attendees[].name,创建者读 creator_name
```

### 6. 结果超过 10 条(分页)

```
→ 只要 has_more=true 就先用 next_cursor 翻页到底,取全所有结果(禁止提前终止)
→ 顺序输出前 10 条日程,每条只含主题/时间/参与人(禁止 markdown 表格)
→ 末尾告知"还有 N 条,需要查看更多吗?"
→ 用户确认后展示后续结果(已取回,无需再调接口)
```

## 注意事项

- **不传默认时间**:用户未明确指定时间时,不传 `begin_time` / `end_time`;仅当用户明确说明时间时才传入。
- **不追问时间**:用户提供了关键词时,直接搜索,不要追问"你说的是什么时候的"。
- **参与人展示**:search 返回的 `attendees[].name` 已包含姓名,直接使用,无需调用 wecomcli-contact 技能反查。禁止展示 `userid`。
- **列表展示规范 [REQUIRED]**:多条结果时按 [SKILL.md 输出格式规范](../SKILL.md) 的「日程列表展示规范」处理——禁止 markdown 表格,每条作为独立条目顺序输出,每个条目只含主题/时间/参与人,超过 10 条只展示前 10 条并告知"还有 N 条,需要查看更多吗?"。
- **时区标注**:日程 `timezone.timezone_offset != 28800`(非东八区)时,按 [SKILL.md 输出格式规范](../SKILL.md) 的时区标注规则在时间后带上时区,如 `14:00-15:00(纽约时间 UTC-5)`。

## 参考

- [wecomcli-calendar](../SKILL.md) — 日程技能主文档
- [calendar-agenda](calendar-agenda.md) — 查看日程安排
references/calendar-update.md
# calendar schedules update — 更新日程

更新已有日程的信息,包括主题、时间、地点、参与人等。**暂不支持更新周期日程**,识别到周期日程时应告知用户并引导其在企业微信客户端操作(见下文工作流与注意事项)。

> [!CAUTION]
> 这是**写入操作** — 参数就绪后直接执行。

## 命令

```bash
# 修改日程主题和时间
wecom-cli calendar schedules update --json '{
  "schedule_id": "SCHEDULE_ID",
  "subject": "产品评审(更新)",
  "begin_time": "2026-04-08 14:00:00",
  "end_time": "2026-04-08 15:00:00"
}'

# 新增/移除参与人
wecom-cli calendar schedules update --json '{
  "schedule_id": "SCHEDULE_ID",
  "add_attendees": [{"userid": "woxxxc"}],
  "remove_attendees": [{"userid": "woxxxb"}]
}'

# 更换会议室(meeting_room_id 须先经 rooms search 确认新会议室 status=bookable)
wecom-cli calendar schedules update --json '{
  "schedule_id": "SCHEDULE_ID",
  "meeting_room_id": "mrmxxxx"
}'
```

## 参数

| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|:----:|--------|------|
| `schedule_id` | string | 是 | — | 日程 ID |
| `subject` | string | 否 | — | 日程主题 |
| `begin_time` | string | 否 | — | 开始时间(格式 `YYYY-MM-DD HH:mm:ss`)。必须晚于当前时刻;与 `end_time` 必须同时传入或同时省略。 |
| `end_time` | string | 否 | — | 结束时间(格式 `YYYY-MM-DD HH:mm:ss`)。必须晚于 `begin_time`(支持跨天 / 多天,无时长上限);与 `begin_time` 必须同时传入或同时省略。 |
| `location` | string | 否 | — | 日程地点(文本)。用户给的是**会议室**时须走 `meeting_room_id` 改订(见「更换会议室工作流」),不要把会议室名仅写进 `location`;用户给的是**非会议室的普通文本地点**时直接写入 `location` |
| `meeting_room_id` | string | 否 | — | 会议室 ID,传入预定(改订)会议室。用户要更换会议室时,须先经 `rooms search`(见 [calendar-meeting-room](calendar-meeting-room.md))查询新会议室状态,确认 `status=bookable` 可用后才传入新的 `meeting_room_id`;ID 仅工具链使用,禁止出现在用户回复正文 |
| `description` | string | 否 | — | 日程描述 |
| `allow_self_join` | bool | 否 | — | 是否允许自行加入 |
| `is_all_day` | bool | 否 | — | 是否全天日程 |
| `add_attendees` | object[] | 否 | `[]` | 新增参与人列表,对象数组,格式 `[{"userid": "woxxx"}, {"userid": "woyyy"}]` |
| `remove_attendees` | object[] | 否 | `[]` | 移除参与人列表,对象数组,格式 `[{"userid": "woxxx"}]` |

**返回**:`detail` 对象,包含更新后的完整日程详情,字段如下:

| 字段 | 类型 | 说明 |
|------|------|------|
| `detail.schedule_id` | string | 日程 ID |
| `detail.subject` | string | 日程主题 |
| `detail.begin_time` | string | 开始时间(YYYY-MM-DD HH:mm:ss) |
| `detail.end_time` | string | 结束时间(YYYY-MM-DD HH:mm:ss) |
| `detail.attendees` | object[] | 参与人列表,格式 `[{"userid": "USERID", "name": "englishname(name)"}]`,展示时只取 `name`,禁止展示 userid |
| `detail.meeting_room` | object | 会议室信息,含 `meeting_room_id` + `meeting_room_name`(改订会议室后返回,展示用 name) |
| `detail.location` | string | 日程地点 |
| `detail.description` | string | 日程描述 |
| `detail.allow_self_join` | bool | 是否允许自行加入 |
| `detail.is_all_day` | bool | 是否全天日程 |
| `detail.meeting` | object | 在线会议信息(关联了会议时才有),含 `meeting_id`/`meeting_code` |
| `detail.reminders` | object | 提醒设置:`is_remind`(bool)+ `reminder_time`(负数秒数组,如 `[-900]` = 提前15分) |
| `detail.creator_name` | string | 日程创建者名字 |
| `detail.repeat_rule` | object | 重复规则(`is_repeat=false` 时无此字段或为空),见下表 |
| `detail.timezone` | object | 时区设置,含 `timezone_id`(如 `Asia/Shanghai`)+ `timezone_offset`(秒,如 `28800`) |

**`detail.repeat_rule` 子字段:**

| 字段 | 类型 | 说明 |
|------|------|------|
| `is_repeat` | bool | 是否重复日程 |
| `repeat_type` | string | 重复类型:`daily`/`weekly`/`monthly`/`monthly_on_the_nth_day`/`yearly`/`yearly_on_the_nth_day`/`work_day` |
| `repeat_flag` | string[] | 重复标记,数组,可选值:`leap_month`(闰月)、`never_ends`(永不结束) |
| `repeat_time` | int | 重复次数,`0` 表示无限 |
| `repeat_interval` | int | 重复间隔 |
| `repeat_until` | string | 重复截止时间(格式 YYYY-MM-DD HH:mm:ss) |
| `repeat_week_of_month` | string[] | 每月第几周,数组,可选值:`first`/`second`/`third`/`fourth`/`last` |
| `repeat_day_of_week` | string[] | 每周周几,数组,可选值:`MO`/`TU`/`WE`/`TH`/`FR`/`SA`/`SU` |
| `repeat_month_of_year` | int[] | 每年哪几个月,数组,取值范围:1~12 |
| `repeat_day_of_month` | int[] | 每月哪几天,数组,取值范围:1~31 |
| `is_custom` | bool | 是否自定义重复 |
| `exception` | object[] | 例外日程列表,每项含 `begin_time`/`end_time`/`flag`/`except_schedule_id` |

## 更新日程流程

> **完整的日程管理工作流**(含查询日程 ID、参数补全策略等)定义在 [SKILL.md](../SKILL.md) 的核心场景中。本文档专注于 `update` 命令的参数和调用细节。

**快速决策参考**:
- 必填参数:`schedule_id`(缺失时需先通过搜索日程获取,见 [calendar-search](calendar-search.md))
- 仅传入需修改的字段,未传入字段保持不变
- **周期日程暂不支持更新**:定位到的目标日程若 `repeat_rule.is_repeat=true`,终止本次更新操作,用文字告知用户目前暂不支持更新周期日程,引导其在企业微信客户端操作;禁止逐场 `update` 拼凑或改为取消重建
- **权限判定交给接口**:不预先按"是否本人创建"拦截——直接执行 `update` 并按返回结果判断(详见注意事项)
- 参数就绪后直接执行,结果展示时人名不暴露 userid

### 参与人变更工作流

涉及 `add_attendees` 或 `remove_attendees` 时,按以下方式获取 userid:上下文中已有合法 userid(`wo` 前缀)则直接使用;用户提供的是姓名时通过 `读取 wecomcli-contact 技能` 将姓名解析为 userid。

```
+-- 参与人变更解析(如有 add_attendees / remove_attendees)
|   +-- 上下文中已有合法 userid → 直接使用,跳过搜索
|   +-- 用户提供的是姓名 → 通过 `读取 wecomcli-contact 技能` 批量搜索所有新增/移除的人名
|   |   +-- 某关键词唯一匹配 → 直接使用,无需确认
|   |   +-- 某关键词多个匹配 → 用文字让用户选择(列出姓名 + 部门):
|   |   |     文字提问:"搜索到多个「{姓名}」,请确认要操作哪一位?"
|   |   |     列出候选(如"张三 - 产品部 - 产品经理 / 张三 - 技术部 - 前端工程师",最多 4 条,超出取前 4 并提示用户缩小范围)
|   |   +-- 某关键词无结果 → 用文字提示用户确认人名是否正确,停止执行
|   +-- 汇总全部 userid → 组装 add_attendees / remove_attendees(对象数组 `[{"userid": "woxxx"}]`)
+-- 时间/参与人忙闲检查(改时间或加参与人时必做)[REQUIRED]
|   +-- 触发条件:本次修改了 begin_time/end_time,或新增了参与人(add_attendees)
|   +-- 核心原则(避免自冲突误报)[CRITICAL]:对【已在本日程中的人】(自己/创建者 + 已有参与人)
|   |     只查"与本日程当前时段【不重叠】"的时间——本日程已占着原时段,查到的"忙"是它自己造成的误报;
|   |     【新增参与人】才查完整目标时段
|   +-- 据此分三种情况:
|   |   +-- ① 只加人、不改时间 → 仅对【新增参与人 add_attendees】查【日程原时段】;
|   |   |     已有参与人和自己/创建者全部不查(原时段被本日程占满,纳入必然误报;
|   |   |     用户本意就是让别人加入自己这个已定时间的日程)
|   |   +-- ② 改时间且新时段与原时段【不重叠】(平移/改期,如 15:00 改到 17:00)→
|   |   |     对【改后仍需参加的人 + 新增参与人】查【新时段】(新旧无交集,现有参与人查新时段不会撞上本日程)
|   |   +-- ③ 改时间且新时段与原时段【有重叠】(延长/提前等,新时段含部分原时段)→
|   |         · 新增参与人:查【完整新时段】
|   |         · 现有参与人及自己:只查【新时段去掉与原时段重叠后剩下的增量段】
|   |           (如 15:00-16:00 延到 15:00-17:00,现有人只查 16:00-17:00;如 15:00-16:00 提前到 14:00-16:00,只查 14:00-15:00);
|   |           增量段为空(如仅缩短时间)则现有参与人无需查
|   +-- 按上面裁剪后的查询对象执行;裁剪后查询对象为空、或某人查询时段为空(如仅缩短时间的增量段为空)时才跳过——不要因为"日程只有自己"就跳过(②/③ 里自己在新时段/增量段内仍要查,避免约到自己已占用的时段)
|   +-- 读取 [calendar-freebusy](calendar-freebusy.md),按上面圈定的查询对象 + 时段调 free list(窗口 ≤ 24h)
|   |   +-- 无冲突 → 继续执行 update
|   |   +-- 有人占线 → 用文字让用户二选一(禁止自行改期):
|   |   |     文字提问:"该时间段{姓名}有冲突,如何处理?(请回复:坚持这个时间 / 换一个时间)"
|   |   +-- 接口失败 → 告知忙闲暂不可用,确认时间后继续,不阻塞
+-- 执行 update
```

> **关键约束**:只要存在多个候选人,必须等用户选择后才能继续,不得自动选取任何一个。

> 边界说明:上面"现有参与人及自己只查增量段、增量段为空则该人不查",是因为本日程已占着原时段、扣除重叠后这些人在重叠段没有剩余窗口可查(**不是"只有自己就整条跳过"——自己在增量段/新时段内仍要查**);不要把它套到新建场景——新建时日程尚不存在,自己必须按完整目标时段查(见 [calendar-create](calendar-create.md) 步骤3)。
>
> 查忙闲时 `min_duration_minutes` 设成所查时段时长(或直接传 1),否则被默认 30 分钟过滤掉的短空闲段,会让落在其中的短日程误报为冲突。

### 更换会议室工作流

涉及 `meeting_room_id`(更换 / 改订会议室)时,必须先经会议室查询确认新会议室可用,禁止凭记忆或猜测直接传入 `meeting_room_id`:

```
+-- 用户要更换会议室
|   +-- 确定查询时段:用日程的起止时间;若本次同时改时间,用改后的新 begin_time/end_time
|   +-- 读取 [calendar-meeting-room](calendar-meeting-room.md),按其编排执行:
|   |   +-- 用户提了楼名 → buildings list 匹配出 building_city/name;没提则跳过(后端按当前所在楼兜底)
|   |   +-- rooms search(带日程时段 + 可选楼 + 可选room_keyword + min_capacity)
|   +-- 按新会议室状态决策:
|   |   +-- 指定会议室 target 中有 bookable 项 → 取该项 target[].room.meeting_room_id 传入 update(多个 bookable 时用文字让用户选)
|   |   +-- 指定会议室 target=[](查无此名)/命中项均 unavailable(被占)→ 必须先告知用户"未查到/无法预订你指定的『xxx』会议室",
|   |   |       再用文字让用户决定是否改订其他会议室或换时间;禁止用其他名称会议室静默替代(候选仅 1 个也须用户确认)
|   |   +-- 未指定具体会议室(target=[]):
|   |   |   +-- recommendations 多个候选 → 用文字让用户选(禁止自动取第一个)
|   |   |   +-- recommendations 仅 1 个    → 可直接使用该候选 meeting_room_id
|   |   |   +-- recommendations = []       → 告知该时段无可用会议室,引导换楼(expand_to_other_buildings)或换时间
|   +-- 拿到用户确认的、可用的 meeting_room_id
|   +-- 判断地点是否需要同步:取原日程 detail.location 与原 detail.meeting_room.meeting_room_name 比对
|   |   +-- 原 location 就是原会议室(与原会议室名/地点一致)→ 把 location 一并改为新会议室对应地点(新会议室名 / rooms search 返回的楼+房间信息),与 meeting_room_id 同次 update 传入
|   |   +-- 原 location 是用户自定义文本(与原会议室无关)/ 原本无会议室 → 不动 location,避免覆盖用户自填内容
|   +-- 执行 update(meeting_room_id,必要时 + location)
```

> **关键约束**:新会议室未经 `rooms search` 确认 `bookable` 之前,禁止传入 `meeting_room_id` 调用 update——否则会改订到不可用或不存在的会议室。会议室查询/选择是本次 update 的前置阻塞项。

> **地点同步**:若原日程已绑定会议室、且 `location` 就是这个原会议室(地点只是在镜像会议室名),更换会议室时要把 `location` 一并改成新会议室对应地点,和 `meeting_room_id` 在同一次 update 传入,避免出现"会议室已换、地点还停在旧会议室"的不一致。若 `location` 是用户自填的、与原会议室无关的文本,则保持不动。

## 典型场景

### 1. 修改日程时间

```
用户:把明天下午3点的评审推迟1小时
→ 调用 search 查询日程 → 获取 schedule_id
→ 组装参数:begin_time="2026-04-08 16:00:00",end_time="2026-04-08 17:00:00"
→ 调用 update
```

### 2. 添加参与人

**唯一匹配**:
```
用户:把王五加到明天的评审会
→ 通过 wecomcli-contact 技能搜索「王五」→ 唯一匹配,获得 userid woxxxe
→ 调用 search 查询日程 → 获取 schedule_id
→ 调用 update,add_attendees=[{"userid": "woxxxe"}]
```

**多候选情形**:
```
用户:把张三加到明天的评审会
→ 通过 wecomcli-contact 技能搜索「张三」→ 返回 2 个候选
→ 用文字询问:搜索到多个「张三」,请确认要操作哪一位?(列出:张三 - 产品部 - 产品经理 / 张三 - 技术部 - 前端工程师)
→ 用户选择后,获得对应 userid
→ 调用 search 查询日程 → 获取 schedule_id
→ 调用 update,add_attendees=[{"userid": "woxxxf"}]
```

### 3. 移除参与人

```
用户:把李四从明天的评审会里移除
→ 通过 wecomcli-contact 技能搜索「李四」→ 返回 2 个候选
→ 用文字询问:搜索到多个「李四」,请确认要移除哪一位?(列出:李四 - 设计部 - UI设计师 / 李四 - 技术部 - 后端工程师)
→ 用户选择后,获得对应 userid
→ 调用 search 查询日程 → 获取 schedule_id
→ 调用 update,remove_attendees=[{"userid": "woxxxd"}]
```

### 4. 周期日程更新(不支持)

```
用户:下周一的周会改到下午3点
→ search 拿到日程 → repeat_rule.is_repeat=true(周期日程)
→ 不调用 update → 告知:目前暂不支持更新周期日程,请在企业微信客户端对该日程进行修改
```

### 5. 修改非本人创建的日程

不预先按"是否本人创建"拦截,直接执行 update,根据返回结果判断。
```
用户:把明天的评审改到下午3点(该日程创建人是李四)
→ search / get → 找到日程
→ 不因创建人非本人而提前拒绝 → 直接调用 update(begin_time/end_time)
→ 依返回判断:
    · 返回 detail(更新后详情)→ 报告:已改到下午3点
    · 返回权限错误 → 告知:你无权修改该日程,建议联系创建人李四操作
```

### 6. 更换会议室

```
用户:把明天评审会的会议室换到 1608
→ search 拿 schedule_id(及日程起止时间)
→ 读取 calendar-meeting-room,用日程时段 + room_keyword="1608" 调 rooms search
→ target 中有 bookable 项 → 取其 target[].room.meeting_room_id
→ 调用 update,meeting_room_id="mrmxxxx"
→ 展示更新后日程摘要(只露会议室 name)

用户:明天的评审会换个会议室
→ search 拿 schedule_id 与时段 → rooms search(未指定具体会议室,target=[])
→ recommendations 多个 → 用文字让用户选(展示 name + 楼层 + 容量)
→ 用户选定后取其 meeting_room_id → update
```

## 注意事项

- **权限判定交给接口**:不预先按"是否本人创建"限制修改——直接执行 `update`,根据返回结果判断:返回 `detail`(更新后完整详情)即修改成功;返回权限类错误则说明当前用户无权修改该日程,告知用户并建议联系创建人操作。
- **含会议链接的日程不在本技能改时间**:目标日程 `meeting` 非空(含在线会议链接,`search`/`list` 结果即可判定,无需补 `get`)时,`calendar update` 改不动其背后的在线会议,须改用 `读取 wecomcli-meeting 技能` 把 `meeting_id` 传入 `meeting update`。本技能 `update` 只处理纯日程(`meeting` 为空)。
- **`schedule_id` 获取**:如用户未提供,需先通过 [calendar-search](calendar-search.md) 查询。
- **部分更新**:只需传入要修改的字段,未传字段服务端保持原值不变。
- **更换会议室**:用户要换会议室时必须先经 [calendar-meeting-room](calendar-meeting-room.md) 的 `rooms search` 查询新会议室、确认 `status=bookable` 可用后,再把新会议室的 `meeting_room_id` 传入 update。禁止跳过查询、凭记忆/猜测直接传 `meeting_room_id`,禁止把会议室名仅写进 `location`(那样不会真正占用会议室)。同时改时间又改会议室时,用改后的新时段查询会议室。若原 `location` 本就是原会议室(地点镜像会议室名),换会议室时把 `location` 一并改为新会议室对应地点同次传入;`location` 是用户自填的无关文本则不动。
- **时间字段成对传入**:修改时间时 `begin_time` 与 `end_time` 必须同时传入;只传其一会与原值组合,可能立即违反"晚于当前时刻"约束而失败。
- **时间合法性**:`begin_time` 必须晚于当前真实时刻、`end_time` 晚于 `begin_time`(支持跨天 / 多天,无时长上限)。任何不满足都先用文字询问引导用户修正,禁止直接传错时间试错。
- **周期日程不支持更新**:检测到目标日程 `repeat_rule.is_repeat=true` 时,直接告知用户目前暂不支持更新周期日程,引导其在企业微信客户端操作,禁止逐场 `update` 拼凑或改为取消重建等变通方式(详见 [SKILL.md 已知限制](../SKILL.md))。
- **改时间/加参与人需查忙闲 [REQUIRED]**:本次修改了 `begin_time`/`end_time` 或新增了参与人(`add_attendees`)时,执行 update 前必须先读取 [calendar-freebusy](calendar-freebusy.md) 查忙闲;占线时用文字让用户在「坚持这个时间 / 换一个时间」二选一,禁止自行改期。
  - **查询对象须排除"因本日程占用而必然忙碌"的人 [CRITICAL]**:核心原则是【已在本日程中的人】(自己/创建者 + 已有参与人)只查"与本日程当前时段【不重叠】"的时间,【新增参与人】查完整目标时段。分三种情况:①只加人、不改时间 → 只对新增参与人查日程原时段,自己和已有参与人全部不查;②改时间且新旧时段不重叠(平移/改期)→ 对"改后仍需参加的人 + 新增参与人"查新时段;③改时间且新旧时段有重叠(延长/提前等)→ 新增参与人查完整新时段,现有参与人及自己只查"新时段去掉与原时段重叠后的增量段"(如 15:00-16:00 延到 15:00-17:00 只查 16:00-17:00),增量段为空(如仅缩短时间)则不查。    裁剪后查询对象为空、或某人查询时段为空时才跳过——不要因为"日程只有自己"就跳过(②/③ 中自己在新时段/增量段内仍要查)。
- **禁止暴露 userid**:结果展示中只显示人名。
- **直接执行**:参数补全后直接调用更新接口,无需展示摘要或等待确认。
- **时区标注**:`detail.timezone.timezone_offset != 28800`(非东八区)时,结果摘要按 [SKILL.md 输出格式规范](../SKILL.md) 的时区标注规则在时间后带上时区。传入的 `begin_time` / `end_time` 按日程时区解释,禁止自行换算。

## 参考

- [wecomcli-calendar](../SKILL.md) — 日程技能主文档
- [calendar-search](calendar-search.md) — 搜索日程(获取 schedule_id)
- [calendar-freebusy](calendar-freebusy.md) — 查询参与人共同空闲(改时间/加参与人时查忙闲)
- [calendar-create](calendar-create.md) — 创建日程
- [calendar-cancel](calendar-cancel.md) — 取消日程
- [calendar-meeting-room](calendar-meeting-room.md) — 会议室查询(更换会议室时确认新会议室 `status=bookable`)
SKILL.md
---
name: wecomcli-calendar
description: "企业微信日程管理。当用户需要预约日程、预订会议室、查看/更新/取消日程或查忙闲时触发。本技能负责『日程』——即不含在线会议链接的安排(也涵盖纯线下面对面碰头);若用户要的是『在线会议』(含会议号/入会链接、可远程或视频参会),改用 wecomcli-meeting 技能。用户仅说'开会/约个会/某会'等、未明确要创建的是日程还是在线会议时,必须先读取本技能并按其中的消歧流程向用户追问确认后再处理,不可臆断直接创建。"
metadata:
  requires:
    bins: ["wecom-cli"]
---

# 企业微信日程技能

> 执行任何 `wecom-cli` 命令前,必须先读取并完成 `wecomcli-shared` 技能的公共前置检查。

## 适用范围

### 适用

- 预约 / 创建日程(含纯线下面对面碰头,即不带在线会议链接的安排)
- 查看 / 浏览日程(今天有什么安排、查本周日程)
- 搜索日程(按关键词、按组织人、按参与人找某个日程)
- 更新 / 修改日程(改时间、改地点、加减人、换会议室;不支持更新周期日程)
- 取消日程(不支持取消周期日程)
- 查忙闲 / 约多人共同空闲时段
- 订会议室、查会议室空不空、查办公楼

### 不适用

- 创建、更新、取消周期 / 重复日程(每周 / 每月 / 每天重复)→ 均不支持,引导用户在企业微信客户端手动操作
- 回复 / 拒绝日程邀请(接受 / 拒绝 / 待定,含"拒绝这个日程""不参加")→ 不支持,引导用户在企业微信客户端操作或私信发起人

### 易混淆场景路由

- 用户要**创建含在线会议链接的会议**(需会议号 / 入会链接 / 远程或视频参会)→ 改用 `wecomcli-meeting`(创建会议会同时生成日程,无需在本技能再建)
- 用户仅说"开会 / 约个会 / 安排个会 / xx 会"等、**未明确是日程还是在线会议**(创建场景)→ 必须先用文字追问消歧(固定问题"需要创建日程还是会议?",请用户回复"日程 / 会议"),不得臆断直接创建
- 用户要的会**同时支持线下与远程参会**(如"线下开、外地同事远程接入")→ 含在线会议链接,改用 `wecomcli-meeting`
- **仅给了地点 / 会议室号**(如"在 1605 开会""订个会议室开会")→ 不构成"明确是日程",仍需先用文字询问消歧,不能因带地点就跳过追问
- **查询场景的模糊表述**("最近有什么会 / 有哪些会")→ 严禁追问,日程和会议都查并合并展示;仅当明确提到"在线会议 / 视频会议 / 入会链接 / 会议号 / 腾讯会议 / 远程参会"时才改用 `wecomcli-meeting` 只查会议

## 路由规则

| 用户意图 | 参考文档 |
|---------|---------|
| 预约日程、安排纯线下面对面会议(不含在线会议链接)、创建日程 | [calendar-create](references/calendar-create.md) |
| 看日程、今天有什么安排、查本周日程 | [calendar-agenda](references/calendar-agenda.md) |
| 找某个日程、项目评审是什么时候 | [calendar-search](references/calendar-search.md) |
| 查日程详情、看周期规则、看会议链接 | [calendar-agenda](references/calendar-agenda.md) |
| 取消日程、不开了 | [calendar-cancel](references/calendar-cancel.md) |
| 修改日程、更新日程、改时间、加人/移除人、换会议室 | [calendar-update](references/calendar-update.md) |
| 查忙闲、某人什么时候有空、约多人共同空闲 | [calendar-freebusy](references/calendar-freebusy.md) |
| 订会议室、查会议室空不空、查办公楼、约会议室 | [calendar-meeting-room](references/calendar-meeting-room.md) |

> **浏览 vs 搜索的选择原则**:用户提到**日程主题关键词**时走搜索;**只给了时间/日期而无日程主题关键词时,必须走列表浏览(`list`)**。需要周期规则、会议链接等详情时再读取单条日程详情补充。

## 技能边界:日程 vs 会议 [CRITICAL]

本技能(wecomcli-calendar)只负责**日程**——即非会议的日程安排,以及不含在线会议链接的纯线下面对面会议。**只要涉及在线会议链接(含远程/视频参会)的会议,一律归 wecomcli-meeting 技能**,不在本技能创建。

| 用户意图 | 归属技能 |
|---------|---------|
| 预约日程、安排纯线下面对面会议(不含在线会议链接)、订会议室、查/改/取消日程、查忙闲 | **本技能 wecomcli-calendar** |
| 创建含在线会议链接的会议、需要会议号或入会链接的会、需要远程/视频参会的会 | **wecomcli-meeting 技能** |

**消歧规则(仅创建场景)**:用户仅说"会议/会/开个会/约个会/安排个会/xx会/xx会议"等而未明确是日程还是会议时,**必须先用文字追问**,再路由到对应技能,禁止默认直接创建日程。此文字消歧仅用于「创建」;查询场景严格禁止追问——明确指向在线会议时只查会议,明确是日程/安排时只查日程,模糊表述("会 / xx会 / 最近有什么会"等)则日程和会议都查(见下文「查询消歧」)。

> **问题与选项固定 [CRITICAL]**:消歧确认时,问题与可选项都必须原文照用、严格禁止修改任何内容——问题固定为 `"需要创建日程还是会议?"`,可选项固定为 `日程` / `会议`;不得改写问题措辞、增减或改写选项、翻译,或自行设计其他表述(如"在线会议 / 线上会议 / 视频会议 / 线下会议"等)。

用文字向用户提问:`需要创建日程还是会议?(请回复:日程 / 会议)`

- **"会议""会""开会"等词本身不构成"明确" [CRITICAL]**:这些词只表示要碰头议事,并未说明是日程还是会议。禁止仅因 query 里出现"会议"二字就默认归本技能(日程)创建,也禁止反向默认成会议——只要未明确,一律先用文字追问后再路由。只有出现"碰个面/创建日程"等纯线下信号时才直接留在本技能。
- 用户答「日程」→ 留在本技能,按"预约日程工作流"创建日程。
- 用户答「会议」→ 改用 `读取 wecomcli-meeting 技能` 创建会议(创建会议会同时生成对应日程,无需在本技能再建一条)。
- 用户已明确(如"碰个面""创建日程"=日程;"发个入会链接""要会议号""远程参会"=会议)时,直接路由,无需追问。
- **同时支持线下与远程参会**(如"线下开、外地同事远程接入")时,因含在线会议链接,归 wecomcli-meeting 技能:创建会议即同时生成日程,无需在本技能另建日程。
- **仅有地点/会议室号**(如"在 1605 开会""到 A 座会议室碰一下""订个会议室开会")不构成"明确是日程"——会议室里同样可能要远程接入,是日程还是会议仍未知,必须先用文字询问消歧,不能因为带了地点就跳过追问。

## 改约 / 重建日程前必须先识别会议关联 [CRITICAL]

"改约 / 改时间 / 挪到 / 顺延 / 重新约"等改期意图(即使用户说"取消……再约到……",带"取消"也算改期),禁止机械拆成 `cancel` + `create`:

1. **先定位再判定会议关联**:`search` / `list` 返回均含 `meeting` 字段,定位到目标日程后**直接检查 `meeting.meeting_code`**——非空为「含在线会议链接的会议形态日程」,为空为纯日程;无需为此再补一次读取日程详情(仅当还需 `repeat_rule` 等字段时才补)。
2. **纯日程** → 用本技能路由表中更新日程意图改时间,禁止 cancel + create。
3. **含会议链接** → 改用 `读取 wecomcli-meeting 技能`,把 `meeting.meeting_id` 传入 `meeting update` 改时间(保留会议链接与参会人),无需重新 search 定位。

> **根因**:`create` 只能建纯日程、重建不出会议链接(能拆不能合),cancel + create 会让会议链接永久丢失,故改约一律走 update。


## 核心场景

### 1. 预约日程

读取 [calendar-create](references/calendar-create.md),按其中"预约日程工作流"执行(信息补全 → 参与人解析 → 时间协商/忙闲检查 → 执行创建 → 结果反馈)。

### 2. 查看/搜索日程

| 场景 | 参考文档 |
|------|---------|
| 泛泛查询("今天有什么安排") | [calendar-agenda](references/calendar-agenda.md) |
| 有关键词("项目评审是什么时候") | [calendar-search](references/calendar-search.md) |
| 需要详情(只拿到 `schedule_id` 时补齐字段) | [calendar-agenda](references/calendar-agenda.md) |

> **浏览 vs 搜索**:有**日程主题关键词** → 搜索(不追问时间);**只给时间/日期而无主题关键词 → 列表浏览(`list`)**,禁止把日期当 `keywords` 喂给 `search`。列表浏览已返回 `repeat_rule`,无需额外读取单条详情判断是否周期日程。

> **查询消歧(模糊查询时日程 + 会议都查)[REQUIRED]**:查询场景严格禁止用文字追问"是日程还是会议"——日程/会议消歧追问仅用于创建,查询时一律按以下规则直接处理、不追问。**判定分两个独立维度,不要混为一谈**:
>
> **维度一:查哪一边(日程 / 会议 / 两边都查)**
> - **明确是在线会议** → 用户明确提到"在线会议 / 视频会议 / 入会链接 / 会议号 / 腾讯会议 / 远程参会"等在线会议专属特征时,改用 `读取 wecomcli-meeting 技能` 只查会议。
> - **明确是日程 / 安排** → 用户说的明显是日程类内容(如"日程 / 安排 / 我的安排 / 日历 / 今天有什么安排",且不带在线会议特征)时,只查日程。
> - **模糊表述无法判定**("会 / xx会 / xx会议 / 开会 / 最近有什么会 / 有哪些会 / 找下 xx会议"等,既可能是日程也可能是会议)→ **日程和会议都要查**:既查日程,又 `读取 wecomcli-meeting 技能` 查会议。
>
> **维度二:每一边用 `search` 还是 `list`(与维度一独立,逐边各自判断)**
> - **有主题/名称关键词**(如"找下 xx会议""项目评审是什么时候")→ 该边用 `search`(把关键词传入 `keywords`)。
> - **只有时间/日期或泛浏览无关键词**(如"最近有什么会""今天有什么安排")→ 该边用 `list`,禁止把日期当`keywords` 喂给 `search`。
> - 即使"两边都查",也按本维度对每一边各自选择:带关键词时两边都用 `search`,纯时间/泛浏览时两边都用 `list`。
>
> **合并展示**:两边都查时,合并结果后统一展示——按是否含在线会议链接分成「(会议)」(来自会议侧、或日程中 `meeting.meeting_code` 非空者)和「(日程)」(`meeting_code` 为空的纯日程)两部分,同一场会议在两边都出现时按"主题 + 时间"去重只保留一条,末尾汇总"共 N 场,其中会议 X 场、日程 Y 场"。
>
> - 本消歧仅针对查询;创建场景仍按上文"日程 vs 会议"用文字追问。

### 3. 取消日程

先定位日程(有**日程主题关键词**走搜索;只给时间/日期而无主题关键词走列表浏览 `list`,禁止把日期当 `keywords` 喂给 `search`),再判断是否周期日程(可直接读取列表返回的 `repeat_rule`,无需额外读取单条详情)——**周期日程不支持取消**,告知用户并引导其在企业微信客户端操作(见「已知限制」)。普通日程**不预先按"是否本人创建"拦截取消**,直接执行取消并根据工具返回结果判断能否取消(成功返回 `{}`,无权限则返回错误,此时告知用户并建议联系创建人)。**若用户意图实为"改约 / 挪到 / 顺延"(即使带"取消"字样),按上文「改约 / 重建日程前必须先识别会议关联」走更新流程。** 完整流程见 [calendar-cancel](references/calendar-cancel.md)。

### 4. 更新日程

- 先定位日程(有**日程主题关键词**走搜索;只给时间/日期而无主题关键词走列表浏览 `list`,禁止把日期当 `keywords` 喂给 `search`),判断是否周期日程——**周期日程不支持更新**,告知用户并引导其在企业微信客户端操作(见「已知限制」),禁止逐场 `update` 拼凑或改为取消重建。普通日程收集修改内容后执行更新,**不预先按"是否本人创建"拦截修改**,直接执行更新并根据工具返回结果判断能否修改(成功返回更新后的 `detail`,无权限则返回错误,此时告知用户并建议联系创建人)。
- **改时间/改地点/加减人/换会议室都走更新,不要取消重建。** 换会议室时须先经 `rooms search` 确认新会议室 `status=bookable` 再把新 `meeting_room_id` 传入更新(见 [calendar-meeting-room](references/calendar-meeting-room.md))。
- **含在线会议链接的日程(定位结果中 `meeting` 非空)改时间不在本技能 update**,须改用 `读取 wecomcli-meeting 技能`(见上文「改约 / 重建日程前必须先识别会议关联」)。
- 更新日程的完整流程见 [calendar-update](references/calendar-update.md)。

### 5. 查询忙闲 / 共同空闲

查询参与人在指定时段的可用空闲时段(服务端已合并区间、过滤过去、按策略推荐),用于协调日程时间。详见 [calendar-freebusy](references/calendar-freebusy.md)。

## 核心概念

- **日程(Schedule)**:日程系统中的单个事件,含主题、起止时间、参与人等属性。
- **全天日程(All-day)**:`is_all_day=true`,只按日期占用,结束日期包含在日程内。
- **周期日程(Recurring)**:`repeat_rule.is_repeat=true`,按规则重复出现。
- **参与人(Attendee)**:以 `userid`(`wo` 前缀)标识。用户提供的是姓名时通过 `读取 wecomcli-contact 技能` 解析为 `userid`。
- **忙闲(FreeBusy)**:查询参与人在指定时段是否有日程占用。
- **地点(Location)**:日程的地点为一段自由文本(`location` 字段)。用户给的地点是**公司会议室**时,须经会议室查询(`rooms search`)预订、以 `meeting_room_id` 占用(见 [calendar-meeting-room](references/calendar-meeting-room.md)),不要把会议室名仅写进 `location`;用户给的是**非会议室的普通文本地点**时才直接写入 `location`。
- **会议室 / 办公楼(Meeting Room / Building)**:物理空间资源(与在线会议链接无关)。`buildings list` 查可访问办公楼,`rooms search` 查会议室可订性,创建日程时传 `meeting_room_id` 原子占用,更新日程时传 `meeting_room_id` 改订。详见 [calendar-meeting-room](references/calendar-meeting-room.md)。
- **时区(Timezone)**:每个日程带 `timezone`(`timezone_id` + `timezone_offset`)。日程的 `begin_time` / `end_time` 是该时区下的**墙上时间**,后台不做转换——传入和返回的时间字符串都按日程时区解释,禁止自行换算成东八区或本地时间。

## 核心规则


### 规则 1: userid 获取 [CRITICAL]

- `attendees` / `add_attendees` / `remove_attendees` / `userids` / `has_attendees` 等所有"成员 userid 列表"入参**统一为对象数组**,格式为 `[{"userid": "woxxx"}, {"userid": "woyyy"}]`,不接受姓名或平铺字符串数组。
- `organizer`(搜索按组织人)为单值,传 userid 字符串(`wo` 前缀),不是数组。
- 用户提供的是姓名时,通过 `读取 wecomcli-contact 技能` 解析为对应 userid;多候选人时列出供用户选择,不自行猜测。
- **禁止**把姓名当 userid 拼接,**禁止**凭记忆或猜测编造 userid。
- **原因**:日程 API 不支持用姓名匹配参与人,传入姓名会导致静默失败或邀请到错误的人。

### 规则 2: 写操作直接执行

- 创建日程、取消日程时,参数就绪后直接执行,无需向用户展示摘要或询问确认。
- 结果返回时**禁止暴露 userid**,只展示人名。
- **原因**:上层交互已完整展示操作内容并完成确认,此处再展示一遍会造成冗余。

### 规则 3: 用户交互必须用文字询问 [CRITICAL]

任何操作中,当必要参数不明确或需要用户做出选择时,**必须用文字直接向用户提问**,禁止自行猜测或使用默认值代替询问。提问时把可选项 / 候选值一并写进文字里,让用户直接回复。

以下情况均适用此规则:
- **必填参数及参与人缺失**:创建日程的必填参数(`subject` / `begin_time` / `end_time`)以及参与人 `attendees` 无法从上下文中推断时,必须用文字询问;其余非必填参数(如地点)用户未明确指定时不专门询问,直接走默认值
- **多候选项需用户选择**:搜索返回多个匹配日程、wecomcli-contact 技能搜索到多个同名候选人
- **操作范围需确认**:如更换会议室时查到多个 bookable 候选,需用户选定具体一个
- **冲突处理**:忙闲检查发现时间冲突,需用户决策

**文字询问的约束**:
- 列出的可选项 / 候选建议以 **2~4 个**为宜。可选候选多于 4 个时(如同名候选人、多个匹配日程),取最相关的前 4 个列出,并提示用户可进一步缩小范围(输入更精确的关键词 / 完整姓名 / 具体时间),不要一次性罗列 5 个及以上候选。
- **询问时间时,列出的候选时刻必须是精确到分钟的具体时刻**(如"明天 14:00"、"周六 10:30"),禁止给出"上午/下午/傍晚/午间/上班后/下班前"等模糊时间选项——模糊选项会导致用户回复后仍需二次追问具体几点,必须一次问到可直接落为 `begin_time` 的精确时刻。

### 规则 4: 任务简洁原则

只完成用户要求的操作,不额外添加其他操作。

### 规则 5: 输入合法性检查

执行写操作前,验证以下输入的合法性:
- **时间格式**:必须为 `YYYY-MM-DD HH:mm:ss`,拒绝模糊表述直接传参(如"明天"不能直接传入,需先解析为具体时间)
- **时间顺序**:`end_time` 必须晚于 `begin_time`,拒绝零时长或负时长日程
- **userid 格式**:必须为 `wo` 前缀的字符串,不接受纯数字或中文姓名
- **历史时间**:禁止创建完全在当前时刻之前的日程

### 规则 6: 输入安全处理

- 用户提供的是姓名时,必须经过 `读取 wecomcli-contact 技能` 搜索验证后才能转换为 userid。
- **禁止**把姓名直接拼接为 userid,**禁止**凭记忆或猜测编造。
- **原因**:用户输入的字符串可能不对应真实员工(姓名不唯一、已离职等),直接拼接会导致将日程邀请发送给错误的人,且此类错误无法被 API 在调用时拦截。

## 操作参考

| 操作参考 | 读取时机 | 说明 |
|----------|---------|------|
| [`calendar-agenda`](references/calendar-agenda.md) | 查看/获取日程详情时 | 查看日程安排(list + get) |
| [`calendar-create`](references/calendar-create.md) | 创建日程时 | 创建日程并邀请参与人 |
| [`calendar-search`](references/calendar-search.md) | 搜索日程时 | 按关键词搜索日程 |
| [`calendar-cancel`](references/calendar-cancel.md) | 取消日程时 | 取消日程(不支持周期日程) |
| [`calendar-update`](references/calendar-update.md) | 更新/修改日程时 | 更新日程信息(主题、时间、参与人、地点等) |
| [`calendar-freebusy`](references/calendar-freebusy.md) | 需要协调时间 / 查共同空闲时 | 查询共同空闲时段,协调日程时间 |
| [`calendar-meeting-room`](references/calendar-meeting-room.md) | 预订/更换会议室 / 查办公楼或会议室可订性时 | 办公楼清单(`buildings list`)+ 会议室可订性(`rooms search`),拿 `meeting_room_id` 供创建占用或更新改订 |

## 上下文传递表

> 此表描述接口间的数据流转契约,第一列"来源操作"为业务语义;各操作的完整参数与字段定义见对应 reference。

| 来源操作 | 从返回中提取 | 用于 |
|---------|-------------|------|
| 搜索(search) | `schedules[].schedule_id` | 单条详情、取消日程 |
| 列表浏览 / 单条详情(list / get) | `schedule_list[].schedule_id` | 单条详情、取消日程 |
| 搜索(search) | `schedules[].attendees[].name` | 直接展示参与人姓名,无需额外反查(搜索接口已返回) |
| 搜索(search) | `schedules[].creator_name` | 直接展示日程创建者姓名 |
| 搜索(search) | `next_cursor` + `has_more` | 分页翻页控制 |
| wecomcli-contact 技能搜索 | `userid`(`wo` 前缀) | 创建/更新日程的 `attendees` / `add_attendees` / `remove_attendees`、忙闲查询的 `userids`、搜索的 `has_attendees`(均组装为对象数组 `[{"userid": "woxxx"}]`);搜索的 `organizer` 为单值 userid 字符串 |
| 搜索 / 列表浏览 / 单条详情 | `repeat_rule` | 判断是否周期日程(`is_repeat=true`):命中时取消 / 更新均不支持,告知用户并引导企业微信客户端操作;`search`/`list` 均直接返回,无需补 `get` |
| 搜索 / 列表浏览 / 单条详情 | `meeting.meeting_code` | 识别该日程含在线会议链接(非空即「会议形态日程」,search/list/get 均直接返回,无需额外补 `get`);改约 / 取消含会议链接日程时,直接把 `meeting.meeting_id` 传入 `wecomcli-meeting` 的 `meeting update` / `meeting cancel`,无需在 wecomcli-meeting 重新 search 定位 |
| 搜索 / 列表浏览 + 单条详情 | 搜索取 `schedules[].schedule_id`、列表/详情取 `schedule_list[].schedule_id` 与 `schedule_list[].repeat_rule` | 更新日程的定位与周期日程判断(命中周期日程则不支持更新) |
| 忙闲查询 | `slots[]`(含 `available_users`、`available_count`、`busy_users`) | 直接展示推荐时段,挑前几个让用户选择;展示时只用人名,userid 仅回传创建日程的 `attendees` |
| 会议室可订性查询(`rooms search`) | `target[].room.meeting_room_id` 或 `recommendations[].meeting_room_id` | 创建日程的 `meeting_room_id`(原子占用会议室)、更新日程的 `meeting_room_id`(改订会议室);ID仅工具链流转,禁止展示,对用户只露会议室 name |

## 错误处理

> 原则:告诉用户**出了什么问题** + **可以怎么做** + **备选方案**。禁止静默失败。

| 场景 | 恢复建议 |
|------|---------|
| 搜索无结果 | 用文字提供恢复建议:1. 更换关键词重试;2. 按组织人搜索(提供姓名,解析 userid 后传 `organizer`);3. 按参与人搜索(提供姓名,解析 userid 后传 `has_attendees`);|
| 通讯录多候选人 | 用文字列出候选人(姓名+部门)供选择 |
| wecomcli-contact 技能搜索无结果 | 用文字提示用户确认姓名,等待重新输入 |
| 取消/修改非本人创建的日程 | 不预先拦截,直接执行命令;返回权限错误时说明当前用户无权操作,建议联系创建人 |
| 共同空闲查询返回空 `slots` | 引导用户扩大时间窗口或减少参与人,不要在同一窗口反复重试 |
| 共同空闲查询降级(`available_count < total_count`) | 告知哪些人冲突、几人能参加,由用户决定是否按降级时段安排或更换时间 |

## 输出质量标准

好的输出应满足以下条件:
- 日程列表:按开始时间升序排序,每条日程作为独立条目顺序输出(**禁止 markdown 表格**),每个条目只含主题、时间、参与人;超过 10 条只展示前 10 条
- 参与人展示:原样使用接口返回的 `attendees[].name` 字段(完全与接口返回的格式保持一致,如返回 `zhangsan(张三)` 就展示 `zhangsan(张三)`),不展示 userid
- 操作结果:明确告知成功/失败及原因,操作成功后展示日程摘要
- 错误提示:包含问题描述+恢复建议+备选方案,不暴露技术错误码

不可接受的输出:
- 直接展示 userid 而非姓名
- 遇到错误静默失败,不给用户任何提示
- 展示内部 schedule_id


## 输出格式规范

**参与人姓名格式 [REQUIRED]**:所有展示参与人的场景(创建反馈、单条摘要、列表等),姓名一律**原样使用接口返回的 `attendees[].name` 字段**,完全与接口返回的格式保持一致(如返回 `zhangsan(张三)` 就展示 `zhangsan(张三)`);下文模板中的 `{人名}` 均指该原样 name。

**时间年份显示 [REQUIRED]**:下文"时间"行默认省略年份、只到月日(模板中的 `{月日}` 即指 `M月D日`);仅当日程年份与当前年份不同(跨年)时,才在月日前补上年份,格式为 `{YYYY}年M月D日 {HH:mm}-{HH:mm}`。

**相对日期标签 [REQUIRED]**:当日程日期为昨天 / 今天 / 明天时,"时间"行在月日前加上相对词,格式 `{昨天|今天|明天} M月D日 {HH:mm}-{HH:mm}`(如 `时间:明天 6月11日 14:00-15:00`);其余日期按 `{月日} {HH:mm}-{HH:mm}` 展示。

**创建成功反馈 [REQUIRED]**:创建日程成功后,输出内容只包含三部分:主题、时间、参与人,禁止输出其他任何内容和额外语句(不展示地点、提醒、schedule_id 等字段,也不附加说明、建议或寒暄):
```
主题:{subject}
时间:{月日} {HH:mm}-{HH:mm}
参与人:{人名1}、{人名2}
```

**单条日程摘要**(用于查看/搜索单条场景,非创建反馈):
```
主题:{subject}
时间:{月日} {HH:mm}-{HH:mm}
参与人:{人名1}、{人名2}
```

**日程列表展示规范 [REQUIRED]**(列表/搜索浏览均适用):
- **禁止使用 markdown 表格**;每条日程作为独立条目顺序输出,按开始时间升序排序。
- 每个条目 **只展示三项:主题、时间、参与人**(不展示地点、提醒、schedule_id 等)。
- **超过 10 条时只展示前 10 条**,并在末尾告知"还有 N 条,需要查看更多吗?"。
- **会议 / 日程 分两部分展示**:判断依据是该日程是否带有会议链接——`meeting.meeting_code` 有值(非空)归为「会议」,为空 / 不存在归为「日程」(`search`/`list`/`get` 返回均含 `meeting` 字段,可直接判断)。**仅当本次结果中同时存在「会议」和「日程」两类时**,才把结果分成「(会议)」和「(日程)」两个部分分别展示:先列「(会议)」部分、再列「(日程)」部分;每部分内部按开始时间升序、逐条只展示主题/时间/参与人;末尾追加汇总"共 N 场,其中会议 X 场、日程 Y 场"。**当结果只有单一类别时**(全是会议或全是日程),不分部分、不加「(会议)」/「(日程)」标题,按普通列表直接展示。
- 分部分格式:
```
(会议)
1. {主题}
   时间:{月日} {HH:mm}-{HH:mm}
   参与人:{人名1}、{人名2}

(日程)
1. {主题}
   时间:{月日} {HH:mm}-{HH:mm}
   参与人:{人名1}、{人名2}
```

**时区标注 [REQUIRED]**:日程 `timezone_offset != 28800`(非东八区)时,展示时间必须带时区标注,格式 `{HH:mm}-{HH:mm}({地区中文名} UTC±N)`,如 `14:00-15:00(纽约时间 UTC-5)`。
- `UTC±N` 由 `timezone_offset / 3600` 得出。
- 地区中文名由 `timezone_id` 推导(如 `America/New_York` → 纽约时间);`timezone_id` 为空时省略中文名,只留 `(UTC-5)`。
- 东八区(`timezone_offset = 28800`,含 `Asia/Shanghai`、`Asia/Singapore` 等)不标注,保持现状。
- 适用于单条摘要的"时间"行、日程列表、创建/更新成功反馈;freebusy 的 `slots` 不适用(按本人时区展示)。

## 已知限制

| 限制 | 替代方案 |
|------|---------|
| **schedules list 查询窗口 ≤ 前后 30 天** | `begin_time`/`end_time` 必须落在「当前时刻前后 30 天」窗口内,超出范围服务端不返回。超出时直接告知用户超出可查范围、请重新给一个更短的时间范围,等用户重新提供后再调用 |
| **不支持创建/更新/取消周期(重复)日程** | 用户希望创建"每周/每月/每天重复"等周期日程,或对已识别为周期日程(`repeat_rule.is_repeat=true`)的日程发起更新、取消时,均直接告知用户目前不支持,并引导用户在企业微信客户端手动操作;禁止用创建多条单次日程、逐场 `update` 拼凑、`cancel`+`create` 重建、传入未公开参数等方式变通绕过 |
| **不支持回复 / 拒绝日程邀请(RSVP)** | 本技能不支持对收到的日程邀请做接受 / 拒绝 / 待定等回复(含"拒绝这个日程""不参加""婉拒邀请"等)。用户有此需求时,告知其本技能不支持,建议直接在企业微信客户端对该日程邀请操作,或通过消息告知日程发起人 |
| **共同空闲查询限制** | 周期日程仅查看最近两个月有修改的;单次查询窗口 ≤ 24h,超出需分批;`begin_time` 早于服务端当前时刻的部分会被自动截断,传纯历史窗口会返回空 `slots` |