dsh-caduceus 0.1.1 → 0.4.1
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.
- package/README.md +9 -0
- package/README.zh.md +9 -0
- package/dist/bridge/bridge.d.ts +9 -0
- package/dist/bridge/bridge.js +155 -5
- package/dist/bridge/types.d.ts +22 -0
- package/dist/commands/parse.d.ts +0 -11
- package/dist/commands/parse.js +5 -1
- package/dist/config.d.ts +52 -1
- package/dist/config.js +29 -0
- package/dist/gate.d.ts +3 -1
- package/dist/gate.js +6 -5
- package/dist/index.js +20 -1
- package/dist/interactive/approval.d.ts +9 -0
- package/dist/interactive/approval.js +85 -0
- package/dist/interactive/cards.d.ts +36 -0
- package/dist/interactive/cards.js +125 -0
- package/dist/interactive/pending.d.ts +29 -0
- package/dist/interactive/pending.js +39 -0
- package/dist/interactive/question.d.ts +25 -0
- package/dist/interactive/question.js +86 -0
- package/dist/interactive/runtime.d.ts +44 -0
- package/dist/interactive/runtime.js +147 -0
- package/dist/interactive/types.d.ts +50 -0
- package/dist/interactive/types.js +25 -0
- package/dist/mentions.d.ts +15 -0
- package/dist/mentions.js +21 -0
- package/dist/transport/feishu-api.d.ts +9 -1
- package/dist/transport/lark.d.ts +7 -1
- package/dist/transport/lark.js +34 -2
- package/dist/transport/normalize.d.ts +22 -22
- package/dist/transport/normalize.js +83 -3
- package/dist/transport/reliable.d.ts +5 -1
- package/dist/transport/reliable.js +12 -0
- package/dist/transport/types.d.ts +15 -0
- package/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `approval/request` answerer: send an approval card, wait for a button click
|
|
3
|
+
* (or timeout/abort), and return only closed dsh approval outcomes.
|
|
4
|
+
*
|
|
5
|
+
* Fail-closed rules:
|
|
6
|
+
* - approvalCard disabled / no owner context -> `unavailable` (never prompt);
|
|
7
|
+
* - card send fails -> log ERROR and `cancelled`;
|
|
8
|
+
* - timeout or abort -> `cancelled`;
|
|
9
|
+
* - callback handler resolves the pending entry with the chosen outcome.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-caduceus/interactive/approval
|
|
12
|
+
*/
|
|
13
|
+
import { approvalCard, approvalTerminalCard } from './cards.js';
|
|
14
|
+
import { logPrefix } from './types.js';
|
|
15
|
+
export function createApprovalAnswerer(host, contextFor) {
|
|
16
|
+
return async (request) => {
|
|
17
|
+
if (!host.config.approvalCard) {
|
|
18
|
+
host.logger.warn('interactive: approval skipped (approvalCard disabled)');
|
|
19
|
+
return 'unavailable';
|
|
20
|
+
}
|
|
21
|
+
const context = contextFor();
|
|
22
|
+
if (context === undefined) {
|
|
23
|
+
host.logger.warn('interactive: approval skipped (no session context)');
|
|
24
|
+
return 'unavailable';
|
|
25
|
+
}
|
|
26
|
+
let settle;
|
|
27
|
+
const outcome = new Promise((resolve) => {
|
|
28
|
+
settle = resolve;
|
|
29
|
+
});
|
|
30
|
+
const entry = host.pending.add({
|
|
31
|
+
kind: 'approval',
|
|
32
|
+
scopeKey: context.scopeKey,
|
|
33
|
+
chatId: context.chatId,
|
|
34
|
+
ownerOpenId: context.ownerOpenId,
|
|
35
|
+
sessionId: context.sessionId,
|
|
36
|
+
createdAt: host.clock.now(),
|
|
37
|
+
resolve: (value) => settle(value),
|
|
38
|
+
reject: () => settle('cancelled'),
|
|
39
|
+
meta: { toolName: request.toolName, message: request.reason },
|
|
40
|
+
});
|
|
41
|
+
host.logger.info(`interactive: approval request id=${entry.id} questions=0 scope=${context.scopeKey}`);
|
|
42
|
+
const card = approvalCard(entry.id, context.sessionId, request.toolName, request.reason);
|
|
43
|
+
let timer;
|
|
44
|
+
const onAbort = () => {
|
|
45
|
+
if (host.pending.delete(entry.id) === undefined)
|
|
46
|
+
return;
|
|
47
|
+
void host
|
|
48
|
+
.updateCard(entry.cardMessageId ?? '', approvalTerminalCard(request.toolName, '已取消'))
|
|
49
|
+
.catch(() => undefined);
|
|
50
|
+
settle('cancelled');
|
|
51
|
+
};
|
|
52
|
+
const cleanup = () => {
|
|
53
|
+
if (timer !== undefined)
|
|
54
|
+
host.clock.clearTimeout(timer);
|
|
55
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
56
|
+
};
|
|
57
|
+
entry.cleanup = cleanup;
|
|
58
|
+
host.logger.info(`interactive: approval card sending chat=${logPrefix(context.chatId)}`);
|
|
59
|
+
try {
|
|
60
|
+
const sent = await host.sendCard(context.sessionId, card);
|
|
61
|
+
entry.cardMessageId = sent.messageId;
|
|
62
|
+
host.logger.info(`interactive: approval card sent messageId=${logPrefix(entry.cardMessageId)} thread=${logPrefix(sent.threadId ?? '-', 8)}`);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
host.pending.delete(entry.id);
|
|
66
|
+
host.logger.error(`interactive: approval card send failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
return 'cancelled';
|
|
68
|
+
}
|
|
69
|
+
timer = host.clock.setTimeout(() => {
|
|
70
|
+
if (host.pending.delete(entry.id) === undefined)
|
|
71
|
+
return;
|
|
72
|
+
host.logger.info(`interactive: approval timed out after ${host.config.timeoutMs}ms`);
|
|
73
|
+
void host
|
|
74
|
+
.updateCard(entry.cardMessageId ?? '', approvalTerminalCard(request.toolName, '已过期'))
|
|
75
|
+
.catch(() => undefined);
|
|
76
|
+
settle('cancelled');
|
|
77
|
+
}, host.config.timeoutMs);
|
|
78
|
+
if (request.signal?.aborted === true) {
|
|
79
|
+
onAbort();
|
|
80
|
+
return outcome;
|
|
81
|
+
}
|
|
82
|
+
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
83
|
+
return outcome;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FeishuCard, FeishuCardElement } from '../transport/types.js';
|
|
2
|
+
export interface CardButton {
|
|
3
|
+
readonly label: string;
|
|
4
|
+
readonly type?: 'primary' | 'danger' | 'default';
|
|
5
|
+
readonly value: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
/** A card with optional buttons. */
|
|
8
|
+
export declare function interactiveCard(title: string, lines: readonly string[], buttons?: readonly CardButton[], template?: string): FeishuCard;
|
|
9
|
+
/** Card from pre-built elements (forms, inputs, action rows). */
|
|
10
|
+
export declare function interactiveElementsCard(title: string, elements: readonly FeishuCardElement[], template?: string): FeishuCard;
|
|
11
|
+
export declare function approvalCard(requestId: string, sessionId: string, toolName: string, reason: string | undefined): FeishuCard;
|
|
12
|
+
export declare function approvalTerminalCard(toolName: string, outcome: string): FeishuCard;
|
|
13
|
+
export interface QuestionCardInput {
|
|
14
|
+
readonly requestId: string;
|
|
15
|
+
readonly sessionId: string;
|
|
16
|
+
/** Extra warning appended to the card (e.g. the empty-custom hint). */
|
|
17
|
+
readonly hint?: string;
|
|
18
|
+
readonly questions: readonly {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly question: string;
|
|
21
|
+
readonly header?: string;
|
|
22
|
+
readonly detail?: string;
|
|
23
|
+
readonly options?: readonly {
|
|
24
|
+
readonly label: string;
|
|
25
|
+
readonly description?: string;
|
|
26
|
+
}[];
|
|
27
|
+
readonly multiSelect?: boolean;
|
|
28
|
+
}[];
|
|
29
|
+
}
|
|
30
|
+
export interface QuestionCardResult {
|
|
31
|
+
readonly card: FeishuCard;
|
|
32
|
+
readonly buttons: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** Buttons only for exactly one single-select question with 1..4 options. */
|
|
35
|
+
export declare function questionCard(input: QuestionCardInput): QuestionCardResult;
|
|
36
|
+
export declare function questionTerminalCard(question: string, answer: string): FeishuCard;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Feishu card builders (approval / question / terminal states).
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-caduceus/interactive/cards
|
|
5
|
+
*/
|
|
6
|
+
import { buildCard } from '../render/cards.js';
|
|
7
|
+
function div(content) {
|
|
8
|
+
return { tag: 'div', text: { tag: 'lark_md', content } };
|
|
9
|
+
}
|
|
10
|
+
function actionRow(buttons) {
|
|
11
|
+
return {
|
|
12
|
+
tag: 'action',
|
|
13
|
+
actions: buttons.map((button) => ({
|
|
14
|
+
tag: 'button',
|
|
15
|
+
text: { tag: 'plain_text', content: button.label },
|
|
16
|
+
type: button.type ?? 'default',
|
|
17
|
+
value: button.value,
|
|
18
|
+
})),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** A card with optional buttons. */
|
|
22
|
+
export function interactiveCard(title, lines, buttons = [], template = 'orange') {
|
|
23
|
+
const elements = lines.map(div);
|
|
24
|
+
if (buttons.length > 0)
|
|
25
|
+
elements.push(actionRow(buttons));
|
|
26
|
+
return buildCard({ title, template, elements });
|
|
27
|
+
}
|
|
28
|
+
/** Card from pre-built elements (forms, inputs, action rows). */
|
|
29
|
+
export function interactiveElementsCard(title, elements, template = 'orange') {
|
|
30
|
+
return buildCard({ title, template, elements });
|
|
31
|
+
}
|
|
32
|
+
export function approvalCard(requestId, sessionId, toolName, reason) {
|
|
33
|
+
return interactiveCard(`审批请求 · ${toolName}`, [
|
|
34
|
+
reason === undefined || reason.trim().length === 0 ? '(无说明)' : reason,
|
|
35
|
+
], [
|
|
36
|
+
{
|
|
37
|
+
label: '允许一次',
|
|
38
|
+
type: 'primary',
|
|
39
|
+
value: { kind: 'approval', id: requestId, sessionId, choice: 'allow' },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
label: '拒绝',
|
|
43
|
+
type: 'danger',
|
|
44
|
+
value: { kind: 'approval', id: requestId, sessionId, choice: 'reject' },
|
|
45
|
+
},
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
export function approvalTerminalCard(toolName, outcome) {
|
|
49
|
+
return interactiveCard(`审批请求 · ${toolName}`, [outcome], [], 'grey');
|
|
50
|
+
}
|
|
51
|
+
/** Buttons only for exactly one single-select question with 1..4 options. */
|
|
52
|
+
export function questionCard(input) {
|
|
53
|
+
const questions = input.questions;
|
|
54
|
+
const lines = [];
|
|
55
|
+
for (const question of questions) {
|
|
56
|
+
const heading = question.header === undefined ? question.question : `${question.header}:${question.question}`;
|
|
57
|
+
lines.push(`**${heading}**`);
|
|
58
|
+
if (question.detail !== undefined && question.detail.trim().length > 0)
|
|
59
|
+
lines.push(question.detail);
|
|
60
|
+
if (question.multiSelect === true)
|
|
61
|
+
lines.push('_(多选暂不支持,仅能选一项)_');
|
|
62
|
+
for (const option of question.options ?? []) {
|
|
63
|
+
lines.push(`- ${option.label}${option.description === undefined ? '' : ` — ${option.description}`}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (input.hint !== undefined && input.hint.length > 0)
|
|
67
|
+
lines.push(`**${input.hint}**`);
|
|
68
|
+
const only = questions.length === 1 ? questions[0] : undefined;
|
|
69
|
+
const options = only?.options ?? [];
|
|
70
|
+
const buttons = only !== undefined &&
|
|
71
|
+
options.length > 0 &&
|
|
72
|
+
options.length <= 4;
|
|
73
|
+
const elements = lines.map(div);
|
|
74
|
+
if (buttons) {
|
|
75
|
+
elements.push(actionRow(options.map((option) => ({
|
|
76
|
+
label: option.label,
|
|
77
|
+
value: {
|
|
78
|
+
kind: 'question',
|
|
79
|
+
id: input.requestId,
|
|
80
|
+
sessionId: input.sessionId,
|
|
81
|
+
questionId: only.id,
|
|
82
|
+
label: option.label,
|
|
83
|
+
},
|
|
84
|
+
}))));
|
|
85
|
+
}
|
|
86
|
+
if (only === undefined) {
|
|
87
|
+
lines.push('_本版暂不支持在卡片内输入文字,请直接回复文本作答。_');
|
|
88
|
+
return { card: interactiveCard('问题', lines), buttons: false };
|
|
89
|
+
}
|
|
90
|
+
elements.push(div('或自己填:'));
|
|
91
|
+
elements.push({
|
|
92
|
+
tag: 'form',
|
|
93
|
+
name: 'question_form',
|
|
94
|
+
elements: [
|
|
95
|
+
{
|
|
96
|
+
tag: 'input',
|
|
97
|
+
name: 'other',
|
|
98
|
+
placeholder: { tag: 'plain_text', content: '输入自定义答案' },
|
|
99
|
+
max_length: 200,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
tag: 'button',
|
|
103
|
+
name: 'submit',
|
|
104
|
+
type: 'primary',
|
|
105
|
+
action_type: 'form_submit',
|
|
106
|
+
text: { tag: 'plain_text', content: '提交' },
|
|
107
|
+
value: {
|
|
108
|
+
kind: 'question',
|
|
109
|
+
id: input.requestId,
|
|
110
|
+
sessionId: input.sessionId,
|
|
111
|
+
questionId: only.id,
|
|
112
|
+
form: true,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
const title = only.header === undefined ? `问题 · ${only.question}` : `问题 · ${only.header}`;
|
|
118
|
+
return {
|
|
119
|
+
card: interactiveElementsCard(title, elements),
|
|
120
|
+
buttons,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function questionTerminalCard(question, answer) {
|
|
124
|
+
return interactiveCard(`问题 · ${question}`, [answer], [], 'grey');
|
|
125
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type PendingKind = 'approval' | 'question';
|
|
2
|
+
export interface PendingEntry {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly kind: PendingKind;
|
|
5
|
+
readonly scopeKey: string;
|
|
6
|
+
readonly chatId: string;
|
|
7
|
+
readonly ownerOpenId: string;
|
|
8
|
+
readonly sessionId: string;
|
|
9
|
+
readonly createdAt: number;
|
|
10
|
+
readonly resolve: (value: unknown) => void;
|
|
11
|
+
readonly reject: (error: unknown) => void;
|
|
12
|
+
/** Card message id assigned after a successful send. */
|
|
13
|
+
cardMessageId?: string;
|
|
14
|
+
/** Extra presentation data (tool name / questions). */
|
|
15
|
+
readonly meta?: Record<string, unknown>;
|
|
16
|
+
/** Clears timers/signal listeners when the runtime settles the entry. */
|
|
17
|
+
cleanup?: () => void;
|
|
18
|
+
}
|
|
19
|
+
export type NewPendingEntry = Omit<PendingEntry, 'id'> & {
|
|
20
|
+
readonly id?: string;
|
|
21
|
+
};
|
|
22
|
+
export declare class PendingRegistry {
|
|
23
|
+
#private;
|
|
24
|
+
add(entry: NewPendingEntry): PendingEntry;
|
|
25
|
+
get(id: string): PendingEntry | undefined;
|
|
26
|
+
delete(id: string): PendingEntry | undefined;
|
|
27
|
+
get size(): number;
|
|
28
|
+
clear(): void;
|
|
29
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory pending interactive requests (one per approval/question card).
|
|
3
|
+
*
|
|
4
|
+
* Entries are keyed by a random id embedded in every button value. They carry
|
|
5
|
+
* the scope owner so a click from anyone else can be ignored. The registry is
|
|
6
|
+
* intentionally dumb: the runtime decides when an entry settles, patches the
|
|
7
|
+
* card, and clears timers through `cleanup`.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-caduceus/interactive/pending
|
|
10
|
+
*/
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
export class PendingRegistry {
|
|
13
|
+
#entries = new Map();
|
|
14
|
+
add(entry) {
|
|
15
|
+
const id = entry.id ?? randomUUID();
|
|
16
|
+
const value = { ...entry, id };
|
|
17
|
+
this.#entries.set(id, value);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
get(id) {
|
|
21
|
+
return this.#entries.get(id);
|
|
22
|
+
}
|
|
23
|
+
delete(id) {
|
|
24
|
+
const value = this.#entries.get(id);
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
this.#entries.delete(id);
|
|
28
|
+
value.cleanup?.();
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
get size() {
|
|
32
|
+
return this.#entries.size;
|
|
33
|
+
}
|
|
34
|
+
clear() {
|
|
35
|
+
for (const entry of this.#entries.values())
|
|
36
|
+
entry.cleanup?.();
|
|
37
|
+
this.#entries.clear();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type InteractiveContext, type InteractiveHost } from './types.js';
|
|
2
|
+
export interface QuestionItemLike {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly question: string;
|
|
5
|
+
readonly header?: string;
|
|
6
|
+
readonly detail?: string;
|
|
7
|
+
readonly options?: readonly {
|
|
8
|
+
readonly label: string;
|
|
9
|
+
readonly description?: string;
|
|
10
|
+
}[];
|
|
11
|
+
readonly multiSelect?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface QuestionRequestLike {
|
|
14
|
+
readonly questions: readonly QuestionItemLike[];
|
|
15
|
+
readonly signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
export interface QuestionAnswerLike {
|
|
18
|
+
readonly answers: readonly {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly selected: readonly string[];
|
|
21
|
+
readonly custom?: string;
|
|
22
|
+
}[];
|
|
23
|
+
}
|
|
24
|
+
export type QuestionAnswerer = (request: QuestionRequestLike, next: () => Promise<QuestionAnswerLike>) => Promise<QuestionAnswerLike>;
|
|
25
|
+
export declare function createQuestionAnswerer(host: InteractiveHost, contextFor: () => InteractiveContext | undefined): QuestionAnswerer;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `user-questions/request` answerer: render one question card (buttons when a
|
|
3
|
+
* single question has 1..4 options) and resolve the first click. Free-text
|
|
4
|
+
* answers are not implemented in this version; text-only cards expire.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-caduceus/interactive/question
|
|
7
|
+
*/
|
|
8
|
+
import { questionCard, questionTerminalCard } from './cards.js';
|
|
9
|
+
import { logPrefix } from './types.js';
|
|
10
|
+
export function createQuestionAnswerer(host, contextFor) {
|
|
11
|
+
return async (request) => {
|
|
12
|
+
if (!host.config.questionCard) {
|
|
13
|
+
host.logger.warn('interactive: question skipped (questionCard disabled)');
|
|
14
|
+
throw new Error('interactive question cards are disabled');
|
|
15
|
+
}
|
|
16
|
+
const context = contextFor();
|
|
17
|
+
if (context === undefined) {
|
|
18
|
+
host.logger.warn('interactive: question skipped (no session context)');
|
|
19
|
+
throw new Error('no caduceus session context for user question');
|
|
20
|
+
}
|
|
21
|
+
let settle;
|
|
22
|
+
let fail;
|
|
23
|
+
const answer = new Promise((resolve, reject) => {
|
|
24
|
+
settle = resolve;
|
|
25
|
+
fail = reject;
|
|
26
|
+
});
|
|
27
|
+
const entry = host.pending.add({
|
|
28
|
+
kind: 'question',
|
|
29
|
+
scopeKey: context.scopeKey,
|
|
30
|
+
chatId: context.chatId,
|
|
31
|
+
ownerOpenId: context.ownerOpenId,
|
|
32
|
+
sessionId: context.sessionId,
|
|
33
|
+
createdAt: host.clock.now(),
|
|
34
|
+
resolve: (value) => settle(value),
|
|
35
|
+
reject: (error) => fail(error),
|
|
36
|
+
meta: { questions: request.questions },
|
|
37
|
+
});
|
|
38
|
+
host.logger.info(`interactive: question request id=${entry.id} questions=${request.questions.length} scope=${context.scopeKey}`);
|
|
39
|
+
const rendered = questionCard({
|
|
40
|
+
requestId: entry.id,
|
|
41
|
+
sessionId: context.sessionId,
|
|
42
|
+
questions: request.questions,
|
|
43
|
+
});
|
|
44
|
+
let timer;
|
|
45
|
+
const onAbort = () => {
|
|
46
|
+
if (host.pending.delete(entry.id) === undefined)
|
|
47
|
+
return;
|
|
48
|
+
void host
|
|
49
|
+
.updateCard(entry.cardMessageId ?? '', questionTerminalCard(request.questions[0]?.question ?? '问题', '已取消'))
|
|
50
|
+
.catch(() => undefined);
|
|
51
|
+
fail(new Error('user question cancelled'));
|
|
52
|
+
};
|
|
53
|
+
const cleanup = () => {
|
|
54
|
+
if (timer !== undefined)
|
|
55
|
+
host.clock.clearTimeout(timer);
|
|
56
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
57
|
+
};
|
|
58
|
+
entry.cleanup = cleanup;
|
|
59
|
+
host.logger.info(`interactive: question card sending chat=${logPrefix(context.chatId)}`);
|
|
60
|
+
try {
|
|
61
|
+
const sent = await host.sendCard(context.sessionId, rendered.card);
|
|
62
|
+
entry.cardMessageId = sent.messageId;
|
|
63
|
+
host.logger.info(`interactive: question card sent messageId=${logPrefix(entry.cardMessageId)} thread=${logPrefix(sent.threadId ?? '-', 8)}`);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
host.pending.delete(entry.id);
|
|
67
|
+
host.logger.error(`interactive: question card send failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
68
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
69
|
+
}
|
|
70
|
+
timer = host.clock.setTimeout(() => {
|
|
71
|
+
if (host.pending.delete(entry.id) === undefined)
|
|
72
|
+
return;
|
|
73
|
+
host.logger.info(`interactive: question timed out after ${host.config.timeoutMs}ms`);
|
|
74
|
+
void host
|
|
75
|
+
.updateCard(entry.cardMessageId ?? '', questionTerminalCard(request.questions[0]?.question ?? '问题', '已过期'))
|
|
76
|
+
.catch(() => undefined);
|
|
77
|
+
fail(new Error('user question timed out'));
|
|
78
|
+
}, host.config.timeoutMs);
|
|
79
|
+
if (request.signal?.aborted === true) {
|
|
80
|
+
onAbort();
|
|
81
|
+
return answer;
|
|
82
|
+
}
|
|
83
|
+
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
84
|
+
return answer;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime that owns the pending registry, sends/patch cards, routes
|
|
3
|
+
* `card.action.trigger` clicks, and installs the two agent-scoped answerers.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-caduceus/interactive/runtime
|
|
6
|
+
*/
|
|
7
|
+
import type { LoggerLike } from '../logger.js';
|
|
8
|
+
import { type Clock } from '../render/stream.js';
|
|
9
|
+
import type { CardAction, InteractiveConfig, InteractiveContext } from './types.js';
|
|
10
|
+
import { PendingRegistry } from './pending.js';
|
|
11
|
+
import type { FeishuCard } from '../transport/types.js';
|
|
12
|
+
export interface InteractiveRuntimeOptions {
|
|
13
|
+
readonly config: InteractiveConfig;
|
|
14
|
+
readonly logger?: LoggerLike;
|
|
15
|
+
readonly clock?: Clock;
|
|
16
|
+
sendCard(sessionId: string, card: FeishuCard): Promise<{
|
|
17
|
+
readonly messageId: string;
|
|
18
|
+
readonly threadId?: string;
|
|
19
|
+
}>;
|
|
20
|
+
updateCard(messageId: string, card: FeishuCard): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export declare class InteractiveRuntime {
|
|
23
|
+
#private;
|
|
24
|
+
readonly config: InteractiveConfig;
|
|
25
|
+
readonly logger: LoggerLike;
|
|
26
|
+
readonly clock: Clock;
|
|
27
|
+
readonly pending: PendingRegistry;
|
|
28
|
+
constructor(options: InteractiveRuntimeOptions);
|
|
29
|
+
/**
|
|
30
|
+
* Register both waterfall answerers ONCE on the plugin root context, as dsh's
|
|
31
|
+
* own ACP bridge does. Ownership is decided per request from
|
|
32
|
+
* `request.agent.session.id`; requests for other agents are delegated by
|
|
33
|
+
* calling `next()`, never claimed.
|
|
34
|
+
*/
|
|
35
|
+
registerRoot(rootCtx: unknown, resolve: {
|
|
36
|
+
ownsSession(sessionId: string): boolean;
|
|
37
|
+
contextFor(sessionId: string): InteractiveContext | undefined;
|
|
38
|
+
}): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Route one card click. Unknown ids are ignored; non-owners are ignored with
|
|
41
|
+
* a warning and do not resolve or patch anything.
|
|
42
|
+
*/
|
|
43
|
+
handleCardAction(action: CardAction): Promise<void>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { silentLogger } from '../logger.js';
|
|
2
|
+
import { systemClock } from '../render/stream.js';
|
|
3
|
+
import { describeScope, isEventScope, logPrefix } from './types.js';
|
|
4
|
+
import { createApprovalAnswerer } from './approval.js';
|
|
5
|
+
import { PendingRegistry } from './pending.js';
|
|
6
|
+
import { createQuestionAnswerer } from './question.js';
|
|
7
|
+
import { approvalTerminalCard, questionCard, questionTerminalCard } from './cards.js';
|
|
8
|
+
export class InteractiveRuntime {
|
|
9
|
+
config;
|
|
10
|
+
logger;
|
|
11
|
+
clock;
|
|
12
|
+
pending = new PendingRegistry();
|
|
13
|
+
#sendCard;
|
|
14
|
+
#updateCard;
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.config = options.config;
|
|
17
|
+
this.logger = options.logger ?? silentLogger();
|
|
18
|
+
this.clock = options.clock ?? systemClock;
|
|
19
|
+
this.#sendCard = options.sendCard;
|
|
20
|
+
this.#updateCard = options.updateCard;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Register both waterfall answerers ONCE on the plugin root context, as dsh's
|
|
24
|
+
* own ACP bridge does. Ownership is decided per request from
|
|
25
|
+
* `request.agent.session.id`; requests for other agents are delegated by
|
|
26
|
+
* calling `next()`, never claimed.
|
|
27
|
+
*/
|
|
28
|
+
registerRoot(rootCtx, resolve) {
|
|
29
|
+
if (!isEventScope(rootCtx)) {
|
|
30
|
+
this.logger.warn(`interactive: register skipped (not an event scope: ${describeScope(rootCtx)})`);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
rootCtx.on('approval/request', async (request, next) => {
|
|
34
|
+
const sessionId = sessionIdOf(request);
|
|
35
|
+
if (sessionId === undefined || !resolve.ownsSession(sessionId)) {
|
|
36
|
+
this.logger.info(`interactive: approval request agent=${sessionId === undefined ? '?' : logPrefix(sessionId)} not ours, delegated`);
|
|
37
|
+
return next();
|
|
38
|
+
}
|
|
39
|
+
this.logger.info(`interactive: approval request agent=${logPrefix(sessionId)} claimed`);
|
|
40
|
+
const context = resolve.contextFor(sessionId);
|
|
41
|
+
return createApprovalAnswerer(this.#host(), () => context)(request, next);
|
|
42
|
+
});
|
|
43
|
+
rootCtx.on('user-questions/request', async (request, next) => {
|
|
44
|
+
const sessionId = sessionIdOf(request);
|
|
45
|
+
if (sessionId === undefined || !resolve.ownsSession(sessionId)) {
|
|
46
|
+
this.logger.info(`interactive: question request agent=${sessionId === undefined ? '?' : logPrefix(sessionId)} not ours, delegated`);
|
|
47
|
+
return next();
|
|
48
|
+
}
|
|
49
|
+
this.logger.info(`interactive: question request agent=${logPrefix(sessionId)} claimed`);
|
|
50
|
+
const context = resolve.contextFor(sessionId);
|
|
51
|
+
return createQuestionAnswerer(this.#host(), () => context)(request, next);
|
|
52
|
+
});
|
|
53
|
+
this.logger.info('interactive: answerers registered (root scope)');
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Route one card click. Unknown ids are ignored; non-owners are ignored with
|
|
58
|
+
* a warning and do not resolve or patch anything.
|
|
59
|
+
*/
|
|
60
|
+
async handleCardAction(action) {
|
|
61
|
+
const id = typeof action.value['id'] === 'string' ? action.value['id'] : undefined;
|
|
62
|
+
if (id === undefined) {
|
|
63
|
+
this.logger.info('card action ignored (unknown id)');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.logger.info(`card action id=${id} kind=${String(action.value['kind'] ?? '?')} operator=${logPrefix(action.operatorOpenId)}`);
|
|
67
|
+
const entry = this.pending.get(id);
|
|
68
|
+
if (entry === undefined) {
|
|
69
|
+
this.logger.info('card action ignored (unknown id)');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (action.operatorOpenId !== entry.ownerOpenId) {
|
|
73
|
+
this.logger.warn(`ignored card action from non-owner ${action.operatorOpenId} for ${entry.kind} ${id}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (entry.kind === 'question' && (action.formValue !== undefined || action.value['form'] === true)) {
|
|
77
|
+
const questions = entry.meta?.['questions'];
|
|
78
|
+
const question = questions?.[0]?.question ?? '问题';
|
|
79
|
+
const questionId = typeof action.value['questionId'] === 'string'
|
|
80
|
+
? action.value['questionId']
|
|
81
|
+
: (questions?.[0]?.id ?? '');
|
|
82
|
+
const custom = (action.formValue?.['other'] ?? '').trim();
|
|
83
|
+
if (custom.length === 0) {
|
|
84
|
+
// Keep the pending entry and its timer: prompt again on the same card.
|
|
85
|
+
const rebuilt = questionCard({
|
|
86
|
+
requestId: entry.id,
|
|
87
|
+
sessionId: entry.sessionId,
|
|
88
|
+
...(questions === undefined ? { questions: [] } : { questions }),
|
|
89
|
+
hint: '请填写后再提交',
|
|
90
|
+
});
|
|
91
|
+
await this.#patchTerminal(entry, rebuilt.card);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.pending.delete(id);
|
|
95
|
+
await this.#patchTerminal(entry, questionTerminalCard(question, `已填写:${custom.slice(0, 40)}`));
|
|
96
|
+
entry.resolve({ answers: [{ id: questionId, selected: [], custom }] });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
this.pending.delete(id);
|
|
100
|
+
if (entry.kind === 'approval') {
|
|
101
|
+
const choice = action.value['choice'] === 'allow' ? 'allow' : 'reject';
|
|
102
|
+
const outcome = choice === 'allow' ? 'allowed-once' : 'rejected';
|
|
103
|
+
await this.#patchTerminal(entry, approvalTerminalCard(String(entry.meta?.['toolName'] ?? 'tool'), outcome === 'allowed-once' ? '已允许' : '已拒绝'));
|
|
104
|
+
entry.resolve(outcome);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const label = typeof action.value['label'] === 'string' ? action.value['label'] : '';
|
|
108
|
+
const questionId = typeof action.value['questionId'] === 'string' ? action.value['questionId'] : '';
|
|
109
|
+
const question = entry.meta?.['questions']?.[0]
|
|
110
|
+
?.question ?? '问题';
|
|
111
|
+
await this.#patchTerminal(entry, questionTerminalCard(question, `已选择:${label}`));
|
|
112
|
+
entry.resolve({ answers: [{ id: questionId, selected: [label] }] });
|
|
113
|
+
}
|
|
114
|
+
async #patchTerminal(entry, card) {
|
|
115
|
+
if (entry.cardMessageId === undefined)
|
|
116
|
+
return;
|
|
117
|
+
try {
|
|
118
|
+
await this.#updateCard(entry.cardMessageId, card);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
this.logger.warn(`interactive card patch failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
#host() {
|
|
125
|
+
return {
|
|
126
|
+
config: this.config,
|
|
127
|
+
logger: this.logger,
|
|
128
|
+
clock: this.clock,
|
|
129
|
+
pending: this.pending,
|
|
130
|
+
sendCard: (sessionId, card) => this.#sendCard(sessionId, card),
|
|
131
|
+
updateCard: (messageId, card) => this.#updateCard(messageId, card),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Extract `request.agent.session.id` structurally (dsh waterfall payloads). */
|
|
136
|
+
function sessionIdOf(request) {
|
|
137
|
+
if (typeof request !== 'object' || request === null)
|
|
138
|
+
return undefined;
|
|
139
|
+
const agent = request.agent;
|
|
140
|
+
if (typeof agent !== 'object' || agent === null)
|
|
141
|
+
return undefined;
|
|
142
|
+
const session = agent.session;
|
|
143
|
+
if (typeof session !== 'object' || session === null)
|
|
144
|
+
return undefined;
|
|
145
|
+
const id = session.id;
|
|
146
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined;
|
|
147
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared seams for caduceus interactive cards (approval + user questions).
|
|
3
|
+
*
|
|
4
|
+
* The dsh services and the Cordis waterfall are adapted structurally so unit
|
|
5
|
+
* tests never import dsh internals; the gateway wires the real transport and
|
|
6
|
+
* agent scope at the edge.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-caduceus/interactive/types
|
|
9
|
+
*/
|
|
10
|
+
import type { LoggerLike } from '../logger.js';
|
|
11
|
+
import type { Clock } from '../render/stream.js';
|
|
12
|
+
import type { CardAction, FeishuCard } from '../transport/types.js';
|
|
13
|
+
export type { CardAction };
|
|
14
|
+
import type { PendingRegistry } from './pending.js';
|
|
15
|
+
export interface InteractiveConfig {
|
|
16
|
+
/** Send approval cards (default true). */
|
|
17
|
+
readonly approvalCard: boolean;
|
|
18
|
+
/** Send question cards (default true). */
|
|
19
|
+
readonly questionCard: boolean;
|
|
20
|
+
/** How long a card waits before it expires (ms, default 300000). */
|
|
21
|
+
readonly timeoutMs: number;
|
|
22
|
+
}
|
|
23
|
+
/** Where the card belongs and who may click it. */
|
|
24
|
+
export interface InteractiveContext {
|
|
25
|
+
readonly scopeKey: string;
|
|
26
|
+
readonly chatId: string;
|
|
27
|
+
readonly ownerOpenId: string;
|
|
28
|
+
readonly sessionId: string;
|
|
29
|
+
}
|
|
30
|
+
/** A Cordis context (the plugin root or an agent scope); only `on` is needed. */
|
|
31
|
+
export interface EventScopeLike {
|
|
32
|
+
on(name: string, listener: (...args: any[]) => any): unknown;
|
|
33
|
+
}
|
|
34
|
+
export declare function logPrefix(value: string, max?: number): string;
|
|
35
|
+
/** Short, safe description of a value for the attach-skipped diagnostic. */
|
|
36
|
+
export declare function describeScope(value: unknown): string;
|
|
37
|
+
export declare function isEventScope(value: unknown): value is EventScopeLike;
|
|
38
|
+
/** What the approval/question answerers need from the runtime. */
|
|
39
|
+
export interface InteractiveHost {
|
|
40
|
+
readonly config: InteractiveConfig;
|
|
41
|
+
readonly logger: LoggerLike;
|
|
42
|
+
readonly clock: Clock;
|
|
43
|
+
readonly pending: PendingRegistry;
|
|
44
|
+
sendCard(sessionId: string, card: FeishuCard): Promise<{
|
|
45
|
+
readonly messageId: string;
|
|
46
|
+
readonly threadId?: string;
|
|
47
|
+
}>;
|
|
48
|
+
updateCard(messageId: string, card: FeishuCard): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
/** CardAction is defined by the transport seam. */
|