Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83f3b4f385 | ||
|
|
13d0ae66a6 | ||
|
|
63854705fd | ||
|
|
f3b0fcdc4c | ||
|
|
00ca448aa2 | ||
|
|
afe01d4b70 | ||
|
|
4c6db326f5 | ||
|
|
f10d552fd7 | ||
|
|
8b8756b00e | ||
|
|
ec59cf8065 | ||
|
|
2ffddbd5a0 | ||
|
|
1d3c56f9fa | ||
|
|
502317db68 | ||
|
|
6e3f528f48 | ||
|
|
d70e6b85b6 | ||
|
|
c8f83533fb | ||
|
|
75f08da197 |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending.
|
||||
- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery.
|
||||
@@ -482,7 +482,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and seven OA approval task/instance events.
|
||||
|
||||
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
|
||||
|
||||
@@ -530,12 +530,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# Listen for all six public OA approval events in one process
|
||||
# Listen for all seven public OA approval events in one process
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
+3
-2
@@ -476,7 +476,7 @@ multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及七个 OA 审批任务/实例事件。
|
||||
|
||||
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
|
||||
|
||||
@@ -524,12 +524,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# 一个进程监听全部六个公开 OA 审批事件
|
||||
# 一个进程监听全部七个公开 OA 审批事件
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
@@ -403,7 +403,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
|
||||
UseWhen: []string{
|
||||
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听七个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
|
||||
},
|
||||
|
||||
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount != 22 {
|
||||
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
|
||||
if publicCount != 23 {
|
||||
t.Fatalf("public personal events = %d, want 23 (16 IM + 7 OA)", publicCount)
|
||||
}
|
||||
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
|
||||
if !ruleTypes[ruleType] {
|
||||
|
||||
@@ -61,6 +61,13 @@ func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceCC,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
@@ -161,6 +168,7 @@ func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceCC,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -414,6 +422,7 @@ func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceCC,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
|
||||
@@ -51,7 +51,38 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
return `{"success":true,"result":{"events":[],"hasMore":false,"nextCursor":""}}`
|
||||
case "get_calendar_detail":
|
||||
return c.paramAliasCalendarDetailResponse()
|
||||
case "get_calendar_participants":
|
||||
return `{"success":true,"result":{"participants":[{"userId":"fixture-user","displayName":"Fixture User"},{"userId":"user-2","displayName":"User Two"}]}}`
|
||||
case "search_calendar":
|
||||
return `{"success":true,"result":{"calendars":[]}}`
|
||||
case "search_rooms":
|
||||
return `{"success":true,"result":{"rooms":[]}}`
|
||||
case "query_available_meeting_room":
|
||||
return `{"success":true,"result":{"rooms":[],"hasMore":false}}`
|
||||
case "list_meeting_room_groups":
|
||||
return `{"success":true,"result":{"groups":[]}}`
|
||||
case "query_busy_status":
|
||||
return `{"success":true,"result":[]}`
|
||||
case "list_suggested_event_times":
|
||||
return `{"success":true,"result":{"recommendEventTimes":[]}}`
|
||||
case "create_calendar_event":
|
||||
return `{"success":true,"result":{"eventId":"event-1"}}`
|
||||
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
|
||||
return `{"success":true}`
|
||||
case "respond":
|
||||
status := "accepted"
|
||||
if call := c.lastParamAliasCall(); call != nil {
|
||||
if value, ok := call.args["responseStatus"].(string); ok && value != "" {
|
||||
status = value
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"responseStatus": status}})
|
||||
return string(encoded)
|
||||
case "get_current_user_profile":
|
||||
return `{"success":true,"result":{"userId":"user-1","name":"Fixture Current User"}}`
|
||||
case "query_records":
|
||||
return `{"success":true,"status":"success","error":{},"data":{}}`
|
||||
case "search_mail_users":
|
||||
@@ -127,6 +158,41 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
}
|
||||
}
|
||||
|
||||
func (c *paramAliasCaptureCaller) lastParamAliasCall() *paramAliasToolCall {
|
||||
if len(c.calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &c.calls[len(c.calls)-1]
|
||||
}
|
||||
|
||||
func (c *paramAliasCaptureCaller) paramAliasCalendarDetailResponse() string {
|
||||
event := map[string]any{
|
||||
"eventId": "event-1",
|
||||
"summary": "Fixture Meeting",
|
||||
"description": "fixture description",
|
||||
"startDateTime": "2026-03-10T09:00:00+08:00",
|
||||
"endDateTime": "2026-03-10T10:00:00+08:00",
|
||||
}
|
||||
for _, call := range c.calls {
|
||||
switch call.tool {
|
||||
case "create_calendar_event", "update_calendar_event":
|
||||
for _, key := range []string{"eventId", "summary", "description", "startDateTime", "endDateTime", "timeZone", "location", "freeBusy"} {
|
||||
if value, ok := call.args[key]; ok {
|
||||
event[key] = value
|
||||
}
|
||||
}
|
||||
case "respond":
|
||||
if value, ok := call.args["responseStatus"]; ok {
|
||||
event["responseStatus"] = value
|
||||
}
|
||||
case "delete_calendar_event":
|
||||
event["status"] = "cancelled"
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": event})
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func (*paramAliasCaptureCaller) Format() string { return "json" }
|
||||
func (*paramAliasCaptureCaller) DryRun() bool { return false }
|
||||
func (*paramAliasCaptureCaller) Fields() string { return "" }
|
||||
|
||||
@@ -5,6 +5,8 @@ package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -17,6 +19,8 @@ import (
|
||||
const (
|
||||
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
|
||||
paramAliasCalendarPayloadChildEnv = "DWS_TEST_CALENDAR_PARAM_ALIAS_PAYLOAD_CHILD"
|
||||
)
|
||||
|
||||
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
|
||||
@@ -46,7 +50,37 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"aitable workflow run": {"aitable", "workflow", "run", "--base-id", "base-1", "--workflow-id", "workflow-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
|
||||
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"calendar +agenda": {"calendar", "+agenda", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"calendar +attendee-list": {"calendar", "+attendee-list", "--event", "event-1", "--calendar-id", "primary"},
|
||||
"calendar +book": {"calendar", "+book", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--with", "Fixture User", "--yes"},
|
||||
"calendar +book-search": {"calendar", "+book-search", "--query", "fixture"},
|
||||
"calendar +cancel-event": {"calendar", "+cancel-event", "--event", "event-1", "--yes"},
|
||||
"calendar +conflicts": {"calendar", "+conflicts", "--in-days", "1"},
|
||||
"calendar +create": {"calendar", "+create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--desc", "fixture description", "--attendees", "user-1,user-2", "--rooms", "room-1,room-2", "--calendar-id", "primary", "--yes"},
|
||||
"calendar +free": {"calendar", "+free", "--who", "Fixture User", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +free-slots": {"calendar", "+free-slots", "--from", "9", "--to", "18", "--in-days", "1"},
|
||||
"calendar +freebusy": {"calendar", "+freebusy", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +get": {"calendar", "+get", "--event", "event-1", "--calendar-id", "primary"},
|
||||
"calendar +invite": {"calendar", "+invite", "--event", "event-1", "--with", "Fixture User", "--yes"},
|
||||
"calendar +my-free": {"calendar", "+my-free", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +reschedule": {"calendar", "+reschedule", "--event", "event-1", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--yes"},
|
||||
"calendar +room-find": {"calendar", "+room-find", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--room-name", "Fixture Room", "--group-id", "group-1", "--page", "1", "--limit", "7"},
|
||||
"calendar +room-groups": {"calendar", "+room-groups", "--page", "1", "--limit", "7"},
|
||||
"calendar +room-search": {"calendar", "+room-search", "--room-name", "Fixture Room"},
|
||||
"calendar +rsvp": {"calendar", "+rsvp", "--event", "event-1", "--status", "accept", "--calendar-id", "primary", "--yes"},
|
||||
"calendar +search-event": {"calendar", "+search-event", "--query", "fixture", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"calendar +suggest-time": {"calendar", "+suggest-time", "--with", "Fixture User", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +suggestion": {"calendar", "+suggestion", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
|
||||
"calendar +update": {"calendar", "+update", "--event", "event-1", "--title", "Fixture Updated Meeting", "--desc", "fixture updated description", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--add-attendees", "user-2", "--remove-attendees", "user-1", "--yes"},
|
||||
"calendar busy search": {"calendar", "busy", "search", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar event create": {"calendar", "event", "create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--remind-minutes", "15", "--timezone", "Asia/Shanghai", "--rooms", "room-1,room-2"},
|
||||
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"calendar event respond": {"calendar", "event", "respond", "--id", "event-1", "--status", "accepted"},
|
||||
"calendar event suggest": {"calendar", "event", "suggest", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
|
||||
"calendar event update": {"calendar", "event", "update", "--id", "event-1", "--timezone", "Asia/Shanghai"},
|
||||
"calendar room add": {"calendar", "room", "add", "--event", "event-1", "--rooms", "room-1,room-2"},
|
||||
"calendar room delete": {"calendar", "room", "delete", "--event", "event-1", "--rooms", "room-1,room-2"},
|
||||
"calendar room search": {"calendar", "room", "search", "--room-name", "Fixture Room", "--group-id", "group-1", "--start", "2027-03-10T09:00:00+08:00", "--end", "2027-03-10T10:00:00+08:00", "--page", "1", "--limit", "7"},
|
||||
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
|
||||
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
|
||||
@@ -566,6 +600,108 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
|
||||
// separate from the long-lived app-c race process. Each case still executes
|
||||
// both canonical and alias argv through the real PreParse/Cobra path and
|
||||
// compares the final captured transport calls; the owning top-level test runs
|
||||
// these allocations in a short-lived race-instrumented subprocess so all Root
|
||||
// registrations are released together when that process exits.
|
||||
var paramAliasCalendarPayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "max-results"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "next-cursor"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +attendee-list", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +attendee-list", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book", "attendee-names"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book-search", "keyword"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book-search", "search"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book-search", "name"): true,
|
||||
paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +cancel-event", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free", "name"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free-slots", "start-hour"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free-slots", "end-hour"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free-slots", "day-offset"): true,
|
||||
paramAliasPayloadCaseKey("calendar +freebusy", "user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +freebusy", "room-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +freebusy", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +my-free", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +my-free", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +invite", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +invite", "participant-names"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-groups", "page-size"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-groups", "page-index"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-search", "query"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggest-time", "duration-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggest-time", "attendee-names"): true,
|
||||
paramAliasPayloadCaseKey("calendar +conflicts", "day-offset"): true,
|
||||
paramAliasPayloadCaseKey("calendar busy search", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar event create", "reminder-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar event create", "tz"): true,
|
||||
paramAliasPayloadCaseKey("calendar event create", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar event respond", "response-status"): true,
|
||||
paramAliasPayloadCaseKey("calendar event suggest", "duration-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar event update", "tz"): true,
|
||||
paramAliasPayloadCaseKey("calendar room add", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar room delete", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar room search", "room-group-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "description"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "room-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +get", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +get", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "page-size"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "page-index"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "room-group-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "query"): true,
|
||||
paramAliasPayloadCaseKey("calendar +rsvp", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "keyword"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "next-cursor"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "max-results"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "duration-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "tz"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "description"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "add-user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "remove-user-ids"): true,
|
||||
}
|
||||
|
||||
// paramAliasCalendarConfirmationCases selects one newly reviewed alias for
|
||||
// every Calendar Shortcut whose runtime contract requires user confirmation.
|
||||
// The complete Calendar matrix proves confirmed canonical/alias payload
|
||||
// equality; these representatives additionally prove semantic normalization
|
||||
// cannot cross the confirmation boundary before the first transport call.
|
||||
var paramAliasCalendarConfirmationCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("calendar +book", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +invite", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "event-id"): true,
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
@@ -599,28 +735,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -650,6 +765,118 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
if os.Getenv(paramAliasCalendarPayloadChildEnv) != "1" {
|
||||
command := exec.Command(
|
||||
os.Args[0],
|
||||
"-test.run=^TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads$",
|
||||
"-test.count=1",
|
||||
"-test.timeout=5m",
|
||||
)
|
||||
command.Env = append(os.Environ(), paramAliasCalendarPayloadChildEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("Calendar param-alias payload subprocess failed: %v\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
|
||||
executed := make(map[string]bool)
|
||||
executedConfirmation := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasCalendarPayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executed[caseKey] = true
|
||||
fixture := fixture
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Calendar alias has no complete-command E2E template")
|
||||
}
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete Calendar command must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Expect, replacements, canonicalArgs)
|
||||
}
|
||||
assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs)
|
||||
if paramAliasCalendarConfirmationCases[caseKey] {
|
||||
executedConfirmation[caseKey] = true
|
||||
assertParamAliasCannotBypassConfirmation(t, aliasArgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for caseKey := range paramAliasCalendarPayloadCases {
|
||||
if !executed[caseKey] {
|
||||
t.Errorf("Calendar final-payload case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executed) != len(paramAliasCalendarPayloadCases) {
|
||||
t.Fatalf("Calendar final-payload coverage = %d, want %d", len(executed), len(paramAliasCalendarPayloadCases))
|
||||
}
|
||||
for caseKey := range paramAliasCalendarConfirmationCases {
|
||||
if !executedConfirmation[caseKey] {
|
||||
t.Errorf("Calendar confirmation case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedConfirmation) != len(paramAliasCalendarConfirmationCases) {
|
||||
t.Fatalf("Calendar confirmation coverage = %d, want %d", len(executedConfirmation), len(paramAliasCalendarConfirmationCases))
|
||||
}
|
||||
}
|
||||
|
||||
func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string) {
|
||||
t.Helper()
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
|
||||
if removals != 1 {
|
||||
t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed Calendar alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed Calendar alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed Calendar alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
|
||||
t.Helper()
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
activeAliases := 0
|
||||
for _, test := range paramAliasNewIMCases {
|
||||
|
||||
@@ -1773,6 +1773,439 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "staff-id", "uid", "user", "user-id", "userid"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +agenda",
|
||||
Aliases: map[string]string{
|
||||
"begin": "start",
|
||||
"calendar": "calendar-id",
|
||||
"calendar-book-id": "calendar-id",
|
||||
"end-date": "end",
|
||||
"end-time": "end",
|
||||
"from": "start",
|
||||
"from-date": "start",
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"max-time": "end",
|
||||
"min-time": "start",
|
||||
"next-cursor": "cursor",
|
||||
"next-page-token": "cursor",
|
||||
"next-token": "cursor",
|
||||
"page-size": "limit",
|
||||
"page-token": "cursor",
|
||||
"per-page": "limit",
|
||||
"since": "start",
|
||||
"size": "limit",
|
||||
"start-date": "start",
|
||||
"start-time": "start",
|
||||
"take": "limit",
|
||||
"time-max": "end",
|
||||
"time-min": "start",
|
||||
"to": "end",
|
||||
"top": "limit",
|
||||
},
|
||||
Blocked: []string{"acl-id", "count", "date", "event", "event-id", "id", "offset", "page", "room-id", "time"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +attendee-list",
|
||||
Aliases: map[string]string{
|
||||
"calendar": "calendar-id",
|
||||
"calendar-book-id": "calendar-id",
|
||||
"calendar-event-id": "event",
|
||||
"event-id": "event",
|
||||
},
|
||||
Blocked: []string{"acl-id", "room-id"},
|
||||
Ambiguous: []string{"id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +book",
|
||||
Aliases: map[string]string{
|
||||
"attendee-names": "with",
|
||||
"begin": "start",
|
||||
"end-time": "end",
|
||||
"from": "start",
|
||||
"names": "with",
|
||||
"participant-names": "with",
|
||||
"start-time": "start",
|
||||
"subject": "title",
|
||||
"summary": "title",
|
||||
"to": "end",
|
||||
},
|
||||
Blocked: []string{"attendees", "calendar-id", "calendar-name", "date", "end-date", "name", "open-dingtalk-ids", "participants", "room-id", "room-ids", "room-name", "rooms", "start-date", "time", "time-max", "time-min", "user", "user-id", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +book-search",
|
||||
Aliases: map[string]string{
|
||||
"keyword": "query",
|
||||
"keywords": "query",
|
||||
"name": "query",
|
||||
"q": "query",
|
||||
"search": "query",
|
||||
"search-word": "query",
|
||||
},
|
||||
Blocked: []string{"subject", "text", "title"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +cancel-event",
|
||||
Aliases: map[string]string{
|
||||
"calendar-event-id": "event",
|
||||
"event-id": "event",
|
||||
"id": "event",
|
||||
},
|
||||
Blocked: []string{"acl-id", "calendar-book-id", "calendar-id", "room-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +conflicts",
|
||||
Aliases: map[string]string{
|
||||
"day-offset": "in-days",
|
||||
"days-from-today": "in-days",
|
||||
},
|
||||
Blocked: []string{"days", "duration", "end", "from", "start", "to"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +create",
|
||||
Aliases: map[string]string{
|
||||
"attendee-ids": "attendees",
|
||||
"begin": "start",
|
||||
"calendar": "calendar-id",
|
||||
"calendar-book-id": "calendar-id",
|
||||
"description": "desc",
|
||||
"end-time": "end",
|
||||
"freebusy": "free-busy",
|
||||
"from": "start",
|
||||
"room-id": "rooms",
|
||||
"room-ids": "rooms",
|
||||
"start-time": "start",
|
||||
"subject": "title",
|
||||
"summary": "title",
|
||||
"time-zone": "timezone",
|
||||
"to": "end",
|
||||
"tz": "timezone",
|
||||
"user-ids": "attendees",
|
||||
"users": "attendees",
|
||||
},
|
||||
Blocked: []string{"acl-id", "attendee-name", "attendee-names", "calendar-name", "config", "date", "end-date", "event", "event-id", "field-description", "group-id", "id", "locale", "name", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "rich-text-desc", "room", "room-name", "start-date", "time", "time-max", "time-min", "utc-offset", "who", "with"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +free",
|
||||
Aliases: map[string]string{
|
||||
"begin": "start",
|
||||
"end-date": "end",
|
||||
"end-time": "end",
|
||||
"from": "start",
|
||||
"from-date": "start",
|
||||
"max-time": "end",
|
||||
"min-time": "start",
|
||||
"name": "who",
|
||||
"person": "who",
|
||||
"person-name": "who",
|
||||
"since": "start",
|
||||
"start-date": "start",
|
||||
"start-time": "start",
|
||||
"time-max": "end",
|
||||
"time-min": "start",
|
||||
"to": "end",
|
||||
},
|
||||
Blocked: []string{"date", "time", "user", "user-id", "user-ids", "users", "with"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +free-slots",
|
||||
Aliases: map[string]string{
|
||||
"day-offset": "in-days",
|
||||
"days-from-today": "in-days",
|
||||
"end-hour": "to",
|
||||
"start-hour": "from",
|
||||
},
|
||||
Blocked: []string{"days", "duration", "end", "end-time", "start", "start-time", "time-max", "time-min"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +freebusy",
|
||||
Aliases: map[string]string{
|
||||
"begin": "start",
|
||||
"end-date": "end",
|
||||
"end-time": "end",
|
||||
"from": "start",
|
||||
"from-date": "start",
|
||||
"max-time": "end",
|
||||
"min-time": "start",
|
||||
"room-id": "rooms",
|
||||
"room-ids": "rooms",
|
||||
"since": "start",
|
||||
"start-date": "start",
|
||||
"start-time": "start",
|
||||
"time-max": "end",
|
||||
"time-min": "start",
|
||||
"to": "end",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "attendee-names", "date", "group-id", "location", "name", "names", "participant-names", "room", "room-name", "staff-id", "time", "uid", "user", "user-id", "userid", "who", "with"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +get",
|
||||
Aliases: map[string]string{
|
||||
"calendar": "calendar-id",
|
||||
"calendar-book-id": "calendar-id",
|
||||
"calendar-event-id": "event",
|
||||
"event-id": "event",
|
||||
},
|
||||
Blocked: []string{"acl-id", "room-id"},
|
||||
Ambiguous: []string{"id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +invite",
|
||||
Aliases: map[string]string{
|
||||
"attendee-names": "with",
|
||||
"calendar-event-id": "event",
|
||||
"event-id": "event",
|
||||
"id": "event",
|
||||
"names": "with",
|
||||
"participant-names": "with",
|
||||
},
|
||||
Blocked: []string{"acl-id", "attendees", "calendar-book-id", "calendar-id", "open-dingtalk-ids", "participants", "room-id", "user", "user-id", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +my-free",
|
||||
Aliases: map[string]string{
|
||||
"begin": "start",
|
||||
"end-date": "end",
|
||||
"end-time": "end",
|
||||
"from": "start",
|
||||
"from-date": "start",
|
||||
"max-time": "end",
|
||||
"min-time": "start",
|
||||
"since": "start",
|
||||
"start-date": "start",
|
||||
"start-time": "start",
|
||||
"time-max": "end",
|
||||
"time-min": "start",
|
||||
"to": "end",
|
||||
},
|
||||
Blocked: []string{"date", "time"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +reschedule",
|
||||
Aliases: map[string]string{
|
||||
"begin": "start",
|
||||
"calendar-event-id": "event",
|
||||
"end-time": "end",
|
||||
"event-id": "event",
|
||||
"from": "start",
|
||||
"id": "event",
|
||||
"start-time": "start",
|
||||
"to": "end",
|
||||
},
|
||||
Blocked: []string{"acl-id", "calendar-book-id", "calendar-id", "date", "end-date", "room-id", "start-date", "time", "time-max", "time-min"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +room-find",
|
||||
Aliases: map[string]string{
|
||||
"begin": "start",
|
||||
"end-date": "end",
|
||||
"end-time": "end",
|
||||
"from": "start",
|
||||
"from-date": "start",
|
||||
"group": "group-id",
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"max-time": "end",
|
||||
"min-time": "start",
|
||||
"name": "room-name",
|
||||
"page-index": "page",
|
||||
"page-size": "limit",
|
||||
"per-page": "limit",
|
||||
"query": "room-name",
|
||||
"room-group-id": "group-id",
|
||||
"since": "start",
|
||||
"size": "limit",
|
||||
"start-date": "start",
|
||||
"start-time": "start",
|
||||
"take": "limit",
|
||||
"time-max": "end",
|
||||
"time-min": "start",
|
||||
"to": "end",
|
||||
"top": "limit",
|
||||
},
|
||||
Blocked: []string{"count", "cursor", "date", "location", "next-cursor", "page-token", "room", "room-id", "room-ids", "rooms", "time"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +room-groups",
|
||||
Aliases: map[string]string{
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"page-index": "page",
|
||||
"page-size": "limit",
|
||||
"per-page": "limit",
|
||||
"size": "limit",
|
||||
"take": "limit",
|
||||
"top": "limit",
|
||||
},
|
||||
Blocked: []string{"count", "cursor", "page-token"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +room-search",
|
||||
Aliases: map[string]string{
|
||||
"name": "room-name",
|
||||
"query": "room-name",
|
||||
},
|
||||
Blocked: []string{"group-id", "location", "room", "room-id", "room-ids", "rooms"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +rsvp",
|
||||
Aliases: map[string]string{
|
||||
"calendar": "calendar-id",
|
||||
"calendar-book-id": "calendar-id",
|
||||
"calendar-event-id": "event",
|
||||
"event-id": "event",
|
||||
"response": "status",
|
||||
"response-status": "status",
|
||||
},
|
||||
Blocked: []string{"acl-id", "availability", "done", "free-busy", "room-id", "state"},
|
||||
Ambiguous: []string{"id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +search-event",
|
||||
Aliases: map[string]string{
|
||||
"begin": "start",
|
||||
"calendar": "calendar-id",
|
||||
"calendar-book-id": "calendar-id",
|
||||
"end-date": "end",
|
||||
"end-time": "end",
|
||||
"from": "start",
|
||||
"from-date": "start",
|
||||
"keyword": "query",
|
||||
"keywords": "query",
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"max-time": "end",
|
||||
"min-time": "start",
|
||||
"next-cursor": "cursor",
|
||||
"next-page-token": "cursor",
|
||||
"next-token": "cursor",
|
||||
"page-size": "limit",
|
||||
"page-token": "cursor",
|
||||
"per-page": "limit",
|
||||
"q": "query",
|
||||
"search": "query",
|
||||
"search-word": "query",
|
||||
"since": "start",
|
||||
"size": "limit",
|
||||
"start-date": "start",
|
||||
"start-time": "start",
|
||||
"take": "limit",
|
||||
"time-max": "end",
|
||||
"time-min": "start",
|
||||
"to": "end",
|
||||
"top": "limit",
|
||||
},
|
||||
Blocked: []string{"acl-id", "count", "date", "event", "event-id", "id", "name", "offset", "page", "page-index", "room-id", "subject", "text", "time", "title"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +suggest-time",
|
||||
Aliases: map[string]string{
|
||||
"attendee-names": "with",
|
||||
"begin": "start",
|
||||
"duration-minutes": "duration",
|
||||
"end-date": "end",
|
||||
"end-time": "end",
|
||||
"from": "start",
|
||||
"from-date": "start",
|
||||
"max-time": "end",
|
||||
"meeting-duration-minutes": "duration",
|
||||
"min-time": "start",
|
||||
"names": "with",
|
||||
"participant-names": "with",
|
||||
"since": "start",
|
||||
"start-date": "start",
|
||||
"start-time": "start",
|
||||
"time-max": "end",
|
||||
"time-min": "start",
|
||||
"to": "end",
|
||||
},
|
||||
Blocked: []string{"attendees", "date", "open-dingtalk-ids", "participants", "remind-minutes", "time", "user", "user-id", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +suggestion",
|
||||
Aliases: map[string]string{
|
||||
"begin": "start",
|
||||
"duration-minutes": "duration",
|
||||
"end-date": "end",
|
||||
"end-time": "end",
|
||||
"from": "start",
|
||||
"from-date": "start",
|
||||
"max-time": "end",
|
||||
"meeting-duration-minutes": "duration",
|
||||
"min-time": "start",
|
||||
"since": "start",
|
||||
"start-date": "start",
|
||||
"start-time": "start",
|
||||
"time-max": "end",
|
||||
"time-min": "start",
|
||||
"time-zone": "timezone",
|
||||
"to": "end",
|
||||
"tz": "timezone",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "attendee-name", "attendee-names", "date", "locale", "name", "names", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "remind-minutes", "room-id", "room-ids", "room-name", "rooms", "staff-id", "time", "uid", "user", "user-id", "userid", "utc-offset", "who", "with"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar +update",
|
||||
Aliases: map[string]string{
|
||||
"add-user-ids": "add-attendees",
|
||||
"add-users": "add-attendees",
|
||||
"begin": "start",
|
||||
"calendar": "calendar-id",
|
||||
"calendar-book-id": "calendar-id",
|
||||
"calendar-event-id": "event",
|
||||
"description": "desc",
|
||||
"end-time": "end",
|
||||
"event-id": "event",
|
||||
"freebusy": "free-busy",
|
||||
"from": "start",
|
||||
"remove-user-ids": "remove-attendees",
|
||||
"remove-users": "remove-attendees",
|
||||
"start-time": "start",
|
||||
"subject": "title",
|
||||
"summary": "title",
|
||||
"time-zone": "timezone",
|
||||
"to": "end",
|
||||
"tz": "timezone",
|
||||
},
|
||||
Blocked: []string{"acl-id", "attendee-name", "attendee-names", "calendar-name", "config", "date", "end-date", "field-description", "group-id", "locale", "name", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "remind-minutes", "reminder-minutes", "rich-text-desc", "room-id", "room-ids", "room-name", "rooms", "start-date", "time", "time-max", "time-min", "utc-offset", "who", "with"},
|
||||
Ambiguous: []string{"attendees", "id", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar acl delete",
|
||||
Blocked: []string{"calendar-book-id", "calendar-id", "event", "event-id", "room-id", "user-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar attachment add",
|
||||
Blocked: []string{"attachments", "file", "file-id", "file-ids"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar attendee add",
|
||||
Blocked: []string{"attendee-name", "attendee-names", "open-dingtalk-ids", "participant-name", "participant-names", "who", "with"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar attendee delete",
|
||||
Blocked: []string{"attendee-name", "attendee-names", "open-dingtalk-ids", "participant-name", "participant-names", "who", "with"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar busy search",
|
||||
Aliases: map[string]string{
|
||||
"room-id": "rooms",
|
||||
},
|
||||
Blocked: []string{"attendee-name", "attendee-names", "group-id", "location", "name", "names", "participant-names", "room", "room-name", "who", "with"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar event create",
|
||||
Aliases: map[string]string{
|
||||
"reminder-minutes": "remind-minutes",
|
||||
"reminder-offset-minutes": "remind-minutes",
|
||||
"room-id": "rooms",
|
||||
"time-zone": "timezone",
|
||||
"tz": "timezone",
|
||||
},
|
||||
Blocked: []string{"at", "attendee-name", "attendee-names", "due", "duration", "group-id", "locale", "offset", "participant-name", "participant-names", "remind-at", "reminder-time", "room", "room-name", "utc-offset", "who", "with"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar event list",
|
||||
Aliases: map[string]string{
|
||||
@@ -1789,6 +2222,54 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
},
|
||||
Blocked: []string{"offset", "page", "time"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar event respond",
|
||||
Aliases: map[string]string{
|
||||
"response": "status",
|
||||
"response-status": "status",
|
||||
},
|
||||
Blocked: []string{"availability", "done", "free-busy", "state"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar event suggest",
|
||||
Aliases: map[string]string{
|
||||
"duration-minutes": "duration",
|
||||
"meeting-duration-minutes": "duration",
|
||||
"time-zone": "timezone",
|
||||
"tz": "timezone",
|
||||
},
|
||||
Blocked: []string{"attendee-name", "attendee-names", "from", "locale", "name", "names", "offset", "open-dingtalk-ids", "participant-names", "remind-minutes", "to", "utc-offset", "who", "with"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar event update",
|
||||
Aliases: map[string]string{
|
||||
"time-zone": "timezone",
|
||||
"tz": "timezone",
|
||||
},
|
||||
Blocked: []string{"attendees", "group-id", "locale", "offset", "participants", "remind-minutes", "reminder-minutes", "room-id", "room-ids", "room-name", "rooms", "utc-offset"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar room add",
|
||||
Aliases: map[string]string{
|
||||
"room-id": "rooms",
|
||||
},
|
||||
Blocked: []string{"group-id", "location", "room", "room-name"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar room delete",
|
||||
Aliases: map[string]string{
|
||||
"room-id": "rooms",
|
||||
},
|
||||
Blocked: []string{"group-id", "location", "room", "room-name"},
|
||||
},
|
||||
{
|
||||
CLIPath: "calendar room search",
|
||||
Aliases: map[string]string{
|
||||
"group": "group-id",
|
||||
"room-group-id": "group-id",
|
||||
},
|
||||
Blocked: []string{"location", "room", "room-id", "room-ids", "rooms"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat +bot-find",
|
||||
Aliases: map[string]string{
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -132,6 +132,7 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
|
||||
{"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_cc", EventOAApprovalInstanceCC, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}},
|
||||
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
|
||||
|
||||
@@ -180,6 +180,19 @@ type OAApprovalInstanceStartedOutput struct {
|
||||
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceCCOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批实例到达抄送节点时的状态"`
|
||||
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批抄送事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceTerminatedOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
@@ -667,6 +680,19 @@ func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.R
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceCC:
|
||||
return OAApprovalInstanceCCOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceTerminated:
|
||||
return OAApprovalInstanceTerminatedOutput{
|
||||
Type: base.Type,
|
||||
@@ -871,6 +897,8 @@ func outputTypeForEvent(eventKey string) reflect.Type {
|
||||
return reflect.TypeOf(OAApprovalTaskRedirectedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceStarted:
|
||||
return reflect.TypeOf(OAApprovalInstanceStartedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceCC:
|
||||
return reflect.TypeOf(OAApprovalInstanceCCOutput{})
|
||||
case eventKey == EventOAApprovalInstanceTerminated:
|
||||
return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceFinished:
|
||||
@@ -906,6 +934,7 @@ func isOAEvent(eventKey string) bool {
|
||||
eventKey == EventOAApprovalTaskFinished ||
|
||||
eventKey == EventOAApprovalTaskRedirected ||
|
||||
eventKey == EventOAApprovalInstanceStarted ||
|
||||
eventKey == EventOAApprovalInstanceCC ||
|
||||
eventKey == EventOAApprovalInstanceTerminated ||
|
||||
eventKey == EventOAApprovalInstanceFinished
|
||||
}
|
||||
|
||||
@@ -173,6 +173,8 @@ func personalOAData(eventKey string) string {
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
case EventOAApprovalInstanceStarted:
|
||||
body["status"] = "RUNNING"
|
||||
case EventOAApprovalInstanceCC:
|
||||
body["status"] = "RUNNING"
|
||||
case EventOAApprovalInstanceTerminated:
|
||||
body["status"] = "TERMINATED"
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
@@ -521,6 +523,21 @@ func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceCC,
|
||||
want: OAApprovalInstanceCCOutput{
|
||||
Type: EventOAApprovalInstanceCC,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
Title: "测试审批",
|
||||
Status: "RUNNING",
|
||||
CreateTime: 1785229100000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceTerminated,
|
||||
want: OAApprovalInstanceTerminatedOutput{
|
||||
@@ -785,6 +802,7 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
|
||||
@@ -43,6 +43,7 @@ const (
|
||||
EventOAApprovalTaskFinished = "user_oa_approval_task_finished"
|
||||
EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected"
|
||||
EventOAApprovalInstanceStarted = "user_oa_approval_instance_started"
|
||||
EventOAApprovalInstanceCC = "user_oa_approval_instance_cc"
|
||||
EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated"
|
||||
EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished"
|
||||
)
|
||||
@@ -323,6 +324,17 @@ var definitions = []Definition{
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceCC,
|
||||
DisplayName: "审批单抄送",
|
||||
Description: "审批实例到达抄送节点,发送给被抄送人",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceTerminated,
|
||||
DisplayName: "审批单终止",
|
||||
|
||||
@@ -50,6 +50,7 @@ func TestCatalogEnabledEvents(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -65,6 +66,7 @@ func TestOAEventCatalogDefinitions(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -158,6 +160,7 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
@@ -509,6 +512,13 @@ func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) {
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceCC,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
@@ -633,6 +643,7 @@ func TestBuildRuleParamAllEvents(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
@@ -845,6 +856,7 @@ func TestSupportsMessageFilter(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
"unknown_event",
|
||||
|
||||
@@ -437,7 +437,7 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
|
||||
}{
|
||||
{
|
||||
name: "user group mentions",
|
||||
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
|
||||
args: []string{"--identity", "user", "--group", "cid", "--text", "@" + fixtureCurrentDOpenID + " <@" + fixtureCurrentDOpenID2 + "> x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
|
||||
want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true},
|
||||
},
|
||||
{
|
||||
@@ -505,6 +505,69 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendRejectsMismatchedCurrentUserMentions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
atIDs string
|
||||
atAll bool
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "declared member missing from body",
|
||||
body: "请查收",
|
||||
atIDs: fixtureCurrentDOpenID,
|
||||
wantErr: "必须在正文中使用对应",
|
||||
},
|
||||
{
|
||||
name: "declared member only appears as a longer token prefix",
|
||||
body: "@" + fixtureCurrentDOpenID + "_suffix 请查收",
|
||||
atIDs: fixtureCurrentDOpenID,
|
||||
wantErr: "必须在正文中使用对应",
|
||||
},
|
||||
{
|
||||
name: "body member missing from declaration",
|
||||
body: "<@" + fixtureCurrentDOpenID + "> 请查收",
|
||||
wantErr: "必须同时通过 --at-open-dingtalk-ids 声明",
|
||||
},
|
||||
{
|
||||
name: "body at-all missing from declaration",
|
||||
body: "<@all> 请查收",
|
||||
wantErr: "必须同时指定 --at-all",
|
||||
},
|
||||
{
|
||||
name: "bare body member missing from declaration",
|
||||
body: "@" + fixtureCurrentDOpenID + " 请查收",
|
||||
wantErr: "必须同时通过 --at-open-dingtalk-ids 声明",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
args := []string{
|
||||
"chat", "+messages-send", "--identity", "user",
|
||||
"--group", "cid", "--markdown", tt.body, "--yes",
|
||||
}
|
||||
if tt.atIDs != "" {
|
||||
args = append(args, "--at-open-dingtalk-ids", tt.atIDs)
|
||||
}
|
||||
if tt.atAll {
|
||||
args = append(args, "--at-all")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("invalid mention contract reached lower service: %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T) {
|
||||
flags := append([]shortcut.Flag(nil), MessagesSend.Flags...)
|
||||
for i := range flags {
|
||||
|
||||
@@ -585,6 +585,119 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendCurrentUserDirectLocalFileUsesTransportSpecificTargets(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.WriteFile("direct.bin", []byte("direct-file"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
t.Errorf("upload method = %s", r.Method)
|
||||
}
|
||||
if _, err := io.Copy(io.Discard, r.Body); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(uploadServer.Close)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
targetArgs []string
|
||||
requestedType string
|
||||
wantOpenID string
|
||||
wantUserResolve bool
|
||||
}{
|
||||
{
|
||||
name: "explicit open id file",
|
||||
targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID},
|
||||
requestedType: "file",
|
||||
wantOpenID: fixtureCurrentDOpenID,
|
||||
},
|
||||
{
|
||||
name: "resolved user audio",
|
||||
targetArgs: []string{"--user", "user-id"},
|
||||
requestedType: "audio",
|
||||
wantOpenID: "D-resolved",
|
||||
wantUserResolve: true,
|
||||
},
|
||||
{
|
||||
name: "explicit open id video",
|
||||
targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID2},
|
||||
requestedType: "video",
|
||||
wantOpenID: fixtureCurrentDOpenID2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"upload-key"}`,
|
||||
"im/commit_conversation_file_upload": `{"result":{"dentryId":31,"spaceId":41}}`,
|
||||
"chat/send_personal_message": `{"result":{"openMessageId":"sent-direct-file"}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
args := []string{
|
||||
"chat", "+messages-send", "--identity", "user",
|
||||
"--msg-type", tt.requestedType,
|
||||
"--file", "./direct.bin",
|
||||
"--idempotency-key", "direct-" + tt.requestedType,
|
||||
"--yes",
|
||||
}
|
||||
args = append(args, tt.targetArgs...)
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
callOffset := 0
|
||||
if tt.wantUserResolve {
|
||||
if len(fake.calls) == 0 || fake.calls[0].product != "contact" ||
|
||||
fake.calls[0].tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("resolution call = %#v", fake.calls)
|
||||
}
|
||||
callOffset = 1
|
||||
}
|
||||
if len(fake.calls) != callOffset+3 {
|
||||
t.Fatalf("calls = %#v, want resolution plus init, commit, send", fake.calls)
|
||||
}
|
||||
initCall := fake.calls[callOffset]
|
||||
commitCall := fake.calls[callOffset+1]
|
||||
sendCall := fake.calls[callOffset+2]
|
||||
for _, call := range []larkAlignmentCall{initCall, commitCall} {
|
||||
if call.product != "im" || call.args["openDingTalkId"] != tt.wantOpenID {
|
||||
t.Fatalf("upload call = %#v, want openDingTalkId %q", call, tt.wantOpenID)
|
||||
}
|
||||
if _, exists := call.args["receiverOpenDingTalkId"]; exists {
|
||||
t.Fatalf("send-only receiver field leaked into upload call: %#v", call.args)
|
||||
}
|
||||
}
|
||||
if initCall.tool != "init_conversation_file_upload" ||
|
||||
commitCall.tool != "commit_conversation_file_upload" {
|
||||
t.Fatalf("upload calls = %#v, %#v", initCall, commitCall)
|
||||
}
|
||||
if sendCall.product != "chat" || sendCall.tool != "send_personal_message" ||
|
||||
sendCall.args["receiverOpenDingTalkId"] != tt.wantOpenID {
|
||||
t.Fatalf("send call = %#v, want receiverOpenDingTalkId %q", sendCall, tt.wantOpenID)
|
||||
}
|
||||
if _, exists := sendCall.args["openDingTalkId"]; exists {
|
||||
t.Fatalf("upload-only target field leaked into send call: %#v", sendCall.args)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["requestedMessageType"] != tt.requestedType ||
|
||||
payload["effectiveMessageType"] != "file" {
|
||||
t.Fatalf("output = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.WriteFile("fixture.bin", []byte("x"), 0o600); err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
@@ -337,8 +338,15 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te
|
||||
}
|
||||
}
|
||||
if tt.bodyKey == "content" {
|
||||
rawContent := call.args[tt.bodyKey].(string)
|
||||
if strings.Contains(rawContent, `\u003c`) || strings.Contains(rawContent, `\u003e`) {
|
||||
t.Errorf("content = %q; current-user mention tokens must remain literal", rawContent)
|
||||
}
|
||||
if !strings.Contains(rawContent, "<@"+fixtureCurrentDOpenID+">") {
|
||||
t.Errorf("content = %q; missing literal current-user mention token", rawContent)
|
||||
}
|
||||
var content map[string]string
|
||||
if err := json.Unmarshal([]byte(call.args[tt.bodyKey].(string)), &content); err != nil {
|
||||
if err := json.Unmarshal([]byte(rawContent), &content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content["text"] != tt.body {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
@@ -181,6 +183,12 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
|
||||
return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数")
|
||||
}
|
||||
if (group != "" || chatQuery != "") && (contentType == "text" || contentType == "markdown") {
|
||||
if err := validateCurrentUserMentionConsistency(
|
||||
messagesSendBody(rt), atOpenIDs, rt.Bool("at-all")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "bot":
|
||||
if chatQuery != "" || userQuery != "" {
|
||||
return apperrors.NewValidation("--identity bot 当前不接受 --chat-query 或 --user-query;请传真实群 ID 或批量用户 ID")
|
||||
@@ -234,6 +242,89 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCurrentUserMentionConsistency(body string, atOpenIDs []string, atAll bool) error {
|
||||
declared := make(map[string]struct{}, len(atOpenIDs))
|
||||
for _, id := range atOpenIDs {
|
||||
declared[id] = struct{}{}
|
||||
}
|
||||
|
||||
missingPlaceholders := make([]string, 0)
|
||||
for _, id := range atOpenIDs {
|
||||
if !containsCurrentUserMentionToken(body, id) {
|
||||
missingPlaceholders = append(missingPlaceholders, id)
|
||||
}
|
||||
}
|
||||
if len(missingPlaceholders) > 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"--at-open-dingtalk-ids 中的成员必须在正文中使用对应 <@openDingTalkId> 占位符;缺少: %s",
|
||||
strings.Join(missingPlaceholders, ","),
|
||||
))
|
||||
}
|
||||
|
||||
undeclared := make([]string, 0)
|
||||
for _, id := range currentUserMentionBodyIDs(body) {
|
||||
if _, ok := declared[id]; !ok {
|
||||
undeclared = append(undeclared, id)
|
||||
}
|
||||
}
|
||||
if len(undeclared) > 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"正文中的成员 <@openDingTalkId> 占位符必须同时通过 --at-open-dingtalk-ids 声明;未声明: %s",
|
||||
strings.Join(undeclared, ","),
|
||||
))
|
||||
}
|
||||
if !atAll && containsCurrentUserMentionToken(body, "all") {
|
||||
return apperrors.NewValidation("正文中的 <@all> 占位符必须同时指定 --at-all")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsCurrentUserMentionToken(body, id string) bool {
|
||||
placeholder := "@" + id
|
||||
for searchFrom := 0; ; {
|
||||
offset := strings.Index(body[searchFrom:], placeholder)
|
||||
if offset < 0 {
|
||||
return false
|
||||
}
|
||||
end := searchFrom + offset + len(placeholder)
|
||||
if end == len(body) {
|
||||
return true
|
||||
}
|
||||
next, _ := utf8.DecodeRuneInString(body[end:])
|
||||
if !unicode.IsLetter(next) && !unicode.IsDigit(next) && next != '_' && next != '-' {
|
||||
return true
|
||||
}
|
||||
searchFrom = end
|
||||
}
|
||||
}
|
||||
|
||||
func currentUserMentionBodyIDs(body string) []string {
|
||||
ids := make([]string, 0)
|
||||
for searchFrom := 0; ; {
|
||||
offset := strings.IndexByte(body[searchFrom:], '@')
|
||||
if offset < 0 {
|
||||
break
|
||||
}
|
||||
start := searchFrom + offset + 1
|
||||
end := start
|
||||
for end < len(body) {
|
||||
current := body[end]
|
||||
if (current < 'a' || current > 'z') &&
|
||||
(current < 'A' || current > 'Z') &&
|
||||
(current < '0' || current > '9') {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
id := body[start:end]
|
||||
if targetresolver.LooksLikeCurrentDOpenDingTalkID(id) {
|
||||
ids = appendUniqueShortcutString(ids, id)
|
||||
}
|
||||
searchFrom = end
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func executeMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
identity := messagesSendIdentity(rt)
|
||||
body := messagesSendBody(rt)
|
||||
@@ -499,7 +590,7 @@ func resolvedUserMarkdownParams(
|
||||
if target.GroupID != "" {
|
||||
body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true)
|
||||
}
|
||||
content, _ := json.Marshal(map[string]string{"title": title, "text": body})
|
||||
content, _ := jsonutil.Marshal(map[string]string{"title": title, "text": body})
|
||||
params := rt.AddAIMessageTag(map[string]any{
|
||||
"msgType": "markdown",
|
||||
"content": string(content),
|
||||
@@ -573,8 +664,10 @@ func executeMessagesSendUserFile(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetArgs := map[string]any{}
|
||||
addMessagesSendUserTarget(targetArgs, group, openID)
|
||||
uploadTargetArgs := map[string]any{}
|
||||
addMessagesSendUserUploadTarget(uploadTargetArgs, group, openID)
|
||||
sendTargetArgs := map[string]any{}
|
||||
addMessagesSendUserTarget(sendTargetArgs, group, openID)
|
||||
idempotencyKey := messagesSendIdempotencyKey(rt)
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
@@ -587,6 +680,7 @@ func executeMessagesSendUserFile(
|
||||
{
|
||||
"identity": "user",
|
||||
"tool": "init/commit_conversation_file_upload",
|
||||
"target": uploadTargetArgs,
|
||||
"file": map[string]any{
|
||||
"path": rawPath,
|
||||
"name": meta.FileName,
|
||||
@@ -598,7 +692,7 @@ func executeMessagesSendUserFile(
|
||||
"tool": "send_personal_message",
|
||||
"requestedMessageType": requestedType,
|
||||
"effectiveMessageType": "file",
|
||||
"target": targetArgs,
|
||||
"target": sendTargetArgs,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -607,7 +701,7 @@ func executeMessagesSendUserFile(
|
||||
rt.Command().Context(), messagesSendFileUploadTimeout)
|
||||
defer cancelUpload()
|
||||
commitText, err := helpers.UploadConversationLocalFile(
|
||||
uploadContext, targetArgs, meta, idempotencyKey)
|
||||
uploadContext, uploadTargetArgs, meta, idempotencyKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -653,6 +747,14 @@ func addMessagesSendUserTarget(params map[string]any, group, openID string) {
|
||||
params["receiverOpenDingTalkId"] = openID
|
||||
}
|
||||
|
||||
func addMessagesSendUserUploadTarget(params map[string]any, group, openID string) {
|
||||
if group != "" {
|
||||
params["openConversationId"] = group
|
||||
return
|
||||
}
|
||||
params["openDingTalkId"] = openID
|
||||
}
|
||||
|
||||
func nonEmptyStringCount(values ...string) int {
|
||||
count := 0
|
||||
for _, value := range values {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dws
|
||||
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/终止/完成,以及审批任务创建/完成/转交时使用。
|
||||
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/抄送/终止/完成,以及审批任务创建/完成/转交时使用。
|
||||
cli_version: ">=1.0.15"
|
||||
---
|
||||
|
||||
|
||||
@@ -47,10 +47,11 @@
|
||||
| `user_oa_approval_task_finished` | 审批任务已完成 | 无 |
|
||||
| `user_oa_approval_task_redirected` | 审批任务已转交 | 无 |
|
||||
| `user_oa_approval_instance_started` | 审批实例已发起 | 无 |
|
||||
| `user_oa_approval_instance_cc` | 审批实例到达抄送节点,发送给被抄送人 | 无 |
|
||||
| `user_oa_approval_instance_terminated` | 审批实例已终止 | 无 |
|
||||
| `user_oa_approval_instance_finished` | 审批实例完成,发送给审批单发起人 | 无 |
|
||||
|
||||
只承认上表 22 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。六个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。
|
||||
只承认上表 23 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。七个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。
|
||||
|
||||
## Intent mapping
|
||||
|
||||
@@ -76,9 +77,10 @@
|
||||
| "审批任务完成时通知我" | `event consume`,事件码 `user_oa_approval_task_finished`,参数 `--flatten -f ndjson` |
|
||||
| "审批任务被转交时通知我" | `event consume`,事件码 `user_oa_approval_task_redirected`,参数 `--flatten -f ndjson` |
|
||||
| "有审批单发起时通知我" | `event consume`,事件码 `user_oa_approval_instance_started`,参数 `--flatten -f ndjson` |
|
||||
| "有审批抄送给我时通知我" | `event consume`,事件码 `user_oa_approval_instance_cc`,参数 `--flatten -f ndjson` |
|
||||
| "有审批单终止时通知我" | `event consume`,事件码 `user_oa_approval_instance_terminated`,参数 `--flatten -f ndjson` |
|
||||
| "监听我发起的审批何时完成" / "审批实例完成时通知我" | `event consume`,事件码 `user_oa_approval_instance_finished`,参数 `--flatten -f ndjson` |
|
||||
| "同时监听全部已公开 OA 事件" | 一个 consume 放入六个 OA event key,不加目标或消息过滤参数 |
|
||||
| "同时监听全部已公开 OA 事件" | 一个 consume 放入七个 OA event key,不加目标或消息过滤参数 |
|
||||
| "查看个人事件 schema" | `dws event schema <event_key> --flatten` |
|
||||
| "看个人事件订阅状态" | `dws event status --event <event_key>` |
|
||||
| "停止这个个人事件订阅" | `dws event stop <subscribe_id> --dry-run`,确认后改用 `--yes` |
|
||||
@@ -130,6 +132,7 @@ dws event schema user_oa_approval_task_created --flatten
|
||||
dws event schema user_oa_approval_task_finished --flatten
|
||||
dws event schema user_oa_approval_task_redirected --flatten
|
||||
dws event schema user_oa_approval_instance_started --flatten
|
||||
dws event schema user_oa_approval_instance_cc --flatten
|
||||
dws event schema user_oa_approval_instance_terminated --flatten
|
||||
dws event schema user_oa_approval_instance_finished --flatten
|
||||
```
|
||||
@@ -157,6 +160,7 @@ dws event consume user_oa_approval_task_created --flatten -f ndjson
|
||||
dws event consume user_oa_approval_task_finished --flatten -f ndjson
|
||||
dws event consume user_oa_approval_task_redirected --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_started --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_cc --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_terminated --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_finished --flatten -f ndjson
|
||||
```
|
||||
@@ -185,13 +189,14 @@ dws event consume \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
```
|
||||
|
||||
用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。六个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。
|
||||
用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。七个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。
|
||||
|
||||
上述所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user <userId>` 替换为 `--open-dingtalk-id <openDingtalkId>`,但两个参数不能同时使用。
|
||||
|
||||
@@ -216,7 +221,7 @@ dws event stop --all --yes
|
||||
|
||||
## 订阅创建失败与重试预算
|
||||
|
||||
以下约束适用于上表全部 22 个公开个人事件(16 个 IM + 6 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
|
||||
以下约束适用于上表全部 23 个公开个人事件(16 个 IM + 7 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
|
||||
|
||||
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
|
||||
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-event
|
||||
description: 钉钉个人 IM 与 OA 审批事件长连接监听。Use when 用户说监听消息/@我/某人/某群/全部消息、已读/撤回/reaction、群成员加入/群成员退出/群状态变化,或监听审批任务创建/完成/转交、审批实例发起/终止/完成。命令前缀:dws event。
|
||||
description: 钉钉个人 IM 与 OA 审批事件长连接监听。Use when 用户说监听消息/@我/某人/某群/全部消息、已读/撤回/reaction、群成员加入/群成员退出/群状态变化,或监听审批任务创建/完成/转交、审批实例发起/抄送/终止/完成。命令前缀:dws event。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
@@ -45,7 +45,7 @@ metadata:
|
||||
- `group` 必须且只能传 `--chat-id` 或 `--chat-query` 之一。
|
||||
- `--query` 只用于纯 `message` 监听;混入 reaction/read/recall 时不得使用。
|
||||
|
||||
OA 事件不进入 `+listen-im`。六个公开 OA EventKey 都订阅当前 OAuth 用户相关的全部审批事件,使用 `ruleType=all`、`filterRule={}`;不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。六项可放入同一个 consume,每项建立独立订阅并共享 bus。
|
||||
OA 事件不进入 `+listen-im`。七个公开 OA EventKey 都订阅当前 OAuth 用户相关的全部审批事件,使用 `ruleType=all`、`filterRule={}`;不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。七项可放入同一个 consume,每项建立独立订阅并共享 bus。
|
||||
|
||||
自然姓名和群名由 CLI 内部唯一解析:零命中或多候选返回结构化失败,在创建任何订阅前停止。`--dry-run` 走同一解析链。解析、监听、状态和停止必须使用同一个 `--profile`,不得跨组织搬运 ID。
|
||||
|
||||
@@ -65,7 +65,7 @@ user_im_group_member_added user_im_group_member_exited
|
||||
user_im_group_disbanded
|
||||
```
|
||||
|
||||
六个 OA EventKey 及其输出字段见 [OA 事件参考](references/event-oa.md)。
|
||||
七个 OA EventKey 及其输出字段见 [OA 事件参考](references/event-oa.md)。
|
||||
|
||||
用户类事件传 `--user` 或 `--open-dingtalk-id`,群类事件传 `--group`。群生命周期输出可含 `operator_open_dingtalk_id` 和 `members`;成员项使用 `open_dingtalk_id`。精确组合、兼容性和 Filter 规则见 reference。
|
||||
|
||||
@@ -98,7 +98,7 @@ kind + events + target
|
||||
|
||||
- `event stop` 会取消订阅并影响本地 consumer:先 `--dry-run`,用户确认后再加 `--yes`。
|
||||
- 多事件属于一次原始操作;任一订阅启动失败时 Runtime 回滚本次已创建项,不拆成新命令绕过重试预算。
|
||||
- 这套 `0/2/1` 是 **Agent/host** 编排预算,适用于全部 22 个公开个人 EventKey(16 个 IM + 6 个 OA):`retryable=false` 对应 `max_additional_attempts=0`;`retryable=true` 对应 `max_additional_attempts=2`;`retryable=unknown` 对应 `max_additional_attempts=1`。它不是 CLI 持久化硬总次数上限;每次调用最多创建一次,进程内不会自动重试,CLI 也不持久化或计算跨调用的 Agent/host 尝试次数。
|
||||
- 这套 `0/2/1` 是 **Agent/host** 编排预算,适用于全部 23 个公开个人 EventKey(16 个 IM + 7 个 OA):`retryable=false` 对应 `max_additional_attempts=0`;`retryable=true` 对应 `max_additional_attempts=2`;`retryable=unknown` 对应 `max_additional_attempts=1`。它不是 CLI 持久化硬总次数上限;每次调用最多创建一次,进程内不会自动重试,CLI 也不持久化或计算跨调用的 Agent/host 尝试次数。
|
||||
- 重试必须遵守 `retry_after_seconds` / `next_retry_at`。遇到 `in_flight`、`cooldown`、`terminal_hold` 不并发或递归重启同一逻辑订阅,也不换 `subscribe_id` / `trace_id` 绕过保护。
|
||||
- 认证、profile、订阅保护状态和 bus 排障按失败类型读取 [订阅运维](references/event-im-operations.md),不要在正常路径预加载完整运维手册。
|
||||
|
||||
@@ -125,4 +125,4 @@ kind + events + target
|
||||
| ready、bounded consume 与退出清理 | [event-im-lifecycle.md](references/event-im-lifecycle.md) | 启动/托管/关闭 consumer |
|
||||
| 扁平字段与事件到 Chat 交接 | [event-im-output.md](references/event-im-output.md) | 解析事件或自动回复 |
|
||||
| Filter、status/stop、重试与排障 | [event-im-operations.md](references/event-im-operations.md) | 订阅控制或失败恢复 |
|
||||
| OA 审批事件 | [event-oa.md](references/event-oa.md) | 选择六个 OA EventKey、组合消费或解析审批字段 |
|
||||
| OA 审批事件 | [event-oa.md](references/event-oa.md) | 选择七个 OA EventKey、组合消费或解析审批字段 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OA 个人审批事件
|
||||
|
||||
先读事件产品入口 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的六个 OA 个人事件:审批实例发起、终止和完成,以及审批任务创建、完成和转交。
|
||||
先读事件产品入口 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的七个 OA 个人事件:审批实例发起、抄送、终止和完成,以及审批任务创建、完成和转交。
|
||||
|
||||
<!-- dws-intent: event.listen.oa -->实时监听审批事件必须使用 `dws event consume` 长连接,不要轮询 OA 待办或审批实例列表来模拟事件。
|
||||
|
||||
@@ -22,10 +22,11 @@ dws auth login
|
||||
| `user_oa_approval_task_finished` | `all` | 审批任务已完成 | 无 |
|
||||
| `user_oa_approval_task_redirected` | `all` | 审批任务已转交 | 无 |
|
||||
| `user_oa_approval_instance_started` | `all` | 审批实例已发起 | 无 |
|
||||
| `user_oa_approval_instance_cc` | `all` | 审批实例到达抄送节点,发送给被抄送人 | 无 |
|
||||
| `user_oa_approval_instance_terminated` | `all` | 审批实例已终止 | 无 |
|
||||
| `user_oa_approval_instance_finished` | `all` | 审批实例完成,发送给审批单发起人 | 无 |
|
||||
|
||||
只承认上表 6 个 OA 事件码。CLI 为每个事件发送 `ruleType=all`、`filterRule={}` 的独立订阅请求;不要添加 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。
|
||||
只承认上表 7 个 OA 事件码。CLI 为每个事件发送 `ruleType=all`、`filterRule={}` 的独立订阅请求;不要添加 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。
|
||||
|
||||
## Intent mapping
|
||||
|
||||
@@ -35,13 +36,14 @@ dws auth login
|
||||
| “审批任务完成时通知我” | `dws event consume user_oa_approval_task_finished --flatten -f ndjson` |
|
||||
| “审批任务被转交时通知我” | `dws event consume user_oa_approval_task_redirected --flatten -f ndjson` |
|
||||
| “有审批单发起时通知我” | `dws event consume user_oa_approval_instance_started --flatten -f ndjson` |
|
||||
| “有审批抄送给我时通知我” | `dws event consume user_oa_approval_instance_cc --flatten -f ndjson` |
|
||||
| “有审批单终止时通知我” | `dws event consume user_oa_approval_instance_terminated --flatten -f ndjson` |
|
||||
| “监听我发起的审批何时完成” / “审批实例完成时通知我” | `dws event consume user_oa_approval_instance_finished --flatten -f ndjson` |
|
||||
| “同时监听全部已公开 OA 事件” | 一个 consume 放入六个 OA event key,不加目标或过滤参数 |
|
||||
| “同时监听全部已公开 OA 事件” | 一个 consume 放入七个 OA event key,不加目标或过滤参数 |
|
||||
| “查看 OA 事件目录” | `dws event list --category oa` |
|
||||
| “查看 OA 事件输出字段” | 对对应事件运行 `dws event schema <event_key> --flatten` |
|
||||
|
||||
三个审批任务事件分别表达任务已创建、已完成和已转交;三个审批实例事件分别表达实例已发起、已终止和已完成。扁平字段来自六类事件的预发联调样本;`status` 和 `result` 保留服务端原值,不把当前样本值推断为完整枚举。
|
||||
三个审批任务事件分别表达任务已创建、已完成和已转交;四个审批实例事件分别表达实例已发起、到达抄送节点、已终止和已完成。`status` 和 `result` 保留服务端原值,不把当前样本值推断为完整枚举。
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -52,6 +54,7 @@ dws event schema user_oa_approval_task_created --flatten
|
||||
dws event schema user_oa_approval_task_finished --flatten
|
||||
dws event schema user_oa_approval_task_redirected --flatten
|
||||
dws event schema user_oa_approval_instance_started --flatten
|
||||
dws event schema user_oa_approval_instance_cc --flatten
|
||||
dws event schema user_oa_approval_instance_terminated --flatten
|
||||
dws event schema user_oa_approval_instance_finished --flatten
|
||||
```
|
||||
@@ -63,11 +66,12 @@ dws event consume user_oa_approval_task_created --flatten -f ndjson
|
||||
dws event consume user_oa_approval_task_finished --flatten -f ndjson
|
||||
dws event consume user_oa_approval_task_redirected --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_started --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_cc --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_terminated --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_finished --flatten -f ndjson
|
||||
```
|
||||
|
||||
同时监听六种事件:
|
||||
同时监听七种事件:
|
||||
|
||||
```bash
|
||||
dws event consume \
|
||||
@@ -75,13 +79,14 @@ dws event consume \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
```
|
||||
|
||||
多事件 consume 会为六个 event key 分别创建订阅和逻辑 consumer,并共享当前组织的 personal bus、远程连接、stdout 和生命周期。不要给 OA 命令加 `--query` 或 `--filter-json`;这两个 flag 只用于兼容的 IM 消息接收事件。
|
||||
多事件 consume 会为七个 event key 分别创建订阅和逻辑 consumer,并共享当前组织的 personal bus、远程连接、stdout 和生命周期。不要给 OA 命令加 `--query` 或 `--filter-json`;这两个 flag 只用于兼容的 IM 消息接收事件。
|
||||
|
||||
## Output contract
|
||||
|
||||
@@ -105,7 +110,7 @@ dws event consume \
|
||||
- `type` 是当前 event key;`event_id` 可用于去重;`timestamp` 是 transport 事件发生时间;`subscribe_id` 标识对应的独立订阅。
|
||||
- `process_instance_id` 是审批实例 ID,可传给 OA 审批命令的 `--instance-id`;`process_code` 是审批流程模板编码。
|
||||
- `create_time`、`finish_time` 和 `event_time` 都是毫秒时间戳。`event_time` 是审批业务事件时间,`timestamp` 是 transport 事件时间。
|
||||
- 六类事件的额外字段如下;具体事件始终以 `dws event schema <event_key> --flatten` 为准。
|
||||
- 七类事件的额外字段如下;具体事件始终以 `dws event schema <event_key> --flatten` 为准。
|
||||
|
||||
| 事件 | 额外顶层字段 |
|
||||
|---|---|
|
||||
@@ -113,6 +118,7 @@ dws event consume \
|
||||
| `user_oa_approval_task_finished` | `task_id`、`result`、`finish_time` |
|
||||
| `user_oa_approval_task_redirected` | `task_id`、`result`、`finish_time` |
|
||||
| `user_oa_approval_instance_started` | 无 |
|
||||
| `user_oa_approval_instance_cc` | 无 |
|
||||
| `user_oa_approval_instance_terminated` | `finish_time` |
|
||||
| `user_oa_approval_instance_finished` | `result`、`finish_time` |
|
||||
|
||||
@@ -144,6 +150,6 @@ dws event consume \
|
||||
## Lifecycle
|
||||
|
||||
- 单事件等待 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>`。
|
||||
- 六事件先保存六条 `[event] subscription event_key=<key> subscribe_id=<id>`,再等待 `[event] ready event_count=6 bus_pid=<pid>`。
|
||||
- 七事件先保存七条 `[event] subscription event_key=<key> subscribe_id=<id>`,再等待 `[event] ready event_count=7 bus_pid=<pid>`。
|
||||
- 临时验证使用 `--max-events 1` 或 `--duration 10m`;任务完成后优雅结束 consume,本次新建的订阅会自动取消。
|
||||
- 外部停止已有订阅时先运行 `dws event stop <subscribe_id> --dry-run`,确认后再加 `--yes`。不要 `kill -9`,否则会跳过自动退订。
|
||||
|
||||
@@ -1190,6 +1190,10 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
- Prompt: 有和我相关的审批实例发起时实时通知我
|
||||
- Expected: `dws event consume user_oa_approval_instance_started --flatten -f ndjson`
|
||||
|
||||
**event_event_consume_oa_instance_cc_001**
|
||||
- Prompt: 有审批实例抄送给我时实时通知我
|
||||
- Expected: `dws event consume user_oa_approval_instance_cc --flatten -f ndjson`
|
||||
|
||||
**event_event_consume_oa_instance_terminated_001**
|
||||
- Prompt: 和我相关的审批实例终止时实时通知我
|
||||
- Expected: `dws event consume user_oa_approval_instance_terminated --flatten -f ndjson`
|
||||
|
||||
@@ -280,7 +280,7 @@ func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) {
|
||||
"群成员加入",
|
||||
"群成员退出",
|
||||
"审批任务创建/完成/转交",
|
||||
"审批实例发起/终止/完成",
|
||||
"审批实例发起/抄送/终止/完成",
|
||||
} {
|
||||
if !strings.Contains(frontmatter, required) {
|
||||
t.Errorf("%s frontmatter missing event discovery trigger %q", path, required)
|
||||
@@ -306,7 +306,7 @@ func TestStandaloneEventSkillOwnsAllPersonalEventContracts(t *testing.T) {
|
||||
"<!-- dws-intent: event.listen.im -->",
|
||||
"<!-- dws-intent: event.listen.oa -->",
|
||||
"16 个 EventKey",
|
||||
"22 个公开个人 EventKey",
|
||||
"23 个公开个人 EventKey",
|
||||
} {
|
||||
if !strings.Contains(string(skillContent), required) {
|
||||
t.Errorf("%s missing standalone event contract %q", skillPath, required)
|
||||
@@ -360,6 +360,7 @@ func TestStandaloneEventSkillOwnsAllPersonalEventContracts(t *testing.T) {
|
||||
"user_oa_approval_task_finished",
|
||||
"user_oa_approval_task_redirected",
|
||||
"user_oa_approval_instance_started",
|
||||
"user_oa_approval_instance_cc",
|
||||
"user_oa_approval_instance_terminated",
|
||||
"user_oa_approval_instance_finished",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user