@soimy/dingtalk 3.3.0 → 3.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.
Files changed (36) hide show
  1. package/README.md +141 -12
  2. package/index.ts +71 -66
  3. package/package.json +6 -5
  4. package/src/access-control.ts +65 -0
  5. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  8. package/src/ack-reaction-classifier.ts +17 -4
  9. package/src/ack-reaction-service.ts +66 -19
  10. package/src/attachment-text-extractor.ts +2 -1
  11. package/src/card-service.ts +145 -257
  12. package/src/channel.ts +106 -47
  13. package/src/config-schema.ts +28 -6
  14. package/src/config.ts +30 -6
  15. package/src/connection-manager.ts +16 -5
  16. package/src/inbound-handler.ts +694 -520
  17. package/src/media-utils.ts +99 -36
  18. package/src/message-context-store.ts +787 -0
  19. package/src/message-utils.ts +221 -42
  20. package/src/messaging/quoted-context.ts +269 -0
  21. package/src/messaging/quoted-ref.ts +97 -0
  22. package/src/onboarding.ts +381 -269
  23. package/src/reply-strategy-card.ts +225 -0
  24. package/src/reply-strategy-markdown.ts +55 -0
  25. package/src/reply-strategy-with-reaction.ts +190 -0
  26. package/src/reply-strategy.ts +72 -0
  27. package/src/runtime.ts +5 -7
  28. package/src/send-service.ts +164 -62
  29. package/src/targeting/agent-name-matcher.ts +148 -0
  30. package/src/targeting/agent-routing.ts +181 -0
  31. package/src/targeting/target-directory-adapter.ts +152 -0
  32. package/src/targeting/target-directory-store.ts +396 -0
  33. package/src/targeting/target-input.ts +62 -0
  34. package/src/types.ts +124 -21
  35. package/src/quote-journal.ts +0 -242
  36. package/src/quoted-msg-cache.ts +0 -226
@@ -0,0 +1,225 @@
1
+ /**
2
+ * AI Card reply strategy.
3
+ *
4
+ * Encapsulates the card draft controller lifecycle, deliver routing
5
+ * (final / tool / block), finalization, and failure fallback so that
6
+ * inbound-handler only coordinates — it no longer owns card state.
7
+ */
8
+
9
+ import {
10
+ finishAICard,
11
+ formatContentForCard,
12
+ isCardInTerminalState,
13
+ } from "./card-service";
14
+ import { createCardDraftController } from "./card-draft-controller";
15
+ import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
16
+ import { sendBySession, sendMessage } from "./send-service";
17
+ import type { AICardInstance } from "./types";
18
+ import { AICardStatus } from "./types";
19
+ import { formatDingTalkErrorPayloadLog } from "./utils";
20
+
21
+ export function createCardReplyStrategy(
22
+ ctx: ReplyStrategyContext & { card: AICardInstance },
23
+ ): ReplyStrategy {
24
+ const { card, config, log } = ctx;
25
+
26
+ const controller = createCardDraftController({ card, log });
27
+ let finalTextForFallback: string | undefined;
28
+
29
+ return {
30
+ getReplyOptions(): ReplyOptions {
31
+ return {
32
+ // Card mode: intermediate blocks are unused — card updates go through
33
+ // onPartialReply (real-time) or deliver(final) -> finishAICard.
34
+ disableBlockStreaming: true,
35
+
36
+ onAssistantMessageStart: () => {
37
+ controller.notifyNewAssistantTurn();
38
+ },
39
+
40
+ onPartialReply: config.cardRealTimeStream
41
+ ? (payload) => {
42
+ if (payload.text) {
43
+ controller.updateAnswer(payload.text);
44
+ }
45
+ }
46
+ : undefined,
47
+
48
+ onReasoningStream: (payload) => {
49
+ if (payload.text) {
50
+ controller.updateReasoning(payload.text);
51
+ }
52
+ },
53
+ };
54
+ },
55
+
56
+ async deliver(payload: DeliverPayload): Promise<void> {
57
+ const textToSend = payload.text;
58
+
59
+ // Empty-payload guard — card final is an exception (e.g. file-only response).
60
+ if ((typeof textToSend !== "string" || textToSend.length === 0) && payload.mediaUrls.length === 0) {
61
+ if (payload.kind !== "final") {
62
+ return;
63
+ }
64
+ }
65
+
66
+ // ---- final: defer to finalize, just save text ----
67
+ if (payload.kind === "final") {
68
+ log?.info?.(
69
+ `[DingTalk][Finalize] deliver(final) received — cardState=${card.state} ` +
70
+ `textLen=${typeof textToSend === "string" ? textToSend.length : "null"} ` +
71
+ `mediaUrls=${payload.mediaUrls.length} ` +
72
+ `lastAnswer="${(controller.getLastAnswerContent() ?? "").slice(0, 80)}" ` +
73
+ `lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
74
+ );
75
+ if (payload.mediaUrls.length > 0) {
76
+ await ctx.deliverMedia(payload.mediaUrls);
77
+ }
78
+ const rawFinalText = typeof textToSend === "string" ? textToSend : "";
79
+ if (rawFinalText) {
80
+ finalTextForFallback = rawFinalText;
81
+ }
82
+ return;
83
+ }
84
+
85
+ // ---- tool: append to card ----
86
+ if (payload.kind === "tool") {
87
+ if (controller.isFailed() || isCardInTerminalState(card.state)) {
88
+ log?.debug?.("[DingTalk] Card failed, skipping tool result (will send full reply on final)");
89
+ return;
90
+ }
91
+ await controller.flush();
92
+ await controller.waitForInFlight();
93
+ log?.info?.(
94
+ `[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
95
+ );
96
+ const toolText = typeof textToSend === "string" ? formatContentForCard(textToSend, "tool") : "";
97
+ if (toolText) {
98
+ const sendResult = await sendMessage(ctx.config, ctx.to, toolText, {
99
+ sessionWebhook: ctx.sessionWebhook,
100
+ atUserId: !ctx.isDirect ? ctx.senderId : null,
101
+ log,
102
+ card,
103
+ accountId: ctx.accountId,
104
+ storePath: ctx.storePath,
105
+ conversationId: ctx.groupId,
106
+ cardUpdateMode: "append",
107
+ });
108
+ if (!sendResult.ok) {
109
+ throw new Error(sendResult.error || "Tool stream send failed");
110
+ }
111
+ }
112
+ return;
113
+ }
114
+
115
+ // ---- block: only handle media (text blocks are unused) ----
116
+ if (payload.mediaUrls.length > 0) {
117
+ await ctx.deliverMedia(payload.mediaUrls);
118
+ }
119
+ },
120
+
121
+ async finalize(): Promise<void> {
122
+ log?.info?.(
123
+ `[DingTalk][Finalize] Step 5 entry — ` +
124
+ `cardState=${card.state ?? "N/A"} ` +
125
+ `controllerFailed=${controller.isFailed()} ` +
126
+ `finalTextForFallback="${(finalTextForFallback ?? "").slice(0, 80)}" ` +
127
+ `lastAnswer="${(controller.getLastAnswerContent() ?? "").slice(0, 80)}" ` +
128
+ `lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
129
+ );
130
+
131
+ if (card.state === AICardStatus.FINISHED) {
132
+ log?.info?.("[DingTalk][Finalize] Skipping — card already FINISHED");
133
+ return;
134
+ }
135
+
136
+ // Card failed -> markdown fallback (bypass sendMessage to avoid duplicate card).
137
+ if (card.state === AICardStatus.FAILED || controller.isFailed()) {
138
+ const fallbackText = finalTextForFallback
139
+ || controller.getLastAnswerContent()
140
+ || controller.getLastContent()
141
+ || card.lastStreamedContent;
142
+ if (fallbackText) {
143
+ log?.debug?.("[DingTalk] Card failed during streaming, sending markdown fallback");
144
+ const sendResult = await sendMessage(ctx.config, ctx.to, fallbackText, {
145
+ sessionWebhook: ctx.sessionWebhook,
146
+ atUserId: !ctx.isDirect ? ctx.senderId : null,
147
+ log,
148
+ accountId: ctx.accountId,
149
+ storePath: ctx.storePath,
150
+ conversationId: ctx.groupId,
151
+ quotedRef: ctx.replyQuotedRef,
152
+ forceMarkdown: true,
153
+ });
154
+ if (!sendResult.ok) {
155
+ throw new Error(sendResult.error || "Markdown fallback send failed after card failure");
156
+ }
157
+ } else {
158
+ log?.debug?.("[DingTalk] Card failed but no content to fallback with");
159
+ }
160
+ return;
161
+ }
162
+
163
+ // Normal finalize.
164
+ try {
165
+ await controller.flush();
166
+ await controller.waitForInFlight();
167
+ controller.stop();
168
+ const finalText = controller.getLastAnswerContent()
169
+ || finalTextForFallback
170
+ || "✅ Done";
171
+ log?.info?.(
172
+ `[DingTalk][Finalize] Calling finishAICard — finalTextLen=${finalText.length} ` +
173
+ `source=${controller.getLastAnswerContent() ? "lastAnswerContent" : finalTextForFallback ? "finalTextForFallback" : "fallbackDone"} ` +
174
+ `preview="${finalText.slice(0, 120)}"`,
175
+ );
176
+ await finishAICard(card, finalText, log, {
177
+ quotedRef: ctx.replyQuotedRef,
178
+ });
179
+
180
+ // In group chats, send a lightweight @mention via session webhook
181
+ // so the sender gets a notification — card API doesn't support @mention.
182
+ const cardAtSenderText = (ctx.config.cardAtSender || "").trim();
183
+ if (!ctx.isDirect && ctx.senderId && ctx.sessionWebhook && cardAtSenderText) {
184
+ try {
185
+ await sendBySession(ctx.config, ctx.sessionWebhook, cardAtSenderText, {
186
+ atUserId: ctx.senderId,
187
+ log,
188
+ });
189
+ } catch (atErr: unknown) {
190
+ const msg = atErr instanceof Error ? atErr.message : String(atErr);
191
+ log?.debug?.(`[DingTalk] Post-card @mention send failed: ${msg}`);
192
+ }
193
+ }
194
+ } catch (err: unknown) {
195
+ log?.debug?.(`[DingTalk] AI Card finalization failed: ${(err as Error).message}`);
196
+ const errObj = err as { response?: { data?: unknown } };
197
+ if (errObj?.response?.data !== undefined) {
198
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.cardFinalize", errObj.response.data));
199
+ }
200
+ if ((card.state as string) !== AICardStatus.FINISHED) {
201
+ card.state = AICardStatus.FAILED;
202
+ card.lastUpdated = Date.now();
203
+ }
204
+ }
205
+ },
206
+
207
+ async abort(_error: Error): Promise<void> {
208
+ if (!isCardInTerminalState(card.state)) {
209
+ controller.stop();
210
+ await controller.waitForInFlight();
211
+ try {
212
+ await finishAICard(card, "❌ 处理失败", log);
213
+ } catch (cardCloseErr: unknown) {
214
+ log?.debug?.(`[DingTalk] Failed to finalize card after dispatch error: ${(cardCloseErr as Error).message}`);
215
+ card.state = AICardStatus.FAILED;
216
+ card.lastUpdated = Date.now();
217
+ }
218
+ }
219
+ },
220
+
221
+ getFinalText(): string | undefined {
222
+ return controller.getLastAnswerContent() || finalTextForFallback;
223
+ },
224
+ };
225
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Markdown / text reply strategy.
3
+ *
4
+ * Buffers all blocks (disableBlockStreaming=true) and delivers the
5
+ * final text as a single message via sendMessage.
6
+ */
7
+
8
+ import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
9
+ import { sendMessage } from "./send-service";
10
+
11
+ export function createMarkdownReplyStrategy(
12
+ ctx: ReplyStrategyContext,
13
+ ): ReplyStrategy {
14
+ let finalText: string | undefined;
15
+
16
+ return {
17
+ getReplyOptions(): ReplyOptions {
18
+ return { disableBlockStreaming: true };
19
+ },
20
+
21
+ async deliver(payload: DeliverPayload): Promise<void> {
22
+ if (payload.mediaUrls.length > 0) {
23
+ await ctx.deliverMedia(payload.mediaUrls);
24
+ }
25
+
26
+ if (payload.kind === "final" && typeof payload.text === "string" && payload.text.length > 0) {
27
+ finalText = payload.text;
28
+ const sendResult = await sendMessage(ctx.config, ctx.to, payload.text, {
29
+ sessionWebhook: ctx.sessionWebhook,
30
+ atUserId: !ctx.isDirect ? ctx.senderId : null,
31
+ log: ctx.log,
32
+ accountId: ctx.accountId,
33
+ storePath: ctx.storePath,
34
+ conversationId: ctx.groupId,
35
+ quotedRef: ctx.replyQuotedRef,
36
+ });
37
+ if (!sendResult.ok) {
38
+ throw new Error(sendResult.error || "Reply send failed");
39
+ }
40
+ }
41
+ },
42
+
43
+ async finalize(): Promise<void> {
44
+ // Markdown mode: delivery already happened in deliver(final).
45
+ },
46
+
47
+ async abort(): Promise<void> {
48
+ // Nothing to clean up.
49
+ },
50
+
51
+ getFinalText(): string | undefined {
52
+ return finalText;
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Dynamic ack-reaction decorator for ReplyStrategy.
3
+ *
4
+ * When ackReaction is set to "emoji", wraps an inner strategy to switch
5
+ * the native ack-reaction emoji based on runtime tool execution events.
6
+ *
7
+ * Usage:
8
+ * let strategy = createReplyStrategy({ ... });
9
+ * if (ackReaction === "emoji" && runtimeEvents) {
10
+ * strategy = withDynamicReaction(strategy, { ... });
11
+ * }
12
+ *
13
+ * This decorator:
14
+ * - Subscribes to rt.events.onAgentEvent before dispatch
15
+ * - Maps tool_execution_start events to emoji via resolveToolReactionEmoji
16
+ * - Recalls the previous reaction and attaches the new one
17
+ * - Fires a heartbeat reaction if no tool event arrives for 55+ seconds
18
+ * - On dispose: recalls the current reaction and notifies the caller via
19
+ * onReactionDisposed so the finally block does not double-recall
20
+ * - Cleans up (unsubscribe + clear timer) on finalize/abort
21
+ *
22
+ * Important: the subscription MUST filter by runId or sessionKey to avoid
23
+ * cross-session reaction contamination when multiple messages are processed
24
+ * concurrently. The `sessionFilter` parameter is required for this reason.
25
+ */
26
+
27
+ import type { DeliverPayload, ReplyOptions, ReplyStrategy } from "./reply-strategy";
28
+ import type { DingTalkConfig, Logger } from "./types";
29
+
30
+ const TOOL_REACTION_SILENCE_MS = 55_000;
31
+ const TOOL_HEARTBEAT_INTERVAL_MS = 60_000;
32
+ const TOOL_HEARTBEAT_REACTION = "⏳";
33
+
34
+ export interface DynamicReactionParams {
35
+ config: DingTalkConfig;
36
+ msgId: string;
37
+ conversationId?: string;
38
+ initialReaction: string;
39
+ /** Only process events matching this predicate (session isolation). */
40
+ sessionFilter: (event: unknown) => boolean;
41
+ onAttachReaction: (reactionName: string) => Promise<boolean>;
42
+ onRecallReaction: (reactionName: string) => Promise<void>;
43
+ subscribeAgentEvents: (listener: (event: unknown) => void) => () => void;
44
+ /**
45
+ * Called when the decorator recalls its current reaction during dispose.
46
+ * The caller should set ackReactionAttached=false so the finally block
47
+ * does not attempt to recall the same (already-removed) reaction.
48
+ */
49
+ onReactionDisposed?: () => void;
50
+ log?: Logger;
51
+ }
52
+
53
+ function resolveToolReactionEmoji(toolName: unknown): string {
54
+ const name = typeof toolName === "string" ? toolName.trim().toLowerCase() : "";
55
+ switch (name) {
56
+ case "bash":
57
+ case "exec":
58
+ case "process":
59
+ return "🛠️";
60
+ case "read":
61
+ case "view":
62
+ return "📂";
63
+ case "write":
64
+ case "edit":
65
+ case "patch":
66
+ return "✍️";
67
+ case "web_search":
68
+ case "search":
69
+ case "browser.search":
70
+ case "browser_search":
71
+ return "🌐";
72
+ case "fetch":
73
+ case "open":
74
+ case "open_url":
75
+ case "browser.open":
76
+ case "browser_open":
77
+ return "🔗";
78
+ default:
79
+ return "🛠️";
80
+ }
81
+ }
82
+
83
+ export function withDynamicReaction(
84
+ inner: ReplyStrategy,
85
+ params: DynamicReactionParams,
86
+ ): ReplyStrategy {
87
+ const { log } = params;
88
+ let currentReaction = params.initialReaction;
89
+ let reactionChanged = false;
90
+ let disposed = false;
91
+ let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
92
+ let lastEventAt = 0;
93
+ let updateChain: Promise<void> = Promise.resolve();
94
+
95
+ const switchReaction = async (nextReaction: string) => {
96
+ if (disposed || nextReaction === currentReaction) {
97
+ return;
98
+ }
99
+ const prev = currentReaction;
100
+ await params.onRecallReaction(prev);
101
+ const ok = await params.onAttachReaction(nextReaction);
102
+ if (ok) {
103
+ currentReaction = nextReaction;
104
+ reactionChanged = true;
105
+ lastEventAt = Date.now();
106
+ }
107
+ };
108
+
109
+ const queueSwitch = (nextReaction: string) => {
110
+ updateChain = updateChain
111
+ .then(() => switchReaction(nextReaction))
112
+ .catch((err: unknown) => {
113
+ log?.warn?.(`[DingTalk] Dynamic reaction update failed: ${(err as Error).message}`);
114
+ });
115
+ };
116
+
117
+ const handleEvent = (event: unknown) => {
118
+ if (disposed) {
119
+ return;
120
+ }
121
+ if (!params.sessionFilter(event)) {
122
+ return;
123
+ }
124
+ const toolEvent = event as { stream?: string; data?: { phase?: string; name?: string } };
125
+ if (toolEvent?.stream !== "tool" || toolEvent?.data?.phase !== "start") {
126
+ return;
127
+ }
128
+ lastEventAt = Date.now();
129
+ queueSwitch(resolveToolReactionEmoji(toolEvent.data?.name));
130
+ };
131
+
132
+ const unsubscribe = params.subscribeAgentEvents(handleEvent);
133
+
134
+ heartbeatTimer = setInterval(() => {
135
+ if (disposed || lastEventAt === 0) {
136
+ return;
137
+ }
138
+ if (Date.now() - lastEventAt >= TOOL_REACTION_SILENCE_MS) {
139
+ queueSwitch(TOOL_HEARTBEAT_REACTION);
140
+ }
141
+ }, TOOL_HEARTBEAT_INTERVAL_MS);
142
+
143
+ const dispose = async () => {
144
+ if (disposed) {
145
+ return;
146
+ }
147
+ disposed = true;
148
+ unsubscribe();
149
+ if (heartbeatTimer) {
150
+ clearInterval(heartbeatTimer);
151
+ heartbeatTimer = undefined;
152
+ }
153
+ // Wait for any in-flight reaction update to finish.
154
+ await updateChain.catch(() => undefined);
155
+ // Recall whatever reaction is currently displayed, then notify the
156
+ // caller so the finally block does not double-recall.
157
+ if (reactionChanged) {
158
+ try {
159
+ await params.onRecallReaction(currentReaction);
160
+ params.onReactionDisposed?.();
161
+ } catch (err: unknown) {
162
+ log?.warn?.(`[DingTalk] Failed to recall reaction on dispose: ${(err as Error).message}`);
163
+ }
164
+ }
165
+ };
166
+
167
+ return {
168
+ getReplyOptions(): ReplyOptions {
169
+ return inner.getReplyOptions();
170
+ },
171
+
172
+ async deliver(payload: DeliverPayload): Promise<void> {
173
+ await inner.deliver(payload);
174
+ },
175
+
176
+ async finalize(): Promise<void> {
177
+ await dispose();
178
+ await inner.finalize();
179
+ },
180
+
181
+ async abort(_error: Error): Promise<void> {
182
+ await dispose();
183
+ await inner.abort(_error);
184
+ },
185
+
186
+ getFinalText(): string | undefined {
187
+ return inner.getFinalText();
188
+ },
189
+ };
190
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Reply strategy interface for DingTalk message delivery.
3
+ *
4
+ * Abstracts the "how to deliver a reply" concern away from
5
+ * handleDingTalkMessage, so card and markdown modes each manage
6
+ * their own state and lifecycle independently.
7
+ */
8
+
9
+ import type { AICardInstance, DingTalkConfig, Logger, QuotedRef } from "./types";
10
+ import { createCardReplyStrategy } from "./reply-strategy-card";
11
+ import { createMarkdownReplyStrategy } from "./reply-strategy-markdown";
12
+
13
+ // ---- Public types ------------------------------------------------
14
+
15
+ export interface DeliverPayload {
16
+ text?: string;
17
+ mediaUrls: string[];
18
+ kind: "block" | "final" | "tool";
19
+ }
20
+
21
+ export interface ReplyOptions {
22
+ disableBlockStreaming: boolean;
23
+ onPartialReply?: (payload: { text?: string }) => void;
24
+ onReasoningStream?: (payload: { text?: string }) => void;
25
+ onAssistantMessageStart?: () => void;
26
+ }
27
+
28
+ export interface ReplyStrategy {
29
+ /** Options forwarded to the runtime dispatcher. */
30
+ getReplyOptions(): ReplyOptions;
31
+
32
+ /** Called by the deliver callback for each payload chunk. */
33
+ deliver(payload: DeliverPayload): Promise<void>;
34
+
35
+ /** Called after dispatch completes successfully. */
36
+ finalize(): Promise<void>;
37
+
38
+ /** Called when dispatch throws an error. */
39
+ abort(error: Error): Promise<void>;
40
+
41
+ /** Last known final text (for external consumers such as logging). */
42
+ getFinalText(): string | undefined;
43
+ }
44
+
45
+ /** Shared context passed to every strategy implementation. */
46
+ export interface ReplyStrategyContext {
47
+ config: DingTalkConfig;
48
+ to: string;
49
+ sessionWebhook: string;
50
+ senderId: string;
51
+ isDirect: boolean;
52
+ accountId: string;
53
+ storePath: string;
54
+ groupId?: string;
55
+ log?: Logger;
56
+ replyQuotedRef?: QuotedRef;
57
+ deliverMedia: (urls: string[]) => Promise<void>;
58
+ }
59
+
60
+ // ---- Factory -----------------------------------------------------
61
+
62
+ export function createReplyStrategy(
63
+ params: ReplyStrategyContext & {
64
+ card: AICardInstance | undefined;
65
+ useCardMode: boolean;
66
+ },
67
+ ): ReplyStrategy {
68
+ if (params.useCardMode && params.card) {
69
+ return createCardReplyStrategy({ ...params, card: params.card });
70
+ }
71
+ return createMarkdownReplyStrategy(params);
72
+ }
package/src/runtime.ts CHANGED
@@ -1,14 +1,12 @@
1
- import type { PluginRuntime } from "openclaw/plugin-sdk";
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
2
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
2
3
 
3
- let runtime: PluginRuntime | null = null;
4
+ const runtimeStore = createPluginRuntimeStore<PluginRuntime>("DingTalk runtime not initialized");
4
5
 
5
6
  export function setDingTalkRuntime(next: PluginRuntime): void {
6
- runtime = next;
7
+ runtimeStore.setRuntime(next);
7
8
  }
8
9
 
9
10
  export function getDingTalkRuntime(): PluginRuntime {
10
- if (!runtime) {
11
- throw new Error("DingTalk runtime not initialized");
12
- }
13
- return runtime;
11
+ return runtimeStore.getRuntime();
14
12
  }