@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.
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/clawdbot.plugin.json +9 -0
- package/index.ts +86 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +60 -0
- package/src/accounts.ts +49 -0
- package/src/ai-card.ts +341 -0
- package/src/bot.ts +403 -0
- package/src/channel.ts +220 -0
- package/src/client.ts +49 -0
- package/src/config-schema.ts +119 -0
- package/src/directory.ts +90 -0
- package/src/gateway-stream.ts +159 -0
- package/src/media.ts +608 -0
- package/src/monitor.ts +127 -0
- package/src/onboarding.ts +355 -0
- package/src/outbound.ts +46 -0
- package/src/policy.ts +92 -0
- package/src/probe.ts +41 -0
- package/src/reactions.ts +64 -0
- package/src/reply-dispatcher.ts +167 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +314 -0
- package/src/session.ts +144 -0
- package/src/streaming-handler.ts +298 -0
- package/src/targets.ts +56 -0
- package/src/types.ts +198 -0
- package/src/typing.ts +36 -0
|
@@ -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
|
+
});
|
package/src/directory.ts
ADDED
|
@@ -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
|
+
}
|