@adongguo/dingtalk 0.1.3

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.
@@ -0,0 +1,119 @@
1
+ import { z } from "zod";
2
+ export { z };
3
+
4
+ const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
5
+ const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
6
+ const DingTalkConnectionModeSchema = z.enum(["stream", "webhook"]);
7
+
8
+ const ToolPolicySchema = z
9
+ .object({
10
+ allow: z.array(z.string()).optional(),
11
+ deny: z.array(z.string()).optional(),
12
+ })
13
+ .strict()
14
+ .optional();
15
+
16
+ const DmConfigSchema = z
17
+ .object({
18
+ enabled: z.boolean().optional(),
19
+ systemPrompt: z.string().optional(),
20
+ })
21
+ .strict()
22
+ .optional();
23
+
24
+ const MarkdownConfigSchema = z
25
+ .object({
26
+ mode: z.enum(["native", "escape", "strip"]).optional(),
27
+ tableMode: z.enum(["native", "ascii", "simple"]).optional(),
28
+ })
29
+ .strict()
30
+ .optional();
31
+
32
+ // Message render mode: auto (default) = detect markdown, raw = plain text, card = always action_card
33
+ const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
34
+
35
+ const BlockStreamingCoalesceSchema = z
36
+ .object({
37
+ enabled: z.boolean().optional(),
38
+ minDelayMs: z.number().int().positive().optional(),
39
+ maxDelayMs: z.number().int().positive().optional(),
40
+ })
41
+ .strict()
42
+ .optional();
43
+
44
+ const ChannelHeartbeatVisibilitySchema = z
45
+ .object({
46
+ visibility: z.enum(["visible", "hidden"]).optional(),
47
+ intervalMs: z.number().int().positive().optional(),
48
+ })
49
+ .strict()
50
+ .optional();
51
+
52
+ // AI Card streaming mode: enabled (default) = use AI Card, disabled = use regular messages
53
+ const AICardModeSchema = z.enum(["enabled", "disabled"]).optional();
54
+
55
+ export const DingTalkGroupSchema = z
56
+ .object({
57
+ requireMention: z.boolean().optional(),
58
+ tools: ToolPolicySchema,
59
+ skills: z.array(z.string()).optional(),
60
+ enabled: z.boolean().optional(),
61
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
62
+ systemPrompt: z.string().optional(),
63
+ })
64
+ .strict();
65
+
66
+ export const DingTalkConfigSchema = z
67
+ .object({
68
+ enabled: z.boolean().optional(),
69
+ appKey: z.string().optional(), // DingTalk uses appKey (ClientID)
70
+ appSecret: z.string().optional(), // DingTalk uses appSecret (ClientSecret)
71
+ robotCode: z.string().optional(), // Robot code for identifying the bot
72
+ connectionMode: DingTalkConnectionModeSchema.optional().default("stream"),
73
+ webhookPath: z.string().optional().default("/dingtalk/events"),
74
+ webhookPort: z.number().int().positive().optional(),
75
+ capabilities: z.array(z.string()).optional(),
76
+ markdown: MarkdownConfigSchema,
77
+ configWrites: z.boolean().optional(),
78
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
79
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
80
+ groupPolicy: GroupPolicySchema.optional().default("allowlist"),
81
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
82
+ requireMention: z.boolean().optional().default(true),
83
+ groups: z.record(z.string(), DingTalkGroupSchema.optional()).optional(),
84
+ historyLimit: z.number().int().min(0).optional(),
85
+ dmHistoryLimit: z.number().int().min(0).optional(),
86
+ dms: z.record(z.string(), DmConfigSchema).optional(),
87
+ textChunkLimit: z.number().int().positive().optional(),
88
+ chunkMode: z.enum(["length", "newline"]).optional(),
89
+ blockStreamingCoalesce: BlockStreamingCoalesceSchema,
90
+ mediaMaxMb: z.number().positive().optional(),
91
+ heartbeat: ChannelHeartbeatVisibilitySchema,
92
+ renderMode: RenderModeSchema, // raw = plain text, card = action card with markdown
93
+ // DingTalk specific options
94
+ cooldownMs: z.number().int().positive().optional(), // Cooldown between messages to avoid rate limiting
95
+ // AI Card streaming options
96
+ aiCardMode: AICardModeSchema, // enabled (default) = use AI Card streaming, disabled = regular messages
97
+ sessionTimeout: z.number().int().positive().optional().default(1800000), // Session timeout in ms (default 30 min)
98
+ // Gateway integration options
99
+ gatewayToken: z.string().optional(), // Gateway auth token (Bearer)
100
+ gatewayPassword: z.string().optional(), // Gateway auth password (alternative to token)
101
+ gatewayPort: z.number().int().positive().optional().default(18789), // Gateway port
102
+ // Media options
103
+ enableMediaUpload: z.boolean().optional().default(true), // Enable image post-processing upload
104
+ systemPrompt: z.string().optional(), // Custom system prompt
105
+ })
106
+ .strict()
107
+ .superRefine((value, ctx) => {
108
+ if (value.dmPolicy === "open") {
109
+ const allowFrom = value.allowFrom ?? [];
110
+ const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
111
+ if (!hasWildcard) {
112
+ ctx.addIssue({
113
+ code: z.ZodIssueCode.custom,
114
+ path: ["allowFrom"],
115
+ message: 'channels.dingtalk.dmPolicy="open" requires channels.dingtalk.allowFrom to include "*"',
116
+ });
117
+ }
118
+ }
119
+ });
@@ -0,0 +1,90 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import type { DingTalkConfig } from "./types.js";
3
+ import { normalizeDingTalkTarget } from "./targets.js";
4
+
5
+ export type DingTalkDirectoryPeer = {
6
+ kind: "user";
7
+ id: string;
8
+ name?: string;
9
+ };
10
+
11
+ export type DingTalkDirectoryGroup = {
12
+ kind: "group";
13
+ id: string;
14
+ name?: string;
15
+ };
16
+
17
+ export async function listDingTalkDirectoryPeers(params: {
18
+ cfg: ClawdbotConfig;
19
+ query?: string;
20
+ limit?: number;
21
+ }): Promise<DingTalkDirectoryPeer[]> {
22
+ const dingtalkCfg = params.cfg.channels?.dingtalk as DingTalkConfig | undefined;
23
+ const q = params.query?.trim().toLowerCase() || "";
24
+ const ids = new Set<string>();
25
+
26
+ for (const entry of dingtalkCfg?.allowFrom ?? []) {
27
+ const trimmed = String(entry).trim();
28
+ if (trimmed && trimmed !== "*") ids.add(trimmed);
29
+ }
30
+
31
+ for (const userId of Object.keys(dingtalkCfg?.dms ?? {})) {
32
+ const trimmed = userId.trim();
33
+ if (trimmed) ids.add(trimmed);
34
+ }
35
+
36
+ return Array.from(ids)
37
+ .map((raw) => raw.trim())
38
+ .filter(Boolean)
39
+ .map((raw) => normalizeDingTalkTarget(raw) ?? raw)
40
+ .filter((id) => (q ? id.toLowerCase().includes(q) : true))
41
+ .slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
42
+ .map((id) => ({ kind: "user" as const, id }));
43
+ }
44
+
45
+ export async function listDingTalkDirectoryGroups(params: {
46
+ cfg: ClawdbotConfig;
47
+ query?: string;
48
+ limit?: number;
49
+ }): Promise<DingTalkDirectoryGroup[]> {
50
+ const dingtalkCfg = params.cfg.channels?.dingtalk as DingTalkConfig | undefined;
51
+ const q = params.query?.trim().toLowerCase() || "";
52
+ const ids = new Set<string>();
53
+
54
+ for (const groupId of Object.keys(dingtalkCfg?.groups ?? {})) {
55
+ const trimmed = groupId.trim();
56
+ if (trimmed && trimmed !== "*") ids.add(trimmed);
57
+ }
58
+
59
+ for (const entry of dingtalkCfg?.groupAllowFrom ?? []) {
60
+ const trimmed = String(entry).trim();
61
+ if (trimmed && trimmed !== "*") ids.add(trimmed);
62
+ }
63
+
64
+ return Array.from(ids)
65
+ .map((raw) => raw.trim())
66
+ .filter(Boolean)
67
+ .filter((id) => (q ? id.toLowerCase().includes(q) : true))
68
+ .slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
69
+ .map((id) => ({ kind: "group" as const, id }));
70
+ }
71
+
72
+ // DingTalk doesn't provide directory listing APIs via bot API
73
+ // These stubs return the same results as the config-based versions
74
+ export async function listDingTalkDirectoryPeersLive(params: {
75
+ cfg: ClawdbotConfig;
76
+ query?: string;
77
+ limit?: number;
78
+ }): Promise<DingTalkDirectoryPeer[]> {
79
+ // DingTalk bot API doesn't support listing users
80
+ return listDingTalkDirectoryPeers(params);
81
+ }
82
+
83
+ export async function listDingTalkDirectoryGroupsLive(params: {
84
+ cfg: ClawdbotConfig;
85
+ query?: string;
86
+ limit?: number;
87
+ }): Promise<DingTalkDirectoryGroup[]> {
88
+ // DingTalk bot API doesn't support listing groups
89
+ return listDingTalkDirectoryGroups(params);
90
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Gateway SSE Streaming for DingTalk
3
+ *
4
+ * Connects to local OpenClaw/Clawdbot Gateway for streaming chat completions.
5
+ * Implements Server-Sent Events (SSE) client for real-time response streaming.
6
+ */
7
+
8
+ // ============ Types ============
9
+
10
+ export interface GatewayOptions {
11
+ userContent: string;
12
+ systemPrompts: string[];
13
+ sessionKey: string;
14
+ gatewayUrl?: string; // Full URL or just hostname/port will be resolved
15
+ gatewayPort?: number;
16
+ gatewayAuth?: string; // token or password, both use Bearer format
17
+ log?: {
18
+ info?: (msg: string) => void;
19
+ warn?: (msg: string) => void;
20
+ error?: (msg: string) => void;
21
+ };
22
+ }
23
+
24
+ // ============ Gateway Streaming ============
25
+
26
+ /**
27
+ * Stream content from Gateway via SSE (Server-Sent Events).
28
+ * Yields content chunks as they arrive.
29
+ *
30
+ * @param options - Gateway connection and message options
31
+ * @returns AsyncGenerator yielding content chunks
32
+ */
33
+ export async function* streamFromGateway(options: GatewayOptions): AsyncGenerator<string, void, unknown> {
34
+ const { userContent, systemPrompts, sessionKey, gatewayUrl, gatewayPort, gatewayAuth, log } = options;
35
+
36
+ // Resolve gateway URL
37
+ let url: string;
38
+ if (gatewayUrl) {
39
+ url = gatewayUrl;
40
+ } else {
41
+ const port = gatewayPort || 18789;
42
+ url = `http://127.0.0.1:${port}/v1/chat/completions`;
43
+ }
44
+
45
+ // Build messages
46
+ const messages: Array<{ role: "system" | "user"; content: string }> = [];
47
+ for (const prompt of systemPrompts) {
48
+ messages.push({ role: "system", content: prompt });
49
+ }
50
+ messages.push({ role: "user", content: userContent });
51
+
52
+ // Build headers
53
+ const headers: Record<string, string> = {
54
+ "Content-Type": "application/json",
55
+ };
56
+ if (gatewayAuth) {
57
+ headers.Authorization = `Bearer ${gatewayAuth}`;
58
+ }
59
+
60
+ log?.info?.(`[DingTalk][Gateway] POST ${url}, session=${sessionKey}, messages=${messages.length}`);
61
+
62
+ let response: Response;
63
+ try {
64
+ response = await fetch(url, {
65
+ method: "POST",
66
+ headers,
67
+ body: JSON.stringify({
68
+ model: "default",
69
+ messages,
70
+ stream: true,
71
+ user: sessionKey,
72
+ }),
73
+ });
74
+ } catch (err: unknown) {
75
+ const errMsg = err instanceof Error ? err.message : String(err);
76
+ log?.error?.(`[DingTalk][Gateway] Connection failed: ${errMsg}`);
77
+ throw new Error(`Gateway connection failed: ${errMsg}`);
78
+ }
79
+
80
+ log?.info?.(`[DingTalk][Gateway] Response status=${response.status}, ok=${response.ok}, hasBody=${!!response.body}`);
81
+
82
+ if (!response.ok || !response.body) {
83
+ let errText = "";
84
+ try {
85
+ errText = await response.text();
86
+ } catch {
87
+ errText = "(no body)";
88
+ }
89
+ log?.error?.(`[DingTalk][Gateway] Error response: ${errText}`);
90
+ throw new Error(`Gateway error: ${response.status} - ${errText}`);
91
+ }
92
+
93
+ // Parse SSE stream
94
+ const reader = response.body.getReader();
95
+ const decoder = new TextDecoder();
96
+ let buffer = "";
97
+
98
+ try {
99
+ while (true) {
100
+ const { done, value } = await reader.read();
101
+ if (done) break;
102
+
103
+ buffer += decoder.decode(value, { stream: true });
104
+ const lines = buffer.split("\n");
105
+ buffer = lines.pop() || "";
106
+
107
+ for (const line of lines) {
108
+ if (!line.startsWith("data: ")) continue;
109
+
110
+ const data = line.slice(6).trim();
111
+ if (data === "[DONE]") return;
112
+
113
+ try {
114
+ const chunk = JSON.parse(data) as {
115
+ choices?: Array<{ delta?: { content?: string } }>;
116
+ };
117
+ const content = chunk.choices?.[0]?.delta?.content;
118
+ if (content) {
119
+ yield content;
120
+ }
121
+ } catch {
122
+ // Ignore JSON parse errors for malformed chunks
123
+ log?.warn?.(`[DingTalk][Gateway] Failed to parse chunk: ${data.slice(0, 100)}`);
124
+ }
125
+ }
126
+ }
127
+
128
+ // Handle any remaining buffered data
129
+ if (buffer && buffer.startsWith("data: ")) {
130
+ const data = buffer.slice(6).trim();
131
+ if (data !== "[DONE]") {
132
+ try {
133
+ const chunk = JSON.parse(data) as {
134
+ choices?: Array<{ delta?: { content?: string } }>;
135
+ };
136
+ const content = chunk.choices?.[0]?.delta?.content;
137
+ if (content) {
138
+ yield content;
139
+ }
140
+ } catch {
141
+ // Ignore
142
+ }
143
+ }
144
+ }
145
+ } finally {
146
+ reader.releaseLock();
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Make a single (non-streaming) request to Gateway and get full response.
152
+ */
153
+ export async function getGatewayCompletion(options: GatewayOptions): Promise<string> {
154
+ let fullResponse = "";
155
+ for await (const chunk of streamFromGateway(options)) {
156
+ fullResponse += chunk;
157
+ }
158
+ return fullResponse;
159
+ }