@soimy/dingtalk 3.6.4 → 3.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +14 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +7207 -6169
  5. package/dist/index.js.map +4 -4
  6. package/dist/src/card/ask-user-question-context.d.ts +17 -0
  7. package/dist/src/card/ask-user-question-context.d.ts.map +1 -0
  8. package/dist/src/card/ask-user-question.d.ts +241 -0
  9. package/dist/src/card/ask-user-question.d.ts.map +1 -0
  10. package/dist/src/card/card-action-handler.d.ts +1 -0
  11. package/dist/src/card/card-action-handler.d.ts.map +1 -1
  12. package/dist/src/card/card-template.d.ts +5 -0
  13. package/dist/src/card/card-template.d.ts.map +1 -1
  14. package/dist/src/card/task-model-metadata.d.ts +11 -0
  15. package/dist/src/card/task-model-metadata.d.ts.map +1 -0
  16. package/dist/src/gateway/channel-gateway.d.ts.map +1 -1
  17. package/dist/src/inbound-handler.d.ts.map +1 -1
  18. package/dist/src/reply-strategy-card.d.ts.map +1 -1
  19. package/dist/src/reply-strategy-types.d.ts +1 -0
  20. package/dist/src/reply-strategy-types.d.ts.map +1 -1
  21. package/dist/src/session-state.d.ts +11 -5
  22. package/dist/src/session-state.d.ts.map +1 -1
  23. package/docs/assets/dingtalk-ask-user-card-template.json +6 -0
  24. package/index.ts +18 -1
  25. package/openclaw.plugin.json +3 -0
  26. package/package.json +2 -1
  27. package/src/card/ask-user-question-context.ts +31 -0
  28. package/src/card/ask-user-question.ts +1068 -0
  29. package/src/card/card-action-handler.ts +14 -0
  30. package/src/card/card-template.ts +10 -0
  31. package/src/card/task-model-metadata.ts +51 -0
  32. package/src/gateway/channel-gateway.ts +5 -5
  33. package/src/inbound-handler.ts +124 -21
  34. package/src/reply-strategy-card.ts +30 -6
  35. package/src/reply-strategy-types.ts +1 -0
  36. package/src/session-state.ts +44 -13
@@ -0,0 +1,1068 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
+ import { getAccessToken } from "../auth";
4
+ import { updateCardVariables } from "../card-callback-service";
5
+ import { resolveRobotCode } from "../config";
6
+ import axios from "../http-client";
7
+ import { handleDingTalkMessage } from "../inbound-handler";
8
+ import type { DingTalkConfig, DingTalkInboundMessage, Logger } from "../types";
9
+ import { formatDingTalkErrorPayloadLog, getProxyBypassOption, parseBooleanLike } from "../utils";
10
+ import {
11
+ getDingTalkQuestionContext,
12
+ type DingTalkQuestionContext,
13
+ } from "./ask-user-question-context";
14
+ import { DINGTALK_ASK_USER_CARD_TEMPLATE } from "./card-template";
15
+
16
+ const DINGTALK_API = "https://api.dingtalk.com";
17
+ const PENDING_QUESTION_TTL_MS = 5 * 60 * 1000;
18
+ const HANDLED_CALLBACK_TOMBSTONE_TTL_MS = 30 * 60 * 1000;
19
+ const TOOL_NAME = "dingtalk_ask_user_question";
20
+ const ANSWER_FIELD_PREFIX = "answer";
21
+
22
+ type AskUserOption = {
23
+ label?: string;
24
+ value?: string;
25
+ description?: string;
26
+ };
27
+
28
+ type AskUserQuestion = {
29
+ question?: string;
30
+ header?: string;
31
+ options?: AskUserOption[];
32
+ multiSelect?: boolean;
33
+ };
34
+
35
+ type FormFieldType =
36
+ | "TEXT"
37
+ | "TEXT_ARRAY"
38
+ | "TEXT_AREA"
39
+ | "NUMBER"
40
+ | "SELECT"
41
+ | "MULTI_SELECT"
42
+ | "DATE"
43
+ | "TIME"
44
+ | "DATETIME"
45
+ | "CHECKBOX"
46
+ | "SWITCH"
47
+ | "CHECKBOX_GROUP"
48
+ | "MULTI_CHECKBOX_GROUP";
49
+
50
+ type RawValue = string | number | boolean;
51
+ type SelectValue = { index: number; value: RawValue };
52
+ type MultiSelectValue = { index: number[]; value: RawValue[] };
53
+ type AnswerEntry = { question: string; answer: string };
54
+
55
+ type FormField = {
56
+ name: string;
57
+ label?: string;
58
+ type: FormFieldType;
59
+ hidden?: boolean;
60
+ required?: boolean;
61
+ requiredMsg?: string;
62
+ readOnly?: boolean;
63
+ placeholder?: string;
64
+ format?: string;
65
+ defaultValue?: RawValue | RawValue[] | SelectValue | MultiSelectValue;
66
+ // DingTalk form protocol documentation also exposes this misspelled key.
67
+ defautValue?: RawValue | RawValue[] | SelectValue | MultiSelectValue;
68
+ options?: Array<{ value: string; text: string }>;
69
+ minRows?: number;
70
+ maxRows?: number;
71
+ addText?: string;
72
+ };
73
+
74
+ type PendingQuestion = DingTalkQuestionContext & {
75
+ questionId: string;
76
+ outTrackId: string;
77
+ title: string;
78
+ questions: Array<{
79
+ fieldName: string;
80
+ title: string;
81
+ options: Array<{ value: string; text: string }>;
82
+ multiSelect: boolean;
83
+ }>;
84
+ submitted: boolean;
85
+ ownerUserId?: string;
86
+ ttlTimer?: ReturnType<typeof setTimeout>;
87
+ };
88
+
89
+ type HandledQuestionTombstone = {
90
+ outTrackId: string;
91
+ questionId: string;
92
+ reason: "superseded" | "expired" | "submitted" | "cancelled" | "empty";
93
+ timer?: ReturnType<typeof setTimeout>;
94
+ };
95
+
96
+ type ParsedCardCallback = {
97
+ outTrackId?: string;
98
+ actionId?: string;
99
+ params: Record<string, unknown>;
100
+ hasBusinessPayload: boolean;
101
+ };
102
+
103
+ const pendingQuestionsByTrackId = new Map<string, PendingQuestion>();
104
+ const pendingQuestionsByQuestionId = new Map<string, PendingQuestion>();
105
+ const pendingOutTrackIdsByScopeKey = new Map<string, Set<string>>();
106
+ const handledQuestionTombstonesByTrackId = new Map<string, HandledQuestionTombstone>();
107
+ const handledQuestionTombstonesByQuestionId = new Map<string, HandledQuestionTombstone>();
108
+
109
+ function jsonToolResult(payload: unknown): {
110
+ content: Array<{ type: "text"; text: string }>;
111
+ details: unknown;
112
+ } {
113
+ return {
114
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
115
+ details: payload,
116
+ };
117
+ }
118
+
119
+ function stringifyCardData(data: Record<string, unknown>): Record<string, string> {
120
+ const result: Record<string, string> = {};
121
+ for (const [key, value] of Object.entries(data)) {
122
+ result[key] = typeof value === "string" ? value : JSON.stringify(value);
123
+ }
124
+ return result;
125
+ }
126
+
127
+ function readString(value: unknown): string | undefined {
128
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
129
+ }
130
+
131
+ function normalizeUserId(value: unknown): string | undefined {
132
+ return readString(value);
133
+ }
134
+
135
+ function resolvePendingQuestionOwner(ctx: PendingQuestion): string | undefined {
136
+ return (
137
+ normalizeUserId(ctx.ownerUserId) ??
138
+ normalizeUserId(ctx.data.senderStaffId) ??
139
+ normalizeUserId(ctx.data.senderId)
140
+ );
141
+ }
142
+
143
+ function isOwnerClick(ctx: PendingQuestion, clickerUserId?: string): boolean {
144
+ const ownerUserId = resolvePendingQuestionOwner(ctx);
145
+ if (!ownerUserId) {
146
+ return true;
147
+ }
148
+ const clicker = normalizeUserId(clickerUserId);
149
+ if (!clicker) {
150
+ return false;
151
+ }
152
+ const allowed = [ownerUserId, ctx.data.senderStaffId, ctx.data.senderId]
153
+ .map((value) => normalizeUserId(value)?.toLowerCase())
154
+ .filter((value): value is string => Boolean(value));
155
+ return allowed.includes(clicker.toLowerCase());
156
+ }
157
+
158
+ function normalizeOption(option: AskUserOption, index: number): { value: string; text: string } {
159
+ const text = readString(option.label) ?? readString(option.description) ?? `选项 ${index + 1}`;
160
+ const value = readString(option.value) ?? text;
161
+ return { value, text };
162
+ }
163
+
164
+ function normalizeFormOption(option: unknown, index: number): { value: string; text: string } {
165
+ const record = asRecord(option) ?? {};
166
+ const value = readString(record.value) ?? `option_${index + 1}`;
167
+ const text = readString(record.text) ?? value;
168
+ return { value, text };
169
+ }
170
+
171
+ export function buildQuestionForm(questions: AskUserQuestion[]): {
172
+ title: string;
173
+ desc: string;
174
+ fields: FormField[];
175
+ parsed: PendingQuestion["questions"];
176
+ } {
177
+ const parsed = questions.map((question, index) => {
178
+ const options = Array.isArray(question.options)
179
+ ? question.options.map((option, optionIndex) => normalizeOption(option, optionIndex))
180
+ : [];
181
+ const title =
182
+ readString(question.header) ?? readString(question.question) ?? `问题 ${index + 1}`;
183
+ const fieldName = `${ANSWER_FIELD_PREFIX}_${index}`;
184
+ return {
185
+ fieldName,
186
+ title,
187
+ options,
188
+ multiSelect: Boolean(question.multiSelect),
189
+ };
190
+ });
191
+
192
+ const fields: FormField[] = parsed.map((question) => {
193
+ if (question.options.length === 0) {
194
+ return {
195
+ name: question.fieldName,
196
+ label: question.title,
197
+ type: "TEXT",
198
+ required: true,
199
+ placeholder: "请输入回答",
200
+ };
201
+ }
202
+ return {
203
+ name: question.fieldName,
204
+ label: question.title,
205
+ type: question.multiSelect ? "MULTI_CHECKBOX_GROUP" : "CHECKBOX_GROUP",
206
+ required: true,
207
+ options: question.options,
208
+ };
209
+ });
210
+
211
+ const first = questions[0] ?? {};
212
+ const title = readString(first.header) ?? readString(first.question) ?? "需要你的确认";
213
+ const desc = readString(first.question) ?? title;
214
+ return { title, desc, fields, parsed };
215
+ }
216
+
217
+ const FORM_FIELD_TYPES = new Set<FormFieldType>([
218
+ "TEXT",
219
+ "TEXT_ARRAY",
220
+ "TEXT_AREA",
221
+ "NUMBER",
222
+ "SELECT",
223
+ "MULTI_SELECT",
224
+ "DATE",
225
+ "TIME",
226
+ "DATETIME",
227
+ "CHECKBOX",
228
+ "SWITCH",
229
+ "CHECKBOX_GROUP",
230
+ "MULTI_CHECKBOX_GROUP",
231
+ ]);
232
+
233
+ export function buildQuestionFormFromFields(params: {
234
+ title?: string;
235
+ description?: string;
236
+ fields: FormField[];
237
+ }): {
238
+ title: string;
239
+ desc: string;
240
+ fields: FormField[];
241
+ parsed: PendingQuestion["questions"];
242
+ } {
243
+ const fields = params.fields.map((field, index) => {
244
+ const name = readString(field.name) ?? `${ANSWER_FIELD_PREFIX}_${index}`;
245
+ const rawType = readString(field.type);
246
+ const type = rawType && FORM_FIELD_TYPES.has(rawType as FormFieldType) ? rawType : "TEXT";
247
+ const label = readString(field.label) ?? name;
248
+ const normalized: FormField = {
249
+ ...field,
250
+ name,
251
+ label,
252
+ type: type as FormFieldType,
253
+ };
254
+ if (Array.isArray(field.options)) {
255
+ normalized.options = field.options.map((option, optionIndex) =>
256
+ normalizeFormOption(option, optionIndex),
257
+ );
258
+ }
259
+ return normalized;
260
+ });
261
+ const parsed = fields.map((field) => ({
262
+ fieldName: field.name,
263
+ title: readString(field.label) ?? field.name,
264
+ options: Array.isArray(field.options) ? field.options : [],
265
+ multiSelect: field.type === "MULTI_CHECKBOX_GROUP" || field.type === "MULTI_SELECT",
266
+ }));
267
+ const firstLabel = readString(fields[0]?.label);
268
+ const title = readString(params.title) ?? firstLabel ?? "需要你的确认";
269
+ const desc = readString(params.description) ?? title;
270
+ return { title, desc, fields, parsed };
271
+ }
272
+
273
+ async function createAndDeliverQuestionCard(params: {
274
+ config: DingTalkConfig;
275
+ conversationId: string;
276
+ isDirect: boolean;
277
+ templateId: string;
278
+ outTrackId: string;
279
+ cardData: Record<string, unknown>;
280
+ log?: Logger;
281
+ }): Promise<void> {
282
+ const token = await getAccessToken(params.config, params.log);
283
+ const isGroup = !params.isDirect;
284
+ const body = {
285
+ cardTemplateId: params.templateId,
286
+ outTrackId: params.outTrackId,
287
+ cardData: {
288
+ cardParamMap: stringifyCardData(params.cardData),
289
+ },
290
+ callbackType: "STREAM",
291
+ imGroupOpenSpaceModel: { supportForward: true },
292
+ imRobotOpenSpaceModel: { supportForward: true },
293
+ openSpaceId: isGroup
294
+ ? `dtv1.card//IM_GROUP.${params.conversationId}`
295
+ : `dtv1.card//IM_ROBOT.${params.conversationId}`,
296
+ userIdType: 1,
297
+ imGroupOpenDeliverModel: isGroup
298
+ ? {
299
+ robotCode: resolveRobotCode(params.config),
300
+ extension: { dynamicSummary: "true" },
301
+ }
302
+ : undefined,
303
+ imRobotOpenDeliverModel: !isGroup
304
+ ? {
305
+ spaceType: "IM_ROBOT",
306
+ robotCode: resolveRobotCode(params.config),
307
+ extension: { dynamicSummary: "true" },
308
+ }
309
+ : undefined,
310
+ };
311
+
312
+ params.log?.debug?.(
313
+ `[DingTalk][AskUser] POST /v1.0/card/instances/createAndDeliver body=${JSON.stringify(body)}`,
314
+ );
315
+ const resp = await axios.post(`${DINGTALK_API}/v1.0/card/instances/createAndDeliver`, body, {
316
+ headers: {
317
+ "x-acs-dingtalk-access-token": token,
318
+ "Content-Type": "application/json",
319
+ },
320
+ ...getProxyBypassOption(params.config),
321
+ });
322
+ params.log?.debug?.(
323
+ `[DingTalk][AskUser] createAndDeliver response status=${resp.status} data=${JSON.stringify(resp.data)}`,
324
+ );
325
+ const deliverResults = (
326
+ resp.data?.result as
327
+ | { deliverResults?: Array<{ success?: boolean; errorMsg?: string }> }
328
+ | undefined
329
+ )?.deliverResults;
330
+ const failedDelivery = Array.isArray(deliverResults)
331
+ ? deliverResults.find((item) => item?.success === false)
332
+ : undefined;
333
+ if (failedDelivery) {
334
+ throw new Error(failedDelivery.errorMsg?.trim() || "DingTalk question card delivery failed");
335
+ }
336
+ }
337
+
338
+ function removeScopeIndex(ctx: PendingQuestion): void {
339
+ const scopeKey = readString(ctx.questionScopeKey);
340
+ if (!scopeKey) {
341
+ return;
342
+ }
343
+ const set = pendingOutTrackIdsByScopeKey.get(scopeKey);
344
+ if (!set) {
345
+ return;
346
+ }
347
+ set.delete(ctx.outTrackId);
348
+ if (set.size === 0) {
349
+ pendingOutTrackIdsByScopeKey.delete(scopeKey);
350
+ }
351
+ }
352
+
353
+ function addScopeIndex(ctx: PendingQuestion): void {
354
+ const scopeKey = readString(ctx.questionScopeKey);
355
+ if (!scopeKey) {
356
+ return;
357
+ }
358
+ let set = pendingOutTrackIdsByScopeKey.get(scopeKey);
359
+ if (!set) {
360
+ set = new Set();
361
+ pendingOutTrackIdsByScopeKey.set(scopeKey, set);
362
+ }
363
+ set.add(ctx.outTrackId);
364
+ }
365
+
366
+ function deleteHandledQuestionTombstone(tombstone: HandledQuestionTombstone): void {
367
+ if (handledQuestionTombstonesByTrackId.get(tombstone.outTrackId) === tombstone) {
368
+ handledQuestionTombstonesByTrackId.delete(tombstone.outTrackId);
369
+ }
370
+ if (handledQuestionTombstonesByQuestionId.get(tombstone.questionId) === tombstone) {
371
+ handledQuestionTombstonesByQuestionId.delete(tombstone.questionId);
372
+ }
373
+ if (tombstone.timer) {
374
+ clearTimeout(tombstone.timer);
375
+ }
376
+ }
377
+
378
+ function addHandledQuestionTombstone(
379
+ ctx: PendingQuestion,
380
+ reason: HandledQuestionTombstone["reason"],
381
+ ): void {
382
+ const existingByTrack = handledQuestionTombstonesByTrackId.get(ctx.outTrackId);
383
+ if (existingByTrack) {
384
+ deleteHandledQuestionTombstone(existingByTrack);
385
+ }
386
+ const existingByQuestion = handledQuestionTombstonesByQuestionId.get(ctx.questionId);
387
+ if (existingByQuestion && existingByQuestion !== existingByTrack) {
388
+ deleteHandledQuestionTombstone(existingByQuestion);
389
+ }
390
+ const tombstone: HandledQuestionTombstone = {
391
+ outTrackId: ctx.outTrackId,
392
+ questionId: ctx.questionId,
393
+ reason,
394
+ };
395
+ tombstone.timer = setTimeout(() => {
396
+ deleteHandledQuestionTombstone(tombstone);
397
+ }, HANDLED_CALLBACK_TOMBSTONE_TTL_MS);
398
+ if (typeof tombstone.timer === "object" && "unref" in tombstone.timer) {
399
+ tombstone.timer.unref();
400
+ }
401
+ handledQuestionTombstonesByTrackId.set(ctx.outTrackId, tombstone);
402
+ handledQuestionTombstonesByQuestionId.set(ctx.questionId, tombstone);
403
+ }
404
+
405
+ function findHandledQuestionTombstone(
406
+ parsed: ParsedCardCallback,
407
+ ): HandledQuestionTombstone | undefined {
408
+ return (
409
+ (parsed.outTrackId ? handledQuestionTombstonesByTrackId.get(parsed.outTrackId) : undefined) ??
410
+ (parsed.actionId ? handledQuestionTombstonesByQuestionId.get(parsed.actionId) : undefined)
411
+ );
412
+ }
413
+
414
+ function supersedePendingQuestionsInScope(ctx: PendingQuestion): void {
415
+ const scopeKey = readString(ctx.questionScopeKey);
416
+ if (!scopeKey) {
417
+ return;
418
+ }
419
+ const set = pendingOutTrackIdsByScopeKey.get(scopeKey);
420
+ if (!set) {
421
+ return;
422
+ }
423
+ for (const outTrackId of Array.from(set)) {
424
+ if (outTrackId === ctx.outTrackId) {
425
+ continue;
426
+ }
427
+ const oldCtx = pendingQuestionsByTrackId.get(outTrackId);
428
+ if (!oldCtx || oldCtx.submitted) {
429
+ continue;
430
+ }
431
+ oldCtx.submitted = true;
432
+ consumePendingQuestion(oldCtx);
433
+ addHandledQuestionTombstone(oldCtx, "superseded");
434
+ void updateQuestionCardBestEffort(oldCtx, {
435
+ card_status: "expired",
436
+ question_desc: "已有新的问题卡片,请回答最新卡片。",
437
+ form_btn_text: "已失效",
438
+ });
439
+ }
440
+ }
441
+
442
+ function storePendingQuestion(ctx: PendingQuestion): void {
443
+ ctx.ownerUserId = resolvePendingQuestionOwner(ctx);
444
+ supersedePendingQuestionsInScope(ctx);
445
+ pendingQuestionsByTrackId.set(ctx.outTrackId, ctx);
446
+ pendingQuestionsByQuestionId.set(ctx.questionId, ctx);
447
+ addScopeIndex(ctx);
448
+ ctx.ttlTimer = setTimeout(() => {
449
+ if (!pendingQuestionsByTrackId.has(ctx.outTrackId) || ctx.submitted) {
450
+ return;
451
+ }
452
+ ctx.submitted = true;
453
+ consumePendingQuestion(ctx);
454
+ addHandledQuestionTombstone(ctx, "expired");
455
+ void updateQuestionCardBestEffort(ctx, {
456
+ card_status: "expired",
457
+ question_desc: "问题已失效,请重新发起。",
458
+ form_btn_text: "已失效",
459
+ });
460
+ setImmediate(() => {
461
+ void injectAnswerSyntheticMessage(ctx, buildExpiredAnswerMessage(ctx), "expired").catch(
462
+ (err) => {
463
+ ctx.log?.error?.(
464
+ `[DingTalk][AskUser] Failed to inject expired answer message: ${String(err)}`,
465
+ );
466
+ },
467
+ );
468
+ });
469
+ }, PENDING_QUESTION_TTL_MS);
470
+ }
471
+
472
+ function consumePendingQuestion(ctx: PendingQuestion): void {
473
+ pendingQuestionsByTrackId.delete(ctx.outTrackId);
474
+ pendingQuestionsByQuestionId.delete(ctx.questionId);
475
+ removeScopeIndex(ctx);
476
+ if (ctx.ttlTimer) {
477
+ clearTimeout(ctx.ttlTimer);
478
+ }
479
+ }
480
+
481
+ async function updateQuestionCard(
482
+ ctx: PendingQuestion,
483
+ variables: Record<string, unknown>,
484
+ ): Promise<void> {
485
+ const token = await getAccessToken(ctx.dingtalkConfig, ctx.log);
486
+ await updateCardVariables(ctx.outTrackId, variables, token, ctx.dingtalkConfig);
487
+ }
488
+
489
+ async function updateQuestionCardBestEffort(
490
+ ctx: PendingQuestion,
491
+ variables: Record<string, unknown>,
492
+ ): Promise<void> {
493
+ try {
494
+ await updateQuestionCard(ctx, variables);
495
+ } catch (err) {
496
+ ctx.log?.warn?.(
497
+ `[DingTalk][AskUser] Failed to update question card ${ctx.questionId}: ${String(err)}`,
498
+ );
499
+ }
500
+ }
501
+
502
+ function parseEmbeddedJson(value: unknown): unknown {
503
+ if (typeof value !== "string") {
504
+ return value;
505
+ }
506
+ try {
507
+ return JSON.parse(value);
508
+ } catch {
509
+ return value;
510
+ }
511
+ }
512
+
513
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
514
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
515
+ return undefined;
516
+ }
517
+ return value as Record<string, unknown>;
518
+ }
519
+
520
+ export function parseAskUserCardCallback(payload: unknown): ParsedCardCallback {
521
+ const record = asRecord(payload) ?? {};
522
+ const content = asRecord(parseEmbeddedJson(record.content));
523
+ const value = asRecord(parseEmbeddedJson(record.value));
524
+ const privateData =
525
+ asRecord(content?.cardPrivateData) ??
526
+ asRecord(parseEmbeddedJson(record.cardPrivateData)) ??
527
+ asRecord(value?.cardPrivateData);
528
+ const params =
529
+ asRecord(privateData?.params) ?? asRecord(content?.params) ?? asRecord(value?.params) ?? {};
530
+ const actionIds = privateData?.actionIds;
531
+ const actionId =
532
+ Array.isArray(actionIds) && typeof actionIds[0] === "string" ? actionIds[0] : undefined;
533
+ const outTrackId =
534
+ readString(record.outTrackId) ??
535
+ readString(content?.outTrackId) ??
536
+ readString(value?.outTrackId) ??
537
+ readString(privateData?.outTrackId);
538
+ return {
539
+ outTrackId,
540
+ actionId,
541
+ params,
542
+ hasBusinessPayload: Boolean(params.form || params.user_cancel),
543
+ };
544
+ }
545
+
546
+ function readFormAnswer(value: unknown): string[] {
547
+ if (value === undefined || value === null) {
548
+ return [];
549
+ }
550
+ if (Array.isArray(value)) {
551
+ return value.map((item) => String(item));
552
+ }
553
+ if (typeof value === "object") {
554
+ const record = value as Record<string, unknown>;
555
+ const raw = record.value;
556
+ if (Array.isArray(raw)) {
557
+ return raw.map((item) => String(item));
558
+ }
559
+ if (raw !== undefined && raw !== null) {
560
+ return [String(raw)];
561
+ }
562
+ }
563
+ return [String(value)];
564
+ }
565
+
566
+ function formatAnswerText(
567
+ question: PendingQuestion["questions"][number],
568
+ values: string[],
569
+ ): string {
570
+ if (values.length === 0) {
571
+ return "";
572
+ }
573
+ const labels = values.map((value) => {
574
+ return question.options.find((option) => option.value === value)?.text ?? value;
575
+ });
576
+ return labels.join(", ");
577
+ }
578
+
579
+ function buildAnswerMessage(ctx: PendingQuestion, answers: AnswerEntry[]): string {
580
+ const lines = answers.map(({ question, answer }) => `- ${question}: ${answer}`);
581
+ return [
582
+ "用户回答了交互卡片:",
583
+ `- question_id: ${ctx.questionId}`,
584
+ `- question_title: ${ctx.title}`,
585
+ "- status: submitted",
586
+ "- answers:",
587
+ ...lines.map((line) => ` ${line}`),
588
+ ].join("\n");
589
+ }
590
+
591
+ function buildEmptyAnswerMessage(ctx: PendingQuestion): string {
592
+ return [
593
+ "用户提交了空交互卡片:",
594
+ `- question_id: ${ctx.questionId}`,
595
+ `- question_title: ${ctx.title}`,
596
+ "- status: submitted",
597
+ ].join("\n");
598
+ }
599
+
600
+ function buildCancelledAnswerMessage(ctx: PendingQuestion): string {
601
+ return [
602
+ "用户取消了交互卡片:",
603
+ `- question_id: ${ctx.questionId}`,
604
+ `- question_title: ${ctx.title}`,
605
+ "- status: cancelled",
606
+ ].join("\n");
607
+ }
608
+
609
+ function buildExpiredAnswerMessage(ctx: PendingQuestion): string {
610
+ return [
611
+ "交互卡片已超时:",
612
+ `- question_id: ${ctx.questionId}`,
613
+ `- question_title: ${ctx.title}`,
614
+ "- status: expired",
615
+ ].join("\n");
616
+ }
617
+
618
+ async function injectAnswerSyntheticMessage(
619
+ ctx: PendingQuestion,
620
+ text: string,
621
+ suffix: string,
622
+ ): Promise<void> {
623
+ const syntheticData: DingTalkInboundMessage = {
624
+ // Keep this origin-derived synthetic id stable and unique. If inbound
625
+ // dedup/self-filter/auth gates move here later, reinjected ask-user answers
626
+ // must still pass or the waiting session can hang.
627
+ msgId: `${ctx.data.msgId || ctx.outTrackId}:ask-user-${suffix}:${ctx.questionId}`,
628
+ msgtype: "text",
629
+ createAt: Date.now(),
630
+ text: { content: text },
631
+ conversationType: ctx.data.conversationType,
632
+ conversationId: ctx.data.conversationId,
633
+ conversationTitle: ctx.data.conversationTitle,
634
+ senderId: ctx.data.senderId,
635
+ senderStaffId: ctx.data.senderStaffId,
636
+ senderNick: ctx.data.senderNick,
637
+ chatbotUserId: ctx.data.chatbotUserId,
638
+ sessionWebhook: ctx.data.sessionWebhook,
639
+ };
640
+ await handleDingTalkMessage({
641
+ cfg: ctx.cfg,
642
+ accountId: ctx.accountId,
643
+ data: syntheticData,
644
+ sessionWebhook: ctx.sessionWebhook,
645
+ log: ctx.log,
646
+ dingtalkConfig: ctx.dingtalkConfig,
647
+ });
648
+ }
649
+
650
+ export async function handleDingTalkAskUserCardCallback(params: {
651
+ payload: unknown;
652
+ cfg: DingTalkQuestionContext["cfg"];
653
+ accountId: string;
654
+ config: DingTalkConfig;
655
+ clickerUserId?: string;
656
+ log?: Logger;
657
+ }): Promise<{ handled: boolean }> {
658
+ const parsed = parseAskUserCardCallback(params.payload);
659
+ const tombstone = findHandledQuestionTombstone(parsed);
660
+ if (tombstone) {
661
+ params.log?.debug?.(
662
+ `[DingTalk][AskUser] Ignoring handled callback question=${tombstone.questionId} reason=${tombstone.reason}`,
663
+ );
664
+ return { handled: true };
665
+ }
666
+ const ctx =
667
+ (parsed.outTrackId ? pendingQuestionsByTrackId.get(parsed.outTrackId) : undefined) ??
668
+ (parsed.actionId ? pendingQuestionsByQuestionId.get(parsed.actionId) : undefined);
669
+ if (!ctx) {
670
+ return { handled: false };
671
+ }
672
+
673
+ if (!isOwnerClick(ctx, params.clickerUserId)) {
674
+ params.log?.info?.(
675
+ `[DingTalk][AskUser] rejected: clicker=${params.clickerUserId ?? "unknown"} owner=${resolvePendingQuestionOwner(ctx) ?? "unknown"} question=${ctx.questionId}`,
676
+ );
677
+ return { handled: true };
678
+ }
679
+
680
+ if (!parsed.hasBusinessPayload) {
681
+ params.log?.debug?.(
682
+ `[DingTalk][AskUser] Ignoring non-business card callback outTrackId=${ctx.outTrackId}`,
683
+ );
684
+ return { handled: true };
685
+ }
686
+
687
+ if (ctx.submitted) {
688
+ params.log?.debug?.(`[DingTalk][AskUser] Duplicate submit ignored question=${ctx.questionId}`);
689
+ return { handled: true };
690
+ }
691
+
692
+ const isCancel = parseBooleanLike(parsed.params.user_cancel) === true;
693
+ ctx.submitted = true;
694
+
695
+ if (isCancel) {
696
+ await updateQuestionCardBestEffort(ctx, {
697
+ card_status: "cancelled",
698
+ question_desc: "已取消。",
699
+ form_btn_text: "已取消",
700
+ });
701
+ consumePendingQuestion(ctx);
702
+ addHandledQuestionTombstone(ctx, "cancelled");
703
+ setImmediate(() => {
704
+ void injectAnswerSyntheticMessage(ctx, buildCancelledAnswerMessage(ctx), "cancelled").catch(
705
+ (err) => {
706
+ params.log?.error?.(
707
+ `[DingTalk][AskUser] Failed to inject cancelled answer message: ${String(err)}`,
708
+ );
709
+ },
710
+ );
711
+ });
712
+ return { handled: true };
713
+ }
714
+
715
+ const form = asRecord(parsed.params.form);
716
+ if (!form) {
717
+ ctx.submitted = false;
718
+ params.log?.warn?.(
719
+ `[DingTalk][AskUser] Missing form payload question=${ctx.questionId} params=${JSON.stringify(parsed.params)}`,
720
+ );
721
+ return { handled: true };
722
+ }
723
+
724
+ const answers: AnswerEntry[] = [];
725
+ const selectedValues: string[] = [];
726
+ for (const question of ctx.questions) {
727
+ const values = readFormAnswer(form[question.fieldName]);
728
+ selectedValues.push(...values);
729
+ const answerText = formatAnswerText(question, values);
730
+ if (answerText) {
731
+ answers.push({ question: question.title, answer: answerText });
732
+ }
733
+ }
734
+
735
+ if (answers.length === 0) {
736
+ params.log?.warn?.(
737
+ `[DingTalk][AskUser] Empty form answer question=${ctx.questionId} form=${JSON.stringify(form)}`,
738
+ );
739
+ await updateQuestionCardBestEffort(ctx, {
740
+ card_status: "submitted",
741
+ question_desc: "已提交,未填写任何内容。",
742
+ selected_text: "",
743
+ selected_values: "[]",
744
+ form_btn_text: "已提交",
745
+ });
746
+ consumePendingQuestion(ctx);
747
+ addHandledQuestionTombstone(ctx, "empty");
748
+ setImmediate(() => {
749
+ void injectAnswerSyntheticMessage(ctx, buildEmptyAnswerMessage(ctx), "empty").catch((err) => {
750
+ params.log?.error?.(
751
+ `[DingTalk][AskUser] Failed to inject empty answer message: ${String(err)}`,
752
+ );
753
+ });
754
+ });
755
+ return { handled: true };
756
+ }
757
+
758
+ const selectedText = answers.map(({ answer }) => answer).join(", ");
759
+ await updateQuestionCardBestEffort(ctx, {
760
+ card_status: "submitted",
761
+ question_desc: `已选择:${selectedText}。`,
762
+ selected_text: selectedText,
763
+ selected_values: JSON.stringify(selectedValues),
764
+ form_btn_text: "已提交",
765
+ });
766
+ consumePendingQuestion(ctx);
767
+ addHandledQuestionTombstone(ctx, "submitted");
768
+
769
+ const message = buildAnswerMessage(ctx, answers);
770
+ setImmediate(() => {
771
+ void injectAnswerSyntheticMessage(ctx, message, "submitted").catch((err) => {
772
+ params.log?.error?.(`[DingTalk][AskUser] Failed to inject answer message: ${String(err)}`);
773
+ });
774
+ });
775
+ return { handled: true };
776
+ }
777
+
778
+ const AskUserQuestionSchema = {
779
+ type: "object",
780
+ additionalProperties: false,
781
+ anyOf: [{ required: ["questions"] }, { required: ["fields"] }],
782
+ properties: {
783
+ title: {
784
+ type: "string",
785
+ description: "Card title. Used with fields; omit to use the first field label.",
786
+ },
787
+ description: {
788
+ type: "string",
789
+ description: "Short description shown above the form. Used with fields.",
790
+ },
791
+ questions: {
792
+ type: "array",
793
+ description:
794
+ "Lightweight blocking question DSL for simple confirmation, single-select, multi-select, or simple free-text prompts. Prefer exactly one question per card. " +
795
+ "Do not use questions for complex forms, multiple structured fields, date/time inputs, numeric inputs, boolean switches, or mixed input collection; use top-level fields for those cases. " +
796
+ "Do not use for explanations, status updates, capability introductions, or retrospective questions.",
797
+ minItems: 1,
798
+ maxItems: 6,
799
+ items: {
800
+ type: "object",
801
+ additionalProperties: false,
802
+ required: ["question", "header", "options"],
803
+ properties: {
804
+ question: { type: "string", description: "The question to ask the user" },
805
+ header: { type: "string", description: "Short label for the question (max 12 chars)" },
806
+ options: {
807
+ type: "array",
808
+ maxItems: 20,
809
+ items: {
810
+ type: "object",
811
+ additionalProperties: false,
812
+ required: ["label"],
813
+ properties: {
814
+ label: { type: "string", description: "Display text for this option" },
815
+ value: {
816
+ type: "string",
817
+ description:
818
+ "Machine-readable value returned to the assistant; omit to use label",
819
+ },
820
+ description: {
821
+ type: "string",
822
+ description: "Explanation of what this option means",
823
+ },
824
+ },
825
+ },
826
+ description:
827
+ "Available choices. Leave empty ([]) for free-text input — the user will see a text field instead. " +
828
+ "Use two options for confirmation.",
829
+ },
830
+ multiSelect: {
831
+ type: "boolean",
832
+ description: "Whether multiple options can be selected (ignored when options is empty)",
833
+ },
834
+ },
835
+ },
836
+ },
837
+ fields: {
838
+ type: "array",
839
+ description:
840
+ "Advanced DingTalk form fields. Use top-level fields when collecting multiple inputs, " +
841
+ "when the user asks to fill a form, or when you would otherwise list required parameters in markdown. " +
842
+ "Use one fields card to collect all missing inputs for the current turn; do not split related fields into multiple cards. " +
843
+ "Do not answer with a markdown checklist when these fields are needed. The plugin will send " +
844
+ "these fields as the DingTalk card variable form, shaped as { fields }. Do not wrap fields inside form. " +
845
+ "For simple confirmation, single-select, or multi-select questions, prefer questions. Do not mix fields with questions. " +
846
+ "For choice fields (SELECT, MULTI_SELECT, CHECKBOX_GROUP, MULTI_CHECKBOX_GROUP), " +
847
+ "provide options as { value, text }. Use TEXT for single-line text, TEXT_AREA for " +
848
+ "multi-line text, NUMBER for numeric input, DATE/TIME/DATETIME for date or time inputs, " +
849
+ "and CHECKBOX or SWITCH for boolean inputs.",
850
+ minItems: 1,
851
+ maxItems: 20,
852
+ items: {
853
+ type: "object",
854
+ additionalProperties: false,
855
+ required: ["name", "label", "type"],
856
+ properties: {
857
+ name: { type: "string", description: "Unique form field key" },
858
+ label: { type: "string", description: "Field label shown to the user" },
859
+ type: {
860
+ type: "string",
861
+ enum: [
862
+ "TEXT",
863
+ "TEXT_ARRAY",
864
+ "TEXT_AREA",
865
+ "NUMBER",
866
+ "SELECT",
867
+ "MULTI_SELECT",
868
+ "DATE",
869
+ "TIME",
870
+ "DATETIME",
871
+ "CHECKBOX",
872
+ "SWITCH",
873
+ "CHECKBOX_GROUP",
874
+ "MULTI_CHECKBOX_GROUP",
875
+ ],
876
+ description: "DingTalk form field type",
877
+ },
878
+ hidden: { type: "boolean" },
879
+ required: { type: "boolean" },
880
+ requiredMsg: { type: "string" },
881
+ readOnly: { type: "boolean" },
882
+ placeholder: { type: "string" },
883
+ defaultValue: {},
884
+ defautValue: {
885
+ description:
886
+ "Compatibility alias for DingTalk form protocol documentation typo; prefer defaultValue when possible.",
887
+ },
888
+ options: {
889
+ type: "array",
890
+ description:
891
+ "Required for SELECT, MULTI_SELECT, CHECKBOX_GROUP, and MULTI_CHECKBOX_GROUP. Each option must be { value, text }.",
892
+ items: {
893
+ type: "object",
894
+ additionalProperties: false,
895
+ required: ["value", "text"],
896
+ properties: {
897
+ value: { type: "string" },
898
+ text: { type: "string" },
899
+ },
900
+ },
901
+ },
902
+ minRows: { type: "number" },
903
+ maxRows: { type: "number" },
904
+ addText: { type: "string" },
905
+ },
906
+ },
907
+ },
908
+ },
909
+ } as const;
910
+
911
+ export function getAskUserQuestionSchemaForTest(): typeof AskUserQuestionSchema {
912
+ return AskUserQuestionSchema;
913
+ }
914
+
915
+ export function registerPendingQuestionForTest(
916
+ ctx: Omit<PendingQuestion, "ttlTimer" | "submitted"> & { submitted?: boolean },
917
+ ): void {
918
+ storePendingQuestion({
919
+ ...ctx,
920
+ submitted: Boolean(ctx.submitted),
921
+ });
922
+ }
923
+
924
+ export function clearPendingQuestionsForTest(): void {
925
+ for (const ctx of pendingQuestionsByTrackId.values()) {
926
+ if (ctx.ttlTimer) {
927
+ clearTimeout(ctx.ttlTimer);
928
+ }
929
+ }
930
+ const tombstones = new Set([
931
+ ...handledQuestionTombstonesByTrackId.values(),
932
+ ...handledQuestionTombstonesByQuestionId.values(),
933
+ ]);
934
+ for (const tombstone of tombstones) {
935
+ if (tombstone.timer) {
936
+ clearTimeout(tombstone.timer);
937
+ }
938
+ }
939
+ pendingQuestionsByTrackId.clear();
940
+ pendingQuestionsByQuestionId.clear();
941
+ pendingOutTrackIdsByScopeKey.clear();
942
+ handledQuestionTombstonesByTrackId.clear();
943
+ handledQuestionTombstonesByQuestionId.clear();
944
+ }
945
+
946
+ export function registerDingTalkAskUserQuestionTool(api: OpenClawPluginApi): void {
947
+ const registerTool = (
948
+ api as OpenClawPluginApi & { registerTool?: OpenClawPluginApi["registerTool"] }
949
+ ).registerTool;
950
+ api.logger?.debug?.(
951
+ `${TOOL_NAME}: register hook invoked, mode=${api.registrationMode ?? "unknown"}, registerTool=${typeof registerTool}`,
952
+ );
953
+ if (typeof registerTool !== "function") {
954
+ api.logger?.warn?.(`${TOOL_NAME}: registerTool unavailable, skipping tool registration`);
955
+ return;
956
+ }
957
+
958
+ registerTool.call(api, {
959
+ name: TOOL_NAME,
960
+ label: "Ask User Question",
961
+ description:
962
+ "Ask the user a blocking question or collect structured input via an interactive DingTalk form card when the current task cannot continue without the user's answer. " +
963
+ "Returns immediately after sending the card. " +
964
+ "The user's answer will arrive as a new message in the conversation. " +
965
+ "Do NOT poll or re-call this tool — just wait for the response message. " +
966
+ "Use questions only for simple confirmation, single-select, multi-select, or simple free-text prompts. " +
967
+ "For simple selection questions, provide options; for simple free-text input, set options to an empty array. " +
968
+ "When collecting multiple missing values, when the user asks for a form, or when you would otherwise list required parameters for the user to fill, call this tool with top-level fields instead of replying with a markdown checklist. " +
969
+ "Do not call this tool for normal explanations, why/how questions, capability introductions, or cases where you can answer directly.",
970
+ parameters: AskUserQuestionSchema as any,
971
+ async execute(_toolCallId: string, params: unknown) {
972
+ const context = getDingTalkQuestionContext();
973
+ if (!context) {
974
+ return jsonToolResult({
975
+ status: "failed",
976
+ error: "dingtalk_ask_user_question can only be used in a DingTalk message context",
977
+ });
978
+ }
979
+ const templateId = DINGTALK_ASK_USER_CARD_TEMPLATE.templateId;
980
+
981
+ const record = asRecord(params) ?? {};
982
+ const rawFields = Array.isArray(record.fields) ? (record.fields as FormField[]) : [];
983
+ const rawQuestions = Array.isArray(record.questions)
984
+ ? (record.questions as AskUserQuestion[])
985
+ : [];
986
+ if (rawFields.length === 0 && rawQuestions.length === 0) {
987
+ return jsonToolResult({
988
+ status: "failed",
989
+ error: "questions or fields must contain at least one item",
990
+ });
991
+ }
992
+
993
+ const questionId = `q_${randomUUID()}`;
994
+ const outTrackId = `ask_${randomUUID()}`;
995
+ const { title, desc, fields, parsed } =
996
+ rawFields.length > 0
997
+ ? buildQuestionFormFromFields({
998
+ title: readString(record.title),
999
+ description: readString(record.description),
1000
+ fields: rawFields,
1001
+ })
1002
+ : buildQuestionForm(rawQuestions);
1003
+ const cardData = {
1004
+ question_id: questionId,
1005
+ question_title: title,
1006
+ question_desc: desc,
1007
+ card_status: "pending",
1008
+ form_btn_text: "提交",
1009
+ selected_text: "",
1010
+ selected_values: "[]",
1011
+ form: { fields },
1012
+ };
1013
+
1014
+ try {
1015
+ await createAndDeliverQuestionCard({
1016
+ config: context.dingtalkConfig,
1017
+ conversationId:
1018
+ context.data.conversationType === "1"
1019
+ ? context.data.senderStaffId || context.data.senderId || context.data.conversationId
1020
+ : context.data.conversationId,
1021
+ isDirect: context.data.conversationType === "1",
1022
+ templateId,
1023
+ outTrackId,
1024
+ cardData,
1025
+ log: context.log,
1026
+ });
1027
+ } catch (err) {
1028
+ const detail = formatDingTalkErrorPayloadLog("ask_user_create", err, "[DingTalk]");
1029
+ return jsonToolResult({
1030
+ status: "failed",
1031
+ error: detail || (err instanceof Error ? err.message : String(err)),
1032
+ });
1033
+ }
1034
+ const pendingContext: DingTalkQuestionContext = {
1035
+ ...context,
1036
+ onQuestionCardSent: undefined,
1037
+ };
1038
+ storePendingQuestion({
1039
+ ...pendingContext,
1040
+ questionId,
1041
+ outTrackId,
1042
+ title,
1043
+ questions: parsed,
1044
+ submitted: false,
1045
+ });
1046
+
1047
+ try {
1048
+ await context.onQuestionCardSent?.({ questionId, outTrackId });
1049
+ } catch (err) {
1050
+ context.log?.warn?.(
1051
+ `[DingTalk][AskUser] onQuestionCardSent hook failed: ${err instanceof Error ? err.message : String(err)}`,
1052
+ );
1053
+ }
1054
+
1055
+ context.log?.info?.(
1056
+ `[DingTalk][AskUser] question card sent question=${questionId} outTrackId=${outTrackId}`,
1057
+ );
1058
+ return jsonToolResult({
1059
+ status: "pending",
1060
+ questionId,
1061
+ outTrackId,
1062
+ message:
1063
+ "Question card sent to the user. Their answer will arrive as a follow-up message in this conversation.",
1064
+ });
1065
+ },
1066
+ });
1067
+ api.logger?.debug?.(`${TOOL_NAME}: registered tool`);
1068
+ }