From 8965fd2707fce13d3e635cea40a8be3642066570 Mon Sep 17 00:00:00 2001 From: wxianfeng Date: Mon, 13 Jul 2026 11:19:27 +0800 Subject: [PATCH] feat(event): add read recall and emotion events --- internal/app/event_personal_output_test.go | 55 +++++++ internal/event/personal/client_test.go | 59 ++++++++ internal/event/personal/registry.go | 142 ++++++++++++++++-- internal/event/personal/registry_test.go | 113 +++++++++++++- internal/event/source/personal_test.go | 38 +++++ skills/mono/SKILL.md | 4 +- skills/mono/references/products/event.md | 36 ++++- skills/multi/dingtalk-event/SKILL.md | 32 +++- .../dingtalk-event/references/event-im.md | 65 +++++++- test/skill_tests.md | 44 +++++- 10 files changed, 558 insertions(+), 30 deletions(-) diff --git a/internal/app/event_personal_output_test.go b/internal/app/event_personal_output_test.go index fc5409b5..188bf1fa 100644 --- a/internal/app/event_personal_output_test.go +++ b/internal/app/event_personal_output_test.go @@ -46,6 +46,18 @@ func TestPersonalEventListHidesSchemaIDs(t *testing.T) { if strings.Contains(got, personal.EventFromUser) { t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got) } + for _, eventKey := range []string{ + personal.EventReadO2O, + personal.EventReadGroup, + personal.EventRecallO2O, + personal.EventRecallGroup, + personal.EventEmotionO2O, + personal.EventEmotionGroup, + } { + if !strings.Contains(got, eventKey) { + t.Fatalf("list output missing %s: %s", eventKey, got) + } + } }) } } @@ -264,6 +276,49 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) { } } +func TestPersonalActionEventSchemaUsesConservativeJSONSchema(t *testing.T) { + for _, eventKey := range []string{ + personal.EventReadO2O, + personal.EventReadGroup, + personal.EventRecallO2O, + personal.EventRecallGroup, + personal.EventEmotionO2O, + personal.EventEmotionGroup, + } { + t.Run(eventKey, func(t *testing.T) { + cmd := newEventSchemaCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{eventKey}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(out.Bytes(), &doc); err != nil { + t.Fatalf("schema output is not JSON: %v\n%s", err, out.String()) + } + if doc["event_key"] != eventKey || doc["jq_root_path"] != ".data | fromjson" { + t.Fatalf("schema metadata = %#v", doc) + } + schema, ok := doc["schema"].(map[string]any) + if !ok { + t.Fatalf("schema = %#v", doc["schema"]) + } + properties, ok := schema["properties"].(map[string]any) + if !ok || len(properties) != 5 { + t.Fatalf("schema.properties = %#v, want five conservative fields", schema["properties"]) + } + for _, field := range []string{"type", "event_id", "timestamp", "subscribe_id", "payload"} { + if _, ok := properties[field]; !ok { + t.Fatalf("schema missing %q: %#v", field, properties) + } + } + }) + } +} + func TestEventSchemaDefaultsToUser(t *testing.T) { cmd := newEventSchemaCommand() cmd.SilenceUsage = true diff --git a/internal/event/personal/client_test.go b/internal/event/personal/client_test.go index 1bb71d90..9f7fa4f5 100644 --- a/internal/event/personal/client_test.go +++ b/internal/event/personal/client_test.go @@ -21,6 +21,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "reflect" "strconv" "strings" "testing" @@ -107,6 +108,64 @@ func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) { } } +func TestClientCreateActionSubscriptionsUsesDocumentedRuleParam(t *testing.T) { + tests := []struct { + eventKey string + opts RuleOptions + wantRule map[string]any + }{ + {EventReadO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}}, + {EventRecallO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}}, + {EventEmotionO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}}, + {EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}}, + {EventRecallGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}}, + {EventEmotionGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}}, + } + for _, tt := range tests { + t.Run(tt.eventKey, func(t *testing.T) { + var gotPath string + var gotReq dwsCreateSubscriptionRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { + t.Errorf("decode request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"sub-action"}}) + })) + defer srv.Close() + + ruleType, ruleParam, err := BuildRuleParam(tt.eventKey, tt.opts) + if err != nil { + t.Fatal(err) + } + client := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}) + if _, err := client.CreateSubscription(t.Context(), CreateSubscriptionRequest{ + EventKey: tt.eventKey, + RuleType: ruleType, + RuleParam: ruleParam, + }); err != nil { + t.Fatal(err) + } + + if gotPath != "/subscription/user" || gotReq.EventKey != tt.eventKey { + t.Fatalf("path = %q, eventKey = %q", gotPath, gotReq.EventKey) + } + var gotRule map[string]any + if err := json.Unmarshal([]byte(gotReq.FilterRule), &gotRule); err != nil { + t.Fatalf("filterRule = %q: %v", gotReq.FilterRule, err) + } + if !reflect.DeepEqual(gotRule, tt.wantRule) { + t.Fatalf("filterRule = %#v, want %#v", gotRule, tt.wantRule) + } + if gotReq.Ext["ruleType"] != ruleType { + t.Fatalf("ext.ruleType = %#v, want %q", gotReq.Ext["ruleType"], ruleType) + } + }) + } +} + func TestClientCreateSubscriptionObjectResponses(t *testing.T) { cases := []map[string]any{ {"subId": "sub-camel", "eventKey": EventMention, "sourceId": "open", "status": 1}, diff --git a/internal/event/personal/registry.go b/internal/event/personal/registry.go index fa2e1488..eb440375 100644 --- a/internal/event/personal/registry.go +++ b/internal/event/personal/registry.go @@ -23,10 +23,16 @@ import ( ) const ( - EventMention = "user_im_message_receive_at" - EventSingleChat = "user_im_message_receive_o2o" - EventInChat = "user_im_message_receive_group" - EventFromUser = "user_im_message_receive_user" + EventMention = "user_im_message_receive_at" + EventSingleChat = "user_im_message_receive_o2o" + EventInChat = "user_im_message_receive_group" + EventFromUser = "user_im_message_receive_user" + EventReadO2O = "user_im_message_read_o2o" + EventReadGroup = "user_im_message_read_group" + EventRecallO2O = "user_im_message_recall_o2o" + EventRecallGroup = "user_im_message_recall_group" + EventEmotionO2O = "user_im_message_emotion_o2o" + EventEmotionGroup = "user_im_message_emotion_group" ) const ( @@ -105,6 +111,72 @@ var definitions = []Definition{ Auth: map[string]any{"identity": "user"}, Public: true, }, + { + EventKey: EventReadO2O, + DisplayName: "指定单聊消息已读", + Description: "当前用户在指定单聊中发送的消息被对方已读", + Category: "im", + RuleType: "singleChat", + Status: StatusEnabled, + RequiredParams: []string{"user"}, + Auth: map[string]any{"identity": "user"}, + Public: true, + }, + { + EventKey: EventReadGroup, + DisplayName: "指定群消息已读", + Description: "当前用户在指定群聊中发送的消息被已读", + Category: "im", + RuleType: "group", + Status: StatusEnabled, + RequiredParams: []string{"group"}, + Auth: map[string]any{"identity": "user"}, + Public: true, + }, + { + EventKey: EventRecallO2O, + DisplayName: "指定单聊消息撤回", + Description: "指定单聊中的消息被撤回", + Category: "im", + RuleType: "singleChat", + Status: StatusEnabled, + RequiredParams: []string{"user"}, + Auth: map[string]any{"identity": "user"}, + Public: true, + }, + { + EventKey: EventRecallGroup, + DisplayName: "指定群消息撤回", + Description: "指定群聊中的消息被撤回", + Category: "im", + RuleType: "group", + Status: StatusEnabled, + RequiredParams: []string{"group"}, + Auth: map[string]any{"identity": "user"}, + Public: true, + }, + { + EventKey: EventEmotionO2O, + DisplayName: "指定单聊消息表情回应", + Description: "指定单聊中的消息收到表情回应(贴表情)", + Category: "im", + RuleType: "singleChat", + Status: StatusEnabled, + RequiredParams: []string{"user"}, + Auth: map[string]any{"identity": "user"}, + Public: true, + }, + { + EventKey: EventEmotionGroup, + DisplayName: "指定群消息表情回应", + Description: "指定群聊中的消息收到表情回应(贴表情)", + Category: "im", + RuleType: "group", + Status: StatusEnabled, + RequiredParams: []string{"group"}, + Auth: map[string]any{"identity": "user"}, + Public: true, + }, { EventKey: EventFromUser, DisplayName: "指定发送人消息", @@ -165,6 +237,10 @@ func Catalog(category string, enabledOnly, includePending bool) []Definition { func BuildSchemaDocument(def Definition) SchemaDocument { requiredParams := make([]string, 0, len(def.RequiredParams)) requiredParams = append(requiredParams, def.RequiredParams...) + schema := personalActionSchema(def.EventKey) + if isMessageReceiveEvent(def.EventKey) { + schema = personalMessageSchema(def.EventKey) + } return SchemaDocument{ EventKey: def.EventKey, DisplayName: def.DisplayName, @@ -173,7 +249,7 @@ func BuildSchemaDocument(def Definition) SchemaDocument { RuleType: def.RuleType, RequiredParams: requiredParams, JQRootPath: ".data | fromjson", - Schema: personalMessageSchema(def.EventKey), + Schema: schema, } } @@ -193,18 +269,18 @@ func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, rulePar switch def.RuleType { case "at": if userID != "" { - return "", nil, fmt.Errorf("--user is only supported for %s", EventSingleChat) + return "", nil, fmt.Errorf("--user is not supported for %s", eventKey) } if groupID != "" { - return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat) + return "", nil, fmt.Errorf("--group is not supported for %s", eventKey) } return def.RuleType, map[string]any{}, nil case "singleChat": if groupID != "" { - return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat) + return "", nil, fmt.Errorf("--group is not supported for %s; use --user", eventKey) } if userID == "" { - return "", nil, fmt.Errorf("--user is required") + return "", nil, fmt.Errorf("--user is required for %s", eventKey) } return def.RuleType, map[string]any{ "targetUid": userID, @@ -212,10 +288,10 @@ func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, rulePar }, nil case "sender": if groupID != "" { - return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat) + return "", nil, fmt.Errorf("--group is not supported for %s; use --user", eventKey) } if userID == "" { - return "", nil, fmt.Errorf("--user is required") + return "", nil, fmt.Errorf("--user is required for %s", eventKey) } return def.RuleType, map[string]any{ "targetUid": userID, @@ -223,10 +299,10 @@ func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, rulePar }, nil case "group": if userID != "" { - return "", nil, fmt.Errorf("--user is only supported for %s", EventSingleChat) + return "", nil, fmt.Errorf("--user is not supported for %s; use --group", eventKey) } if groupID == "" { - return "", nil, fmt.Errorf("--group is required") + return "", nil, fmt.Errorf("--group is required for %s", eventKey) } return def.RuleType, map[string]any{ "openConversationId": groupID, @@ -395,6 +471,46 @@ func personalMessageSchema(eventKey string) map[string]any { } } +func personalActionSchema(eventKey string) map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "type": map[string]any{ + "type": "string", + "description": "事件类型,对应 eventKey,固定为当前 event_key", + "enum": []string{eventKey}, + }, + "event_id": map[string]any{ + "type": "string", + "description": "事件 ID,对应 eventId,可用于去重", + }, + "timestamp": map[string]any{ + "type": "integer", + "description": "事件发生时间戳,对应 occurredAtMs", + "format": "timestamp_ms", + }, + "subscribe_id": map[string]any{ + "type": "string", + "description": "订阅 ID,对应 subId", + }, + "payload": map[string]any{ + "type": "object", + "description": "事件业务负载;具体字段等待真实推送样本确认", + "additionalProperties": true, + }, + }, + } +} + +func isMessageReceiveEvent(eventKey string) bool { + switch eventKey { + case EventMention, EventSingleChat, EventInChat, EventFromUser: + return true + default: + return false + } +} + func IsSchemaPending(err error) bool { var pending *SchemaPendingError return errors.As(err, &pending) diff --git a/internal/event/personal/registry_test.go b/internal/event/personal/registry_test.go index 1cb64b88..c1e40bdc 100644 --- a/internal/event/personal/registry_test.go +++ b/internal/event/personal/registry_test.go @@ -33,6 +33,12 @@ func TestCatalogEnabledEvents(t *testing.T) { EventMention, EventSingleChat, EventInChat, + EventReadO2O, + EventReadGroup, + EventRecallO2O, + EventRecallGroup, + EventEmotionO2O, + EventEmotionGroup, } if !reflect.DeepEqual(keys, want) { t.Fatalf("keys = %#v, want %#v", keys, want) @@ -54,6 +60,12 @@ func TestLegacyEventKeysAreUnknown(t *testing.T) { "im_message_receive_o2o", "im_message_receive_group", "im_message_receive_user", + "im_message_read_o2o", + "im_message_read_group", + "im_message_recall_o2o", + "im_message_recall_group", + "im_message_emotion_o2o", + "im_message_emotion_group", } for _, key := range legacyKeys { if _, ok := Lookup(key); ok { @@ -157,6 +169,52 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) { } } +func TestActionSchemaDocumentsAreConservative(t *testing.T) { + wantProperties := map[string]bool{ + "type": true, + "event_id": true, + "timestamp": true, + "subscribe_id": true, + "payload": true, + } + for _, eventKey := range actionEventKeysForTest() { + t.Run(eventKey, func(t *testing.T) { + def, ok := Lookup(eventKey) + if !ok { + t.Fatalf("Lookup(%q) failed", eventKey) + } + doc := BuildSchemaDocument(def) + if doc.JQRootPath != ".data | fromjson" { + t.Fatalf("jq_root_path = %q", doc.JQRootPath) + } + props, ok := doc.Schema["properties"].(map[string]any) + if !ok { + t.Fatalf("schema.properties = %#v", doc.Schema["properties"]) + } + if len(props) != len(wantProperties) { + t.Fatalf("schema properties = %#v, want only conservative fields", props) + } + for name := range wantProperties { + if _, ok := props[name]; !ok { + t.Fatalf("schema missing property %q: %#v", name, props) + } + } + payloadSchema, ok := props["payload"].(map[string]any) + if !ok || payloadSchema["additionalProperties"] != true { + t.Fatalf("payload schema = %#v, want open object", props["payload"]) + } + for _, unconfirmed := range []string{ + "message_id", "conversation_id", "sender", "content", + "reader", "recaller", "emotion", "reaction", + } { + if _, ok := props[unconfirmed]; ok { + t.Fatalf("schema exposed unconfirmed property %q", unconfirmed) + } + } + }) + } +} + func TestBuildRuleParamMention(t *testing.T) { rule, param, err := BuildRuleParam(EventMention, RuleOptions{}) if err != nil { @@ -226,18 +284,67 @@ func TestBuildRuleParamGroup(t *testing.T) { } } +func TestBuildRuleParamActionEvents(t *testing.T) { + for _, eventKey := range []string{EventReadO2O, EventRecallO2O, EventEmotionO2O} { + t.Run(eventKey, func(t *testing.T) { + if _, _, err := BuildRuleParam(eventKey, RuleOptions{}); err == nil || !strings.Contains(err.Error(), "--user is required for "+eventKey) { + t.Fatalf("missing user error = %v", err) + } + if _, _, err := BuildRuleParam(eventKey, RuleOptions{GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is not supported for "+eventKey) { + t.Fatalf("wrong group error = %v", err) + } + rule, param, err := BuildRuleParam(eventKey, RuleOptions{UserID: "staff-1"}) + if err != nil { + t.Fatal(err) + } + if rule != "singleChat" || param["targetUid"] != "staff-1" || param["targetUidType"] != "staffId" { + t.Fatalf("rule = %q, param = %#v", rule, param) + } + }) + } + + for _, eventKey := range []string{EventReadGroup, EventRecallGroup, EventEmotionGroup} { + t.Run(eventKey, func(t *testing.T) { + if _, _, err := BuildRuleParam(eventKey, RuleOptions{}); err == nil || !strings.Contains(err.Error(), "--group is required for "+eventKey) { + t.Fatalf("missing group error = %v", err) + } + if _, _, err := BuildRuleParam(eventKey, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+eventKey) { + t.Fatalf("wrong user error = %v", err) + } + rule, param, err := BuildRuleParam(eventKey, RuleOptions{GroupID: "cid-1"}) + if err != nil { + t.Fatal(err) + } + if rule != "group" || param["openConversationId"] != "cid-1" { + t.Fatalf("rule = %q, param = %#v", rule, param) + } + }) + } +} + func TestBuildRuleParamRejectsWrongScopedFlags(t *testing.T) { - if _, _, err := BuildRuleParam(EventMention, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is only supported") { + if _, _, err := BuildRuleParam(EventMention, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+EventMention) { t.Fatalf("mention with user error = %v, want unsupported user", err) } - if _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is only supported") { + if _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is not supported for "+EventSingleChat) { t.Fatalf("singleChat with group error = %v, want unsupported group", err) } - if _, _, err := BuildRuleParam(EventInChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--user is only supported") { + if _, _, err := BuildRuleParam(EventInChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+EventInChat) { t.Fatalf("group with user error = %v, want unsupported user", err) } } +func actionEventKeysForTest() []string { + return []string{ + EventReadO2O, + EventReadGroup, + EventRecallO2O, + EventRecallGroup, + EventEmotionO2O, + EventEmotionGroup, + } +} + func TestBuildFilterQueryAndJSON(t *testing.T) { filter, canonical, err := BuildFilter(`{"field":"conversation_id","op":"eq","value":"cid1"}`, "P0, 故障") if err != nil { diff --git a/internal/event/source/personal_test.go b/internal/event/source/personal_test.go index a5a083d7..6364850b 100644 --- a/internal/event/source/personal_test.go +++ b/internal/event/source/personal_test.go @@ -611,6 +611,44 @@ func TestPersonalSourceParsedHeadersPassNormalBusFilter(t *testing.T) { } } +func TestPersonalSourceActionEventPassesNormalBusFilter(t *testing.T) { + const ( + eventKey = "user_im_message_emotion_group" + subscribeID = "sub-emotion-group" + ) + src := personalSourceForRawEventTests() + raw := src.rawEventFromDataFrame(&payload.DataFrame{ + Headers: payload.DataFrameHeader{ + "EVENT_TYPE": eventKey, + "SUB_ID": subscribeID, + }, + Data: `{"eventKey":"user_im_message_emotion_group","subId":"sub-emotion-group","payload":{}}`, + }) + h := bus.NewHub(10) + consumer, err := h.Register(transport.Hello{ + EventTypes: []string{eventKey}, + SubscribeID: subscribeID, + }) + if err != nil { + t.Fatal(err) + } + + h.Deliver(raw) + + select { + case frame := <-consumer.SendCh: + eventFrame, ok := frame.(transport.Event) + if !ok { + t.Fatalf("frame = %T, want transport.Event", frame) + } + if eventFrame.EventType != eventKey || eventFrame.SubscribeID != subscribeID { + t.Fatalf("event = %#v", eventFrame) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for action event") + } +} + func personalSourceForRawEventTests() *PersonalSource { return &PersonalSource{cfg: PersonalConfig{ SourceID: "fallback_source", diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index ebf372c2..e711e8a1 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -47,7 +47,7 @@ cli_version: ">=1.0.15" | `sheet` | 在线电子表格(axls):工作表 CRUD/区域读写/CSV 批量写入/行列增删/合并/查找替换/筛选视图/全局筛选/排序/下拉列表/条件格式/浮动图片/浮动图表/模板/导出 xlsx(单命令一站式) | [sheet.md](./references/products/sheet.md) | | `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) | | `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 | [wiki.md](./references/products/wiki.md) | -| `event` | 个人消息事件:监听当前用户被 @、指定单聊、指定群聊,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) | +| `event` | 个人 IM 事件:监听消息接收、已读、撤回、表情回应,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) | ## 意图判断决策树 @@ -70,7 +70,7 @@ cli_version: ">=1.0.15" 用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet` 用户提到"待办/TODO/任务提醒/循环待办" → `todo` 用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki` -用户提到"监听有人@我/监听我和某人的单聊消息/监听某个群消息/订阅个人消息事件/实时接收钉钉消息事件/个人消息事件流/event consume user_im_message_receive_at/user_im_message_receive_o2o/user_im_message_receive_group/监听并自动回复消息/驱动 Agent 处理消息" → `event` +用户提到"监听有人@我/监听单聊或群消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/个人事件流/event consume user_im_message_*/监听并自动回复消息/驱动 Agent 处理消息" → `event` 关键区分: aitable(数据表格) vs todo(待办任务) 关键区分: report(钉钉日志/日报周报) vs todo(待办任务) diff --git a/skills/mono/references/products/event.md b/skills/mono/references/products/event.md index e21a64e9..95101527 100644 --- a/skills/mono/references/products/event.md +++ b/skills/mono/references/products/event.md @@ -1,6 +1,6 @@ -# dws event — 个人消息事件 +# dws event — 个人 IM 事件 -通过个人 Stream 长连接监听当前用户收到的钉钉消息事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。实时监听、自动回复、订阅事件都必须使用 `dws event consume`,不要写脚本轮询消息历史。 +通过个人 Stream 长连接监听当前用户的钉钉消息接收、已读、撤回和表情回应事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。实时监听、自动回复、订阅事件都必须使用 `dws event consume`,不要写脚本轮询消息历史。 ## Core commands @@ -19,8 +19,14 @@ | `user_im_message_receive_at` | 当前用户被 @ 的消息 | 无 | | `user_im_message_receive_o2o` | 当前用户与指定用户的单聊消息 | `--user` | | `user_im_message_receive_group` | 当前用户所在指定群聊/会话的消息 | `--group` | +| `user_im_message_read_o2o` | 指定单聊中当前用户发送的消息被已读 | `--user` | +| `user_im_message_read_group` | 指定群聊中当前用户发送的消息被已读 | `--group` | +| `user_im_message_recall_o2o` | 指定单聊中的消息被撤回 | `--user` | +| `user_im_message_recall_group` | 指定群聊中的消息被撤回 | `--group` | +| `user_im_message_emotion_o2o` | 指定单聊中的消息收到表情回应 | `--user` | +| `user_im_message_emotion_group` | 指定群聊中的消息收到表情回应 | `--group` | -只承认上表 3 个事件码。默认身份就是当前用户,不要额外加身份切换 flag。 +只承认上表 9 个事件码。默认身份就是当前用户,不要额外加身份切换 flag。 ## Intent mapping @@ -29,6 +35,12 @@ | "监听有人 @ 我的消息" | `event consume`,事件码 `user_im_message_receive_at`,参数 `-f ndjson` | | "监听我和 userId 507971 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--user 507971 -f ndjson` | | "监听 XX 群消息" | 先 `dws chat search --query "XX" --format json`,确认后 consume group | +| "监听我发给 userId 507971 的消息是否已读" | `event consume`,事件码 `user_im_message_read_o2o`,参数 `--user 507971 -f ndjson` | +| "监听 XX 群消息已读" | 先解析群 ID,再 consume `user_im_message_read_group --group ` | +| "监听我和 userId 507971 的消息撤回" | `event consume`,事件码 `user_im_message_recall_o2o`,参数 `--user 507971 -f ndjson` | +| "监听 XX 群消息撤回" | 先解析群 ID,再 consume `user_im_message_recall_group --group ` | +| "监听我和 userId 507971 的消息贴表情" | `event consume`,事件码 `user_im_message_emotion_o2o`,参数 `--user 507971 -f ndjson` | +| "监听 XX 群消息表情回应" | 先解析群 ID,再 consume `user_im_message_emotion_group --group ` | | "监听并自动回复某人的单聊消息" | 先解析对端 userId,再启动 o2o consume;不要写轮询脚本 | | "查看个人消息事件 schema" | `dws event schema ` | | "看个人事件订阅状态" | `dws event status --event ` | @@ -36,6 +48,8 @@ 多候选必须让用户确认。缺少必填 ID 且无法解析时先追问,不要猜测。 +用户要求执行“撤回消息”时走 `dws chat`;只有“监听/订阅消息撤回”才走 `dws event`。“贴标签”表示给消息贴表情时,对应 `emotion` 表情回应事件。 + ## Call flow 1. 从用户意图选择事件码;人名或群名先解析成必填 ID。 @@ -51,6 +65,12 @@ dws event schema user_im_message_receive_at dws event schema user_im_message_receive_o2o dws event schema user_im_message_receive_group +dws event schema user_im_message_read_o2o +dws event schema user_im_message_read_group +dws event schema user_im_message_recall_o2o +dws event schema user_im_message_recall_group +dws event schema user_im_message_emotion_o2o +dws event schema user_im_message_emotion_group ``` ```bash @@ -69,6 +89,15 @@ dws event consume user_im_message_receive_group \ -f ndjson ``` +```bash +dws event consume user_im_message_read_o2o --user 507971 -f ndjson +dws event consume user_im_message_read_group --group -f ndjson +dws event consume user_im_message_recall_o2o --user 507971 -f ndjson +dws event consume user_im_message_recall_group --group -f ndjson +dws event consume user_im_message_emotion_o2o --user 507971 -f ndjson +dws event consume user_im_message_emotion_group --group -f ndjson +``` + ```bash dws event status --event user_im_message_receive_at dws event status --event user_im_message_receive_o2o @@ -86,6 +115,7 @@ dws event stop - 人工取样可用 `-f json --max-events 1`。 - `data` 是服务端业务 payload 的 JSON 字符串;读取消息内容前按 schema 的 `jq_root_path` 再解析一次。 - 当前消息正文在 `payload.body.content`,发送人展示名在 `payload.body.sender`,会话 ID 在 `payload.body.openConversationId`。 +- 上述消息字段只适用于 `user_im_message_receive_*`;动作事件当前只保证 `type/event_id/timestamp/subscribe_id/payload`,不要猜测未知 payload 字段。 - `--debug-raw-events` 仅用于服务端联调,正常消费不要使用。 ## Full reference diff --git a/skills/multi/dingtalk-event/SKILL.md b/skills/multi/dingtalk-event/SKILL.md index f092d640..4bc15d91 100644 --- a/skills/multi/dingtalk-event/SKILL.md +++ b/skills/multi/dingtalk-event/SKILL.md @@ -1,9 +1,9 @@ --- name: dingtalk-event -description: 钉钉个人消息事件长连接监听、订阅与消费,输出 NDJSON 到 stdout。Use when 用户提到 监听个人消息事件、被@消息、监听我和某人的单聊消息、监听某个群消息、实时接收钉钉消息事件、dws event consume user_im_message_receive_at/user_im_message_receive_o2o/user_im_message_receive_group、用事件驱动 Agent 处理钉钉消息、监听并自动回复消息。命令前缀:dws event。 +description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消息接收、已读、撤回和表情回应,输出 NDJSON 到 stdout。Use when 用户提到 监听个人消息事件、被@消息、监听单聊或群消息、监听消息已读、监听消息撤回、监听消息贴表情或表情回应、实时接收钉钉事件、用事件驱动 Agent。命令前缀:dws event。 --- -# 钉钉个人消息事件 +# 钉钉个人 IM 事件 只使用 `dws event consume` 建立个人消息事件长连接。用户要求实时监听、订阅、自动回复或驱动 Agent 时,不要写轮询脚本,不要用消息历史查询模拟事件。 @@ -25,8 +25,14 @@ description: 钉钉个人消息事件长连接监听、订阅与消费,输出 | `user_im_message_receive_at` | 当前用户被 @ 的消息 | 无 | | `user_im_message_receive_o2o` | 当前用户与指定用户的单聊消息 | `--user` | | `user_im_message_receive_group` | 当前用户所在指定群聊/会话的消息 | `--group` | +| `user_im_message_read_o2o` | 指定单聊中当前用户发送的消息被已读 | `--user` | +| `user_im_message_read_group` | 指定群聊中当前用户发送的消息被已读 | `--group` | +| `user_im_message_recall_o2o` | 指定单聊中的消息被撤回 | `--user` | +| `user_im_message_recall_group` | 指定群聊中的消息被撤回 | `--group` | +| `user_im_message_emotion_o2o` | 指定单聊中的消息收到表情回应 | `--user` | +| `user_im_message_emotion_group` | 指定群聊中的消息收到表情回应 | `--group` | -只承认上表 3 个事件码。其它身份模式、应用凭证模式、非个人消息事件不在本 skill 范围内。 +只承认上表 9 个事件码。其它身份模式、应用凭证模式、非个人 IM 事件不在本 skill 范围内。 ## Command rules @@ -36,6 +42,8 @@ description: 钉钉个人消息事件长连接监听、订阅与消费,输出 - 缺少必填 ID 时先解析或追问,不要猜测 ID。 - 用户只给单聊对端人名时,先运行 `dws aisearch person --keyword "" --dimension name --format json` 解析 userId;多候选必须让用户确认。 - 用户只给群名时,先运行 `dws chat search --query "" --format json` 解析 openConversationId;多候选必须让用户确认。 +- 用户要求执行“撤回消息”时使用 `dws chat`;只有“监听/订阅消息撤回”才使用 `dws event consume user_im_message_recall_*`。 +- 用户说“贴标签”且语义是给消息贴表情时,按消息表情回应事件处理,event key 使用 `emotion`。 - 正常 Agent 消费使用 `-f ndjson`。抓一条样本可用 `--max-events 1 -f json`。 - `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。 @@ -73,6 +81,21 @@ dws event consume user_im_message_receive_group \ --group cidxxxxxxxx \ -f ndjson +# 指定单聊消息已读 +dws event consume user_im_message_read_o2o \ + --user 507971 \ + -f ndjson + +# 指定群聊消息撤回 +dws event consume user_im_message_recall_group \ + --group cidxxxxxxxx \ + -f ndjson + +# 指定单聊消息收到表情回应 +dws event consume user_im_message_emotion_o2o \ + --user 507971 \ + -f ndjson + # 有界自测 dws event consume user_im_message_receive_at \ --duration 10m \ @@ -91,9 +114,10 @@ dws event consume user_im_message_receive_o2o \ - 顶层 `jq_root_path` 说明业务字段起点;当前值是 `.data | fromjson`。 - `schema.properties` 是业务字段列表,例如 `content`、`sender`、`conversation_id`、`message_id`、`event_time`。 - 不要假设 `data` 已经展开。读取消息正文时先解析 `data`,再读 `payload.body.content` 或 schema 中对应字段。 +- 已读、撤回、表情回应事件尚无稳定业务样本;其 schema 只保证 `type/event_id/timestamp/subscribe_id/payload`,处理时保留未知 `payload` 字段,不要猜测已读人、撤回人或表情类型。 ## Topic index | Topic | Reference | Coverage | |---|---|---| -| IM | [references/event-im.md](references/event-im.md) | 三类个人消息事件命令、参数、生命周期、输出解析、自测、过滤和排障 | +| IM | [references/event-im.md](references/event-im.md) | 九类个人 IM 事件命令、参数、生命周期、输出解析、自测和排障 | diff --git a/skills/multi/dingtalk-event/references/event-im.md b/skills/multi/dingtalk-event/references/event-im.md index d1fa13b9..5f34014f 100644 --- a/skills/multi/dingtalk-event/references/event-im.md +++ b/skills/multi/dingtalk-event/references/event-im.md @@ -1,6 +1,6 @@ -# IM 个人消息事件 +# IM 个人事件 -先读上层 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的 IM 个人消息事件:被 @、指定单聊、指定群聊。 +先读上层 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的 IM 个人事件:消息接收、已读、撤回和表情回应。 实时监听、自动回复、订阅事件都必须使用 `dws event consume` 长连接,不要写轮询脚本。 @@ -18,6 +18,12 @@ dws auth login dws event schema user_im_message_receive_at dws event schema user_im_message_receive_o2o dws event schema user_im_message_receive_group +dws event schema user_im_message_read_o2o +dws event schema user_im_message_read_group +dws event schema user_im_message_recall_o2o +dws event schema user_im_message_recall_group +dws event schema user_im_message_emotion_o2o +dws event schema user_im_message_emotion_group ``` schema 默认 JSON。业务字段说明在 `schema.properties`,当前业务 payload 解析起点看 `jq_root_path`。 @@ -29,6 +35,12 @@ schema 默认 JSON。业务字段说明在 `schema.properties`,当前业务 pa | `user_im_message_receive_at` | `at` | 当前用户被 @ 的消息 | 无 | | `user_im_message_receive_o2o` | `singleChat` | 当前用户与指定用户的单聊消息 | `--user` | | `user_im_message_receive_group` | `group` | 当前用户所在指定群聊/会话的消息 | `--group` | +| `user_im_message_read_o2o` | `singleChat` | 指定单聊中当前用户发送的消息被已读 | `--user` | +| `user_im_message_read_group` | `group` | 指定群聊中当前用户发送的消息被已读 | `--group` | +| `user_im_message_recall_o2o` | `singleChat` | 指定单聊中的消息被撤回 | `--user` | +| `user_im_message_recall_group` | `group` | 指定群聊中的消息被撤回 | `--group` | +| `user_im_message_emotion_o2o` | `singleChat` | 指定单聊中的消息收到表情回应 | `--user` | +| `user_im_message_emotion_group` | `group` | 指定群聊中的消息收到表情回应 | `--group` | 默认身份就是当前用户。不要额外加身份切换 flag,不要使用应用凭证模式,不要使用本表以外的事件码。 @@ -38,6 +50,8 @@ schema 默认 JSON。业务字段说明在 `schema.properties`,当前业务 pa - 群名 → `dws chat search --query "" --format json`,确认后取 `openConversationId`。 - 多候选 → 展示候选并让用户确认。 - 仍缺必填 ID → 先追问,不要编造。 +- “撤回消息”表示执行操作时走 `dws chat`;“监听/订阅消息撤回”才走本事件能力。 +- “贴标签”表示给消息贴表情时,对应 `emotion` 表情回应事件。 ## Consume commands @@ -54,6 +68,36 @@ dws event consume user_im_message_receive_o2o \ dws event consume user_im_message_receive_group \ --group cidxxxxxxxx \ -f ndjson + +# 指定单聊已读事件 +dws event consume user_im_message_read_o2o \ + --user 507971 \ + -f ndjson + +# 指定群聊已读事件 +dws event consume user_im_message_read_group \ + --group cidxxxxxxxx \ + -f ndjson + +# 指定单聊撤回事件 +dws event consume user_im_message_recall_o2o \ + --user 507971 \ + -f ndjson + +# 指定群聊撤回事件 +dws event consume user_im_message_recall_group \ + --group cidxxxxxxxx \ + -f ndjson + +# 指定单聊表情回应事件 +dws event consume user_im_message_emotion_o2o \ + --user 507971 \ + -f ndjson + +# 指定群聊表情回应事件 +dws event consume user_im_message_emotion_group \ + --group cidxxxxxxxx \ + -f ndjson ``` ## Self-test triggers @@ -63,6 +107,12 @@ dws event consume user_im_message_receive_group \ | `user_im_message_receive_at` | `--duration 10m -f ndjson` | 让任意可触达用户在群里 @ 当前登录用户 | | `user_im_message_receive_o2o` | `--user --duration 10m -f ndjson` | 让对端用户给当前登录用户发送单聊消息 | | `user_im_message_receive_group` | `--group --duration 10m -f ndjson` | 让任意用户在该群发送消息 | +| `user_im_message_read_o2o` | `--user --duration 10m -f ndjson` | 当前用户给对端发送单聊消息,再让对端打开并阅读 | +| `user_im_message_read_group` | `--group --duration 10m -f ndjson` | 当前用户在群内发送消息,再让群成员打开并阅读 | +| `user_im_message_recall_o2o` | `--user --duration 10m -f ndjson` | 在指定单聊中发送并撤回一条消息 | +| `user_im_message_recall_group` | `--group --duration 10m -f ndjson` | 在指定群聊中发送并撤回一条消息 | +| `user_im_message_emotion_o2o` | `--user --duration 10m -f ndjson` | 在指定单聊中给消息添加表情回应 | +| `user_im_message_emotion_group` | `--group --duration 10m -f ndjson` | 在指定群聊中给消息添加表情回应 | stderr 出现 `connected bus pid=...` 表示本地 consume 已连接到事件 bus。stdout 每行是一个事件 JSON。 @@ -110,6 +160,8 @@ stderr 出现 `connected bus pid=...` 表示本地 consume 已连接到事件 bu | `create_time` | `payload.body.createTime` | 消息创建时间 | | `event_time` | `payload.event_time` | 消息事件时间戳 | +上表业务字段只适用于 `user_im_message_receive_*`。已读、撤回、表情回应事件当前只保证解析后的 `type`、`event_id`、`timestamp`、`subscribe_id` 和开放对象 `payload`;不要提前假设 `payload` 中存在已读人、撤回人、表情类型或消息 ID。 + ## Filtering 优先用订阅规则参数缩小服务端推送范围: @@ -117,7 +169,7 @@ stderr 出现 `connected bus pid=...` 表示本地 consume 已连接到事件 bu - 单聊用 `--user`。 - 群消息用 `--group`。 -额外文本过滤再用 `--query` 或 `--filter-json`: +收消息事件需要额外文本过滤时再用 `--query` 或 `--filter-json`: ```bash dws event consume user_im_message_receive_group \ @@ -128,12 +180,17 @@ dws event consume user_im_message_receive_group \ `--filter-json` 可以使用业务别名,也可以使用真实路径。优先使用 schema 字段名表达意图;需要和服务端联调时再使用真实路径。 +已读、撤回、表情回应事件暂无稳定 payload schema,不推荐使用 `--query` 或 `--filter-json`。 + ## Status and stop ```bash dws event status --event user_im_message_receive_at dws event status --event user_im_message_receive_o2o dws event status --event user_im_message_receive_group +dws event status --event user_im_message_read_o2o +dws event status --event user_im_message_recall_group +dws event status --event user_im_message_emotion_o2o ``` `status` 同时展示服务端 `Subscriptions` 和本地 `Consumers`。`Consumers` 表里的 PID、事件码、`subscribe_id`、received/dropped 计数用于确认当前前台 consume 是否还挂在 personal bus 上。 @@ -155,7 +212,7 @@ dws event stop --all ## Troubleshooting - 没有输出:先确认 stderr 已出现 `connected bus pid=...`。 -- 参数缺失:单聊必须有对端 ID,群消息必须有 openConversationId。 +- 参数缺失:所有 o2o 事件必须有对端 ID,所有 group 事件必须有 openConversationId。 - 收到非预期消息:检查 stdout 的 `subscribe_id` 是否等于当前命令创建/复用的订阅 ID。 - 需要判断服务端是否推到当前连接:临时加 `--debug --debug-raw-events`,排查后去掉。 - 需要长期运行:交给外部进程管理;不要把消息历史查询写成轮询脚本。 diff --git a/test/skill_tests.md b/test/skill_tests.md index 17af85e6..8c77462c 100644 --- a/test/skill_tests.md +++ b/test/skill_tests.md @@ -994,7 +994,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 --- -### event(10 条) +### event(14 条) #### `dws event consume user_im_message_receive_at` @@ -1025,6 +1025,48 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 - Expected: `dws event consume user_im_message_receive_group --group cid123 -f ndjson` - Flags: `--group` = `cid123` +#### `dws event consume user_im_message_read_o2o` + +**event_event_consume_read_o2o_001** +- Prompt: 监听我发给 userId 507971 的单聊消息是否已读 +- Expected: `dws event consume user_im_message_read_o2o --user 507971 -f ndjson` +- Flags: `--user` = `507971` + +#### `dws event consume user_im_message_read_group` + +**event_event_consume_read_group_001** +- Prompt: 监听 openConversationId cid123 群里我发的消息是否已读 +- Expected: `dws event consume user_im_message_read_group --group cid123 -f ndjson` +- Flags: `--group` = `cid123` + +#### `dws event consume user_im_message_recall_o2o` + +**event_event_consume_recall_o2o_001** +- Prompt: 监听我和 userId 507971 的单聊消息撤回事件 +- Expected: `dws event consume user_im_message_recall_o2o --user 507971 -f ndjson` +- Flags: `--user` = `507971` + +#### `dws event consume user_im_message_recall_group` + +**event_event_consume_recall_group_001** +- Prompt: 监听 openConversationId cid123 的群消息撤回事件 +- Expected: `dws event consume user_im_message_recall_group --group cid123 -f ndjson` +- Flags: `--group` = `cid123` + +#### `dws event consume user_im_message_emotion_o2o` + +**event_event_consume_emotion_o2o_001** +- Prompt: 监听我和 userId 507971 的单聊消息贴表情事件 +- Expected: `dws event consume user_im_message_emotion_o2o --user 507971 -f ndjson` +- Flags: `--user` = `507971` + +#### `dws event consume user_im_message_emotion_group` + +**event_event_consume_emotion_group_001** +- Prompt: 监听 openConversationId cid123 的群消息表情回应事件 +- Expected: `dws event consume user_im_message_emotion_group --group cid123 -f ndjson` +- Flags: `--group` = `cid123` + #### `dws chat search` **event_lookup_group_001**