@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
package/src/reactions.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
// DingTalk doesn't have a message reactions API
|
|
4
|
+
// This module provides stub implementations for interface compatibility
|
|
5
|
+
|
|
6
|
+
export type DingTalkReaction = {
|
|
7
|
+
reactionId: string;
|
|
8
|
+
emojiType: string;
|
|
9
|
+
operatorType: "app" | "user";
|
|
10
|
+
operatorId: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Add a reaction (emoji) to a message.
|
|
15
|
+
* Note: DingTalk doesn't support message reactions via bot API.
|
|
16
|
+
*/
|
|
17
|
+
export async function addReactionDingTalk(_params: {
|
|
18
|
+
cfg: ClawdbotConfig;
|
|
19
|
+
messageId: string;
|
|
20
|
+
emojiType: string;
|
|
21
|
+
}): Promise<{ reactionId: string }> {
|
|
22
|
+
// DingTalk doesn't support message reactions via bot API
|
|
23
|
+
throw new Error("DingTalk does not support message reactions via bot API");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Remove a reaction from a message.
|
|
28
|
+
* Note: DingTalk doesn't support message reactions via bot API.
|
|
29
|
+
*/
|
|
30
|
+
export async function removeReactionDingTalk(_params: {
|
|
31
|
+
cfg: ClawdbotConfig;
|
|
32
|
+
messageId: string;
|
|
33
|
+
reactionId: string;
|
|
34
|
+
}): Promise<void> {
|
|
35
|
+
// DingTalk doesn't support message reactions via bot API
|
|
36
|
+
throw new Error("DingTalk does not support message reactions via bot API");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* List all reactions for a message.
|
|
41
|
+
* Note: DingTalk doesn't support message reactions via bot API.
|
|
42
|
+
*/
|
|
43
|
+
export async function listReactionsDingTalk(_params: {
|
|
44
|
+
cfg: ClawdbotConfig;
|
|
45
|
+
messageId: string;
|
|
46
|
+
emojiType?: string;
|
|
47
|
+
}): Promise<DingTalkReaction[]> {
|
|
48
|
+
// DingTalk doesn't support message reactions via bot API
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Common emoji types for convenience.
|
|
54
|
+
* Note: These are placeholders since DingTalk doesn't support reactions.
|
|
55
|
+
*/
|
|
56
|
+
export const DingTalkEmoji = {
|
|
57
|
+
THUMBSUP: "THUMBSUP",
|
|
58
|
+
THUMBSDOWN: "THUMBSDOWN",
|
|
59
|
+
HEART: "HEART",
|
|
60
|
+
SMILE: "SMILE",
|
|
61
|
+
OK: "OK",
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
64
|
+
export type DingTalkEmojiType = (typeof DingTalkEmoji)[keyof typeof DingTalkEmoji];
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { DWClient } from "dingtalk-stream";
|
|
2
|
+
import {
|
|
3
|
+
createReplyPrefixContext,
|
|
4
|
+
createTypingCallbacks,
|
|
5
|
+
logTypingFailure,
|
|
6
|
+
type ClawdbotConfig,
|
|
7
|
+
type RuntimeEnv,
|
|
8
|
+
type ReplyPayload,
|
|
9
|
+
} from "openclaw/plugin-sdk";
|
|
10
|
+
import { getDingTalkRuntime } from "./runtime.js";
|
|
11
|
+
import { sendMessageDingTalk, sendActionCardDingTalk } from "./send.js";
|
|
12
|
+
import type { DingTalkConfig } from "./types.js";
|
|
13
|
+
import {
|
|
14
|
+
addTypingIndicator,
|
|
15
|
+
removeTypingIndicator,
|
|
16
|
+
type TypingIndicatorState,
|
|
17
|
+
} from "./typing.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detect if text contains markdown elements that benefit from card rendering.
|
|
21
|
+
* Used by auto render mode.
|
|
22
|
+
*/
|
|
23
|
+
function shouldUseCard(text: string): boolean {
|
|
24
|
+
// Code blocks (fenced)
|
|
25
|
+
if (/```[\s\S]*?```/.test(text)) return true;
|
|
26
|
+
// Tables (at least header + separator row with |)
|
|
27
|
+
if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) return true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type CreateDingTalkReplyDispatcherParams = {
|
|
32
|
+
cfg: ClawdbotConfig;
|
|
33
|
+
agentId: string;
|
|
34
|
+
runtime: RuntimeEnv;
|
|
35
|
+
conversationId: string;
|
|
36
|
+
sessionWebhook: string;
|
|
37
|
+
client?: DWClient;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function createDingTalkReplyDispatcher(params: CreateDingTalkReplyDispatcherParams) {
|
|
41
|
+
const core = getDingTalkRuntime();
|
|
42
|
+
const { cfg, agentId, conversationId, sessionWebhook, client } = params;
|
|
43
|
+
|
|
44
|
+
const prefixContext = createReplyPrefixContext({
|
|
45
|
+
cfg,
|
|
46
|
+
agentId,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// DingTalk doesn't have a native typing indicator API.
|
|
50
|
+
// We could use emoji reactions if available.
|
|
51
|
+
let typingState: TypingIndicatorState | null = null;
|
|
52
|
+
|
|
53
|
+
const typingCallbacks = createTypingCallbacks({
|
|
54
|
+
start: async () => {
|
|
55
|
+
// DingTalk typing indicator is optional and may not work for all bots
|
|
56
|
+
try {
|
|
57
|
+
typingState = await addTypingIndicator({ cfg, sessionWebhook });
|
|
58
|
+
params.runtime.log?.(`dingtalk: added typing indicator`);
|
|
59
|
+
} catch {
|
|
60
|
+
// Typing indicator not available, ignore
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
stop: async () => {
|
|
64
|
+
if (!typingState) return;
|
|
65
|
+
try {
|
|
66
|
+
await removeTypingIndicator({ cfg, state: typingState });
|
|
67
|
+
typingState = null;
|
|
68
|
+
params.runtime.log?.(`dingtalk: removed typing indicator`);
|
|
69
|
+
} catch {
|
|
70
|
+
// Ignore errors
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
onStartError: (err) => {
|
|
74
|
+
logTypingFailure({
|
|
75
|
+
log: (message) => params.runtime.log?.(message),
|
|
76
|
+
channel: "dingtalk",
|
|
77
|
+
action: "start",
|
|
78
|
+
error: err,
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
onStopError: (err) => {
|
|
82
|
+
logTypingFailure({
|
|
83
|
+
log: (message) => params.runtime.log?.(message),
|
|
84
|
+
channel: "dingtalk",
|
|
85
|
+
action: "stop",
|
|
86
|
+
error: err,
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const textChunkLimit = core.channel.text.resolveTextChunkLimit({
|
|
92
|
+
cfg,
|
|
93
|
+
channel: "dingtalk",
|
|
94
|
+
defaultLimit: 4000,
|
|
95
|
+
});
|
|
96
|
+
const chunkMode = core.channel.text.resolveChunkMode(cfg, "dingtalk");
|
|
97
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
98
|
+
cfg,
|
|
99
|
+
channel: "dingtalk",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
103
|
+
core.channel.reply.createReplyDispatcherWithTyping({
|
|
104
|
+
responsePrefix: prefixContext.responsePrefix,
|
|
105
|
+
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
|
106
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
107
|
+
onReplyStart: typingCallbacks.onReplyStart,
|
|
108
|
+
deliver: async (payload: ReplyPayload) => {
|
|
109
|
+
params.runtime.log?.(`dingtalk deliver called: text=${payload.text?.slice(0, 100)}`);
|
|
110
|
+
const text = payload.text ?? "";
|
|
111
|
+
if (!text.trim()) {
|
|
112
|
+
params.runtime.log?.(`dingtalk deliver: empty text, skipping`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check render mode: auto (default), raw, or card
|
|
117
|
+
const dingtalkCfg = cfg.channels?.dingtalk as DingTalkConfig | undefined;
|
|
118
|
+
const renderMode = dingtalkCfg?.renderMode ?? "auto";
|
|
119
|
+
|
|
120
|
+
// Determine if we should use card for this message
|
|
121
|
+
const useCard =
|
|
122
|
+
renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
|
123
|
+
|
|
124
|
+
if (useCard) {
|
|
125
|
+
// Card mode: send as ActionCard with markdown rendering
|
|
126
|
+
const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
|
|
127
|
+
params.runtime.log?.(`dingtalk deliver: sending ${chunks.length} card chunks to ${conversationId}`);
|
|
128
|
+
for (const chunk of chunks) {
|
|
129
|
+
await sendActionCardDingTalk({
|
|
130
|
+
cfg,
|
|
131
|
+
sessionWebhook,
|
|
132
|
+
title: "Reply",
|
|
133
|
+
text: chunk,
|
|
134
|
+
client,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
// Raw mode: send as plain text with table conversion
|
|
139
|
+
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
|
140
|
+
const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
|
|
141
|
+
params.runtime.log?.(`dingtalk deliver: sending ${chunks.length} text chunks to ${conversationId}`);
|
|
142
|
+
for (const chunk of chunks) {
|
|
143
|
+
await sendMessageDingTalk({
|
|
144
|
+
cfg,
|
|
145
|
+
sessionWebhook,
|
|
146
|
+
text: chunk,
|
|
147
|
+
client,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
onError: (err, info) => {
|
|
153
|
+
params.runtime.error?.(`dingtalk ${info.kind} reply failed: ${String(err)}`);
|
|
154
|
+
typingCallbacks.onIdle?.();
|
|
155
|
+
},
|
|
156
|
+
onIdle: typingCallbacks.onIdle,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
dispatcher,
|
|
161
|
+
replyOptions: {
|
|
162
|
+
...replyOptions,
|
|
163
|
+
onModelSelected: prefixContext.onModelSelected,
|
|
164
|
+
},
|
|
165
|
+
markDispatchIdle,
|
|
166
|
+
};
|
|
167
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setDingTalkRuntime(next: PluginRuntime) {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getDingTalkRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("DingTalk runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|
package/src/send.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { DWClient } from "dingtalk-stream";
|
|
3
|
+
import type {
|
|
4
|
+
DingTalkConfig,
|
|
5
|
+
DingTalkSendResult,
|
|
6
|
+
DingTalkTextMessage,
|
|
7
|
+
DingTalkMarkdownMessage,
|
|
8
|
+
DingTalkActionCardMessage,
|
|
9
|
+
DingTalkOutboundMessage,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
import { getDingTalkRuntime } from "./runtime.js";
|
|
12
|
+
|
|
13
|
+
export type DingTalkMessageInfo = {
|
|
14
|
+
messageId: string;
|
|
15
|
+
conversationId: string;
|
|
16
|
+
senderId?: string;
|
|
17
|
+
content: string;
|
|
18
|
+
contentType: string;
|
|
19
|
+
createTime?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Send a message via DingTalk sessionWebhook.
|
|
24
|
+
* This is the primary method for sending messages in response to incoming messages.
|
|
25
|
+
*/
|
|
26
|
+
export async function sendViaWebhook(params: {
|
|
27
|
+
sessionWebhook: string;
|
|
28
|
+
message: DingTalkOutboundMessage;
|
|
29
|
+
accessToken?: string;
|
|
30
|
+
}): Promise<DingTalkSendResult> {
|
|
31
|
+
const { sessionWebhook, message, accessToken } = params;
|
|
32
|
+
|
|
33
|
+
const headers: Record<string, string> = {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (accessToken) {
|
|
38
|
+
headers["x-acs-dingtalk-access-token"] = accessToken;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const response = await fetch(sessionWebhook, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers,
|
|
44
|
+
body: JSON.stringify(message),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const text = await response.text();
|
|
49
|
+
throw new Error(`DingTalk webhook send failed: ${response.status} ${text}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const result = await response.json() as { errcode?: number; errmsg?: string; processQueryKey?: string };
|
|
53
|
+
|
|
54
|
+
if (result.errcode && result.errcode !== 0) {
|
|
55
|
+
throw new Error(`DingTalk send failed: ${result.errmsg || `code ${result.errcode}`}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
conversationId: "",
|
|
60
|
+
processQueryKey: result.processQueryKey,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type SendDingTalkMessageParams = {
|
|
65
|
+
cfg: ClawdbotConfig;
|
|
66
|
+
sessionWebhook: string;
|
|
67
|
+
text: string;
|
|
68
|
+
atUserIds?: string[];
|
|
69
|
+
client?: DWClient;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export async function sendMessageDingTalk(params: SendDingTalkMessageParams): Promise<DingTalkSendResult> {
|
|
73
|
+
const { cfg, sessionWebhook, text, atUserIds, client } = params;
|
|
74
|
+
const dingtalkCfg = cfg.channels?.dingtalk as DingTalkConfig | undefined;
|
|
75
|
+
if (!dingtalkCfg) {
|
|
76
|
+
throw new Error("DingTalk channel not configured");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const tableMode = getDingTalkRuntime().channel.text.resolveMarkdownTableMode({
|
|
80
|
+
cfg,
|
|
81
|
+
channel: "dingtalk",
|
|
82
|
+
});
|
|
83
|
+
const messageText = getDingTalkRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
|
|
84
|
+
|
|
85
|
+
const message: DingTalkTextMessage = {
|
|
86
|
+
msgtype: "text",
|
|
87
|
+
text: {
|
|
88
|
+
content: messageText,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (atUserIds && atUserIds.length > 0) {
|
|
93
|
+
message.at = {
|
|
94
|
+
atUserIds,
|
|
95
|
+
isAtAll: false,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let accessToken: string | undefined;
|
|
100
|
+
if (client) {
|
|
101
|
+
try {
|
|
102
|
+
accessToken = await client.getAccessToken();
|
|
103
|
+
} catch {
|
|
104
|
+
// Proceed without access token
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return sendViaWebhook({ sessionWebhook, message, accessToken });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type SendDingTalkMarkdownParams = {
|
|
112
|
+
cfg: ClawdbotConfig;
|
|
113
|
+
sessionWebhook: string;
|
|
114
|
+
title: string;
|
|
115
|
+
text: string;
|
|
116
|
+
atUserIds?: string[];
|
|
117
|
+
client?: DWClient;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export async function sendMarkdownDingTalk(params: SendDingTalkMarkdownParams): Promise<DingTalkSendResult> {
|
|
121
|
+
const { sessionWebhook, title, text, atUserIds, client } = params;
|
|
122
|
+
|
|
123
|
+
const message: DingTalkMarkdownMessage = {
|
|
124
|
+
msgtype: "markdown",
|
|
125
|
+
markdown: {
|
|
126
|
+
title,
|
|
127
|
+
text,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (atUserIds && atUserIds.length > 0) {
|
|
132
|
+
message.at = {
|
|
133
|
+
atUserIds,
|
|
134
|
+
isAtAll: false,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let accessToken: string | undefined;
|
|
139
|
+
if (client) {
|
|
140
|
+
try {
|
|
141
|
+
accessToken = await client.getAccessToken();
|
|
142
|
+
} catch {
|
|
143
|
+
// Proceed without access token
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return sendViaWebhook({ sessionWebhook, message, accessToken });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type SendDingTalkActionCardParams = {
|
|
151
|
+
cfg: ClawdbotConfig;
|
|
152
|
+
sessionWebhook: string;
|
|
153
|
+
title: string;
|
|
154
|
+
text: string;
|
|
155
|
+
singleTitle?: string;
|
|
156
|
+
singleURL?: string;
|
|
157
|
+
client?: DWClient;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export async function sendActionCardDingTalk(params: SendDingTalkActionCardParams): Promise<DingTalkSendResult> {
|
|
161
|
+
const { sessionWebhook, title, text, singleTitle, singleURL, client } = params;
|
|
162
|
+
|
|
163
|
+
const message: DingTalkActionCardMessage = {
|
|
164
|
+
msgtype: "actionCard",
|
|
165
|
+
actionCard: {
|
|
166
|
+
title,
|
|
167
|
+
text,
|
|
168
|
+
singleTitle,
|
|
169
|
+
singleURL,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
let accessToken: string | undefined;
|
|
174
|
+
if (client) {
|
|
175
|
+
try {
|
|
176
|
+
accessToken = await client.getAccessToken();
|
|
177
|
+
} catch {
|
|
178
|
+
// Proceed without access token
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return sendViaWebhook({ sessionWebhook, message, accessToken });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build an ActionCard message with markdown content.
|
|
187
|
+
* ActionCards render markdown properly (code blocks, tables, links, etc.)
|
|
188
|
+
*/
|
|
189
|
+
export function buildMarkdownCard(text: string, title?: string): DingTalkActionCardMessage {
|
|
190
|
+
return {
|
|
191
|
+
msgtype: "actionCard",
|
|
192
|
+
actionCard: {
|
|
193
|
+
title: title || "Message",
|
|
194
|
+
text,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Send a message as an ActionCard (for better markdown rendering).
|
|
201
|
+
*/
|
|
202
|
+
export async function sendMarkdownCardDingTalk(params: {
|
|
203
|
+
cfg: ClawdbotConfig;
|
|
204
|
+
sessionWebhook: string;
|
|
205
|
+
text: string;
|
|
206
|
+
title?: string;
|
|
207
|
+
client?: DWClient;
|
|
208
|
+
}): Promise<DingTalkSendResult> {
|
|
209
|
+
const { cfg, sessionWebhook, text, title, client } = params;
|
|
210
|
+
const message = buildMarkdownCard(text, title);
|
|
211
|
+
|
|
212
|
+
let accessToken: string | undefined;
|
|
213
|
+
if (client) {
|
|
214
|
+
try {
|
|
215
|
+
accessToken = await client.getAccessToken();
|
|
216
|
+
} catch {
|
|
217
|
+
// Proceed without access token
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return sendViaWebhook({ sessionWebhook, message, accessToken });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ============ Simplified Send Functions (for streaming-handler) ============
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Send a text message via sessionWebhook (simplified, no cfg required).
|
|
228
|
+
*/
|
|
229
|
+
export async function sendDingTalkTextMessage(params: {
|
|
230
|
+
sessionWebhook: string;
|
|
231
|
+
text: string;
|
|
232
|
+
atUserId?: string;
|
|
233
|
+
client?: DWClient;
|
|
234
|
+
}): Promise<DingTalkSendResult> {
|
|
235
|
+
const { sessionWebhook, text, atUserId, client } = params;
|
|
236
|
+
|
|
237
|
+
const message: DingTalkTextMessage = {
|
|
238
|
+
msgtype: "text",
|
|
239
|
+
text: { content: text },
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
if (atUserId) {
|
|
243
|
+
message.at = { atUserIds: [atUserId], isAtAll: false };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let accessToken: string | undefined;
|
|
247
|
+
if (client) {
|
|
248
|
+
try {
|
|
249
|
+
accessToken = await client.getAccessToken();
|
|
250
|
+
} catch {
|
|
251
|
+
// Proceed without access token
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return sendViaWebhook({ sessionWebhook, message, accessToken });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Send a message via sessionWebhook with smart text/markdown selection.
|
|
260
|
+
*/
|
|
261
|
+
export async function sendDingTalkMessage(params: {
|
|
262
|
+
sessionWebhook: string;
|
|
263
|
+
text: string;
|
|
264
|
+
useMarkdown?: boolean;
|
|
265
|
+
title?: string;
|
|
266
|
+
atUserId?: string;
|
|
267
|
+
client?: DWClient;
|
|
268
|
+
}): Promise<DingTalkSendResult> {
|
|
269
|
+
const { sessionWebhook, text, useMarkdown, title, atUserId, client } = params;
|
|
270
|
+
|
|
271
|
+
// Auto-detect markdown
|
|
272
|
+
const hasMarkdown = /^[#*>-]|[*_`#\[\]]/.test(text) || text.includes("\n");
|
|
273
|
+
const shouldUseMarkdown = useMarkdown !== false && (useMarkdown || hasMarkdown);
|
|
274
|
+
|
|
275
|
+
let accessToken: string | undefined;
|
|
276
|
+
if (client) {
|
|
277
|
+
try {
|
|
278
|
+
accessToken = await client.getAccessToken();
|
|
279
|
+
} catch {
|
|
280
|
+
// Proceed without access token
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (shouldUseMarkdown) {
|
|
285
|
+
const markdownTitle =
|
|
286
|
+
title || text.split("\n")[0].replace(/^[#*\s\->]+/, "").slice(0, 20) || "Message";
|
|
287
|
+
|
|
288
|
+
const message: DingTalkMarkdownMessage = {
|
|
289
|
+
msgtype: "markdown",
|
|
290
|
+
markdown: {
|
|
291
|
+
title: markdownTitle,
|
|
292
|
+
text: atUserId ? `${text} @${atUserId}` : text,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
if (atUserId) {
|
|
297
|
+
message.at = { atUserIds: [atUserId], isAtAll: false };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return sendViaWebhook({ sessionWebhook, message, accessToken });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Plain text
|
|
304
|
+
const message: DingTalkTextMessage = {
|
|
305
|
+
msgtype: "text",
|
|
306
|
+
text: { content: text },
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
if (atUserId) {
|
|
310
|
+
message.at = { atUserIds: [atUserId], isAtAll: false };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return sendViaWebhook({ sessionWebhook, message, accessToken });
|
|
314
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Management for DingTalk
|
|
3
|
+
*
|
|
4
|
+
* Handles session timeout and new session commands.
|
|
5
|
+
* Provides session key generation for conversation persistence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============ Types ============
|
|
9
|
+
|
|
10
|
+
export interface UserSession {
|
|
11
|
+
lastActivity: number;
|
|
12
|
+
sessionId: string; // Format: dingtalk:<senderId> or dingtalk:<senderId>:<timestamp>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Logger {
|
|
16
|
+
info?: (msg: string) => void;
|
|
17
|
+
warn?: (msg: string) => void;
|
|
18
|
+
error?: (msg: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ============ Constants ============
|
|
22
|
+
|
|
23
|
+
/** Commands that trigger a new session */
|
|
24
|
+
const NEW_SESSION_COMMANDS = ["/new", "/reset", "/clear", "新会话", "重新开始", "清空对话"];
|
|
25
|
+
|
|
26
|
+
/** Default session timeout: 30 minutes */
|
|
27
|
+
export const DEFAULT_SESSION_TIMEOUT = 1800000;
|
|
28
|
+
|
|
29
|
+
// ============ Session Storage ============
|
|
30
|
+
|
|
31
|
+
/** User session cache Map<senderId, UserSession> */
|
|
32
|
+
const userSessions = new Map<string, UserSession>();
|
|
33
|
+
|
|
34
|
+
// ============ Functions ============
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if a message is a new session command.
|
|
38
|
+
*/
|
|
39
|
+
export function isNewSessionCommand(text: string): boolean {
|
|
40
|
+
const trimmed = text.trim().toLowerCase();
|
|
41
|
+
return NEW_SESSION_COMMANDS.some((cmd) => trimmed === cmd.toLowerCase());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get list of new session commands for display purposes.
|
|
46
|
+
*/
|
|
47
|
+
export function getNewSessionCommands(): readonly string[] {
|
|
48
|
+
return NEW_SESSION_COMMANDS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get or create a session key for a user.
|
|
53
|
+
*
|
|
54
|
+
* @param senderId - The sender's ID
|
|
55
|
+
* @param forceNew - Force creation of a new session
|
|
56
|
+
* @param sessionTimeout - Session timeout in milliseconds
|
|
57
|
+
* @param log - Optional logger
|
|
58
|
+
* @returns Session key and whether it's a new session
|
|
59
|
+
*/
|
|
60
|
+
export function getSessionKey(
|
|
61
|
+
senderId: string,
|
|
62
|
+
forceNew: boolean,
|
|
63
|
+
sessionTimeout: number,
|
|
64
|
+
log?: Logger,
|
|
65
|
+
): { sessionKey: string; isNew: boolean } {
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const existing = userSessions.get(senderId);
|
|
68
|
+
|
|
69
|
+
// Force new session
|
|
70
|
+
if (forceNew) {
|
|
71
|
+
const sessionId = `dingtalk:${senderId}:${now}`;
|
|
72
|
+
userSessions.set(senderId, { lastActivity: now, sessionId });
|
|
73
|
+
log?.info?.(`[DingTalk][Session] User requested new session: ${senderId}`);
|
|
74
|
+
return { sessionKey: sessionId, isNew: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check timeout
|
|
78
|
+
if (existing) {
|
|
79
|
+
const elapsed = now - existing.lastActivity;
|
|
80
|
+
if (elapsed > sessionTimeout) {
|
|
81
|
+
const sessionId = `dingtalk:${senderId}:${now}`;
|
|
82
|
+
userSessions.set(senderId, { lastActivity: now, sessionId });
|
|
83
|
+
log?.info?.(
|
|
84
|
+
`[DingTalk][Session] Session timeout (${Math.round(elapsed / 60000)} min), auto new session: ${senderId}`,
|
|
85
|
+
);
|
|
86
|
+
return { sessionKey: sessionId, isNew: true };
|
|
87
|
+
}
|
|
88
|
+
// Update activity time
|
|
89
|
+
existing.lastActivity = now;
|
|
90
|
+
return { sessionKey: existing.sessionId, isNew: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// First session
|
|
94
|
+
const sessionId = `dingtalk:${senderId}`;
|
|
95
|
+
userSessions.set(senderId, { lastActivity: now, sessionId });
|
|
96
|
+
log?.info?.(`[DingTalk][Session] New user first session: ${senderId}`);
|
|
97
|
+
return { sessionKey: sessionId, isNew: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Clear a user's session.
|
|
102
|
+
*/
|
|
103
|
+
export function clearSession(senderId: string): void {
|
|
104
|
+
userSessions.delete(senderId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get session info for a user (for debugging).
|
|
109
|
+
*/
|
|
110
|
+
export function getSessionInfo(senderId: string): UserSession | undefined {
|
|
111
|
+
return userSessions.get(senderId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Clear all sessions (for testing or reset).
|
|
116
|
+
*/
|
|
117
|
+
export function clearAllSessions(): void {
|
|
118
|
+
userSessions.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get the number of active sessions.
|
|
123
|
+
*/
|
|
124
|
+
export function getActiveSessionCount(): number {
|
|
125
|
+
return userSessions.size;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Clean up expired sessions.
|
|
130
|
+
* Call this periodically to prevent memory leaks.
|
|
131
|
+
*/
|
|
132
|
+
export function cleanupExpiredSessions(sessionTimeout: number): number {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
let cleaned = 0;
|
|
135
|
+
|
|
136
|
+
for (const [senderId, session] of userSessions.entries()) {
|
|
137
|
+
if (now - session.lastActivity > sessionTimeout) {
|
|
138
|
+
userSessions.delete(senderId);
|
|
139
|
+
cleaned++;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return cleaned;
|
|
144
|
+
}
|