@axhub/genie 0.1.6 → 0.1.8

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 (37) hide show
  1. package/dist/api-docs.html +351 -909
  2. package/dist/assets/index-CVjMty4a.js +902 -0
  3. package/dist/assets/index-eo5scY_Z.css +32 -0
  4. package/dist/index.html +5 -5
  5. package/dist/manifest.json +2 -2
  6. package/package.json +8 -2
  7. package/server/channels/core/ChannelManager.js +399 -0
  8. package/server/channels/core/PluginManager.js +59 -0
  9. package/server/channels/index.js +3 -0
  10. package/server/channels/plugins/BasePlugin.js +46 -0
  11. package/server/channels/plugins/dingtalk/DingTalkAdapter.js +156 -0
  12. package/server/channels/plugins/dingtalk/DingTalkPlugin.js +592 -0
  13. package/server/channels/plugins/dingtalk/index.js +2 -0
  14. package/server/channels/plugins/lark/LarkAdapter.js +100 -0
  15. package/server/channels/plugins/lark/LarkCards.js +43 -0
  16. package/server/channels/plugins/lark/LarkPlugin.js +260 -0
  17. package/server/channels/runtime/AgentRuntimeAdapter.js +179 -0
  18. package/server/channels/runtime/DingTalkStreamWriter.js +105 -0
  19. package/server/channels/runtime/LarkStreamWriter.js +99 -0
  20. package/server/channels/store/ChannelStore.js +236 -0
  21. package/server/database/db.js +109 -1
  22. package/server/database/init.sql +47 -1
  23. package/server/gemini-cli.js +280 -0
  24. package/server/index.js +230 -11
  25. package/server/openai-codex.js +104 -8
  26. package/server/opencode-cli.js +673 -0
  27. package/server/projects.js +645 -5
  28. package/server/routes/agent.js +40 -12
  29. package/server/routes/channels.js +221 -0
  30. package/server/routes/cli-auth.js +317 -0
  31. package/server/routes/commands.js +29 -3
  32. package/server/routes/git.js +15 -5
  33. package/server/routes/opencode.js +72 -0
  34. package/shared/modelConstants.js +62 -17
  35. package/dist/assets/index-CtRxrKDm.css +0 -32
  36. package/dist/assets/index-OENtErNy.js +0 -1249
  37. package/server/database/auth.db +0 -0
@@ -0,0 +1,399 @@
1
+ import { PluginManager } from './PluginManager.js';
2
+ import { LarkPlugin } from '../plugins/lark/LarkPlugin.js';
3
+ import { DingTalkPlugin } from '../plugins/dingtalk/DingTalkPlugin.js';
4
+ import { createUnauthorizedUserCard, createUnknownUserIdCard } from '../plugins/lark/LarkCards.js';
5
+ import { ChannelStore } from '../store/ChannelStore.js';
6
+ import { runBackendSession } from '../runtime/AgentRuntimeAdapter.js';
7
+ import { LarkStreamWriter } from '../runtime/LarkStreamWriter.js';
8
+ import { DingTalkStreamWriter } from '../runtime/DingTalkStreamWriter.js';
9
+
10
+ const SUPPORTED_PLATFORMS = ['lark', 'dingtalk'];
11
+
12
+ function normalizePlatform(platform) {
13
+ const normalized = String(platform || '').trim().toLowerCase();
14
+ return normalized === 'dingtalk' ? 'dingtalk' : 'lark';
15
+ }
16
+
17
+ function toPluginId(platform) {
18
+ return normalizePlatform(platform) === 'dingtalk' ? 'dingtalk_default' : 'lark_default';
19
+ }
20
+
21
+ function isTextMessage(messageType) {
22
+ const normalized = String(messageType || '').trim().toLowerCase();
23
+ return normalized === 'text' || normalized === 'richtext';
24
+ }
25
+
26
+ export class ChannelManager {
27
+ constructor() {
28
+ this.store = new ChannelStore();
29
+ this.pluginManager = new PluginManager();
30
+ this.pluginManager.register('lark', LarkPlugin);
31
+ this.pluginManager.register('dingtalk', DingTalkPlugin);
32
+ this.initialized = false;
33
+ this.runtimeErrors = {
34
+ lark: null,
35
+ dingtalk: null,
36
+ };
37
+ this.processingQueues = new Map();
38
+ }
39
+
40
+ async initialize() {
41
+ if (this.initialized) {
42
+ return;
43
+ }
44
+
45
+ for (const platform of SUPPORTED_PLATFORMS) {
46
+ const pluginId = toPluginId(platform);
47
+ this.store.upsertPluginConfig(pluginId, {});
48
+ this.store.updatePluginRuntimeStatus(pluginId, false, 'stopped');
49
+ this.runtimeErrors[platform] = null;
50
+ }
51
+
52
+ this.initialized = true;
53
+ }
54
+
55
+ async shutdown() {
56
+ await this.pluginManager.stopAll();
57
+ }
58
+
59
+ getConfig(platform = 'lark') {
60
+ const normalizedPlatform = normalizePlatform(platform);
61
+ const pluginId = toPluginId(normalizedPlatform);
62
+ const stored = this.store.getPluginConfig(pluginId);
63
+ const config = stored.config || {};
64
+
65
+ if (normalizedPlatform === 'dingtalk') {
66
+ return {
67
+ clientId: config.clientId || '',
68
+ clientSecret: '',
69
+ hasClientSecret: !!config.clientSecret,
70
+ defaultBackend: config.defaultBackend || 'codex',
71
+ defaultModel: config.defaultModel || '',
72
+ defaultProjectPath: config.defaultProjectPath || '',
73
+ };
74
+ }
75
+
76
+ return {
77
+ appId: config.appId || '',
78
+ appSecret: '',
79
+ hasAppSecret: !!config.appSecret,
80
+ encryptKey: '',
81
+ hasEncryptKey: !!config.encryptKey,
82
+ verificationToken: '',
83
+ hasVerificationToken: !!config.verificationToken,
84
+ defaultBackend: config.defaultBackend || 'codex',
85
+ defaultModel: config.defaultModel || '',
86
+ defaultProjectPath: config.defaultProjectPath || '',
87
+ };
88
+ }
89
+
90
+ updateConfig(platform = 'lark', payload = {}) {
91
+ const normalizedPlatform = normalizePlatform(platform);
92
+ const pluginId = toPluginId(normalizedPlatform);
93
+ const current = this.store.getPluginConfig(pluginId).config;
94
+
95
+ const normalize = (value) => (typeof value === 'string' ? value.trim() : '');
96
+
97
+ if (normalizedPlatform === 'dingtalk') {
98
+ const nextDingTalkConfig = {
99
+ ...current,
100
+ clientId: normalize(payload.clientId) || current.clientId || '',
101
+ clientSecret: normalize(payload.clientSecret) || current.clientSecret || '',
102
+ defaultBackend: normalize(payload.defaultBackend) || current.defaultBackend || 'codex',
103
+ defaultModel: normalize(payload.defaultModel) || current.defaultModel || '',
104
+ defaultProjectPath: normalize(payload.defaultProjectPath) || current.defaultProjectPath || '',
105
+ };
106
+
107
+ this.store.upsertPluginConfig(pluginId, nextDingTalkConfig);
108
+ return this.getConfig(normalizedPlatform);
109
+ }
110
+
111
+ const nextLarkConfig = {
112
+ ...current,
113
+ appId: normalize(payload.appId) || current.appId || '',
114
+ appSecret: normalize(payload.appSecret) || current.appSecret || '',
115
+ encryptKey: normalize(payload.encryptKey) || current.encryptKey || '',
116
+ verificationToken: normalize(payload.verificationToken) || current.verificationToken || '',
117
+ defaultBackend: normalize(payload.defaultBackend) || current.defaultBackend || 'codex',
118
+ defaultModel: normalize(payload.defaultModel) || current.defaultModel || '',
119
+ defaultProjectPath: normalize(payload.defaultProjectPath) || current.defaultProjectPath || '',
120
+ };
121
+
122
+ this.store.upsertPluginConfig(pluginId, nextLarkConfig);
123
+ return this.getConfig(normalizedPlatform);
124
+ }
125
+
126
+ async testConnection(platform = 'lark', credentials = {}) {
127
+ const normalizedPlatform = normalizePlatform(platform);
128
+ const pluginId = toPluginId(normalizedPlatform);
129
+ const stored = this.store.getPluginConfig(pluginId);
130
+ const config = stored.config || {};
131
+
132
+ if (normalizedPlatform === 'dingtalk') {
133
+ const resolvedClientId = String(credentials.clientId || '').trim() || String(config.clientId || '').trim();
134
+ const resolvedClientSecret = String(credentials.clientSecret || '').trim() || String(config.clientSecret || '').trim();
135
+
136
+ if (!resolvedClientId || !resolvedClientSecret) {
137
+ return { success: false, error: '测试连接需要填写 Client ID 和 Client Secret' };
138
+ }
139
+
140
+ return DingTalkPlugin.testConnection(resolvedClientId, resolvedClientSecret);
141
+ }
142
+
143
+ const resolvedAppId = String(credentials.appId || '').trim() || String(config.appId || '').trim();
144
+ const resolvedAppSecret = String(credentials.appSecret || '').trim() || String(config.appSecret || '').trim();
145
+
146
+ if (!resolvedAppId || !resolvedAppSecret) {
147
+ return { success: false, error: '测试连接需要填写 App ID 和 App Secret' };
148
+ }
149
+
150
+ return LarkPlugin.testConnection(resolvedAppId, resolvedAppSecret);
151
+ }
152
+
153
+ getStatus(platform = 'lark') {
154
+ const normalizedPlatform = normalizePlatform(platform);
155
+ const pluginId = toPluginId(normalizedPlatform);
156
+ const stored = this.store.getPluginConfig(pluginId);
157
+ const plugin = this.pluginManager.getInstance(normalizedPlatform);
158
+
159
+ const status = plugin?.status || stored.status || 'stopped';
160
+ const running = status === 'running';
161
+
162
+ return {
163
+ id: pluginId,
164
+ type: normalizedPlatform,
165
+ enabled: !!stored.enabled,
166
+ running,
167
+ status,
168
+ error: this.runtimeErrors[normalizedPlatform],
169
+ };
170
+ }
171
+
172
+ async startPlugin(platform = 'lark') {
173
+ const normalizedPlatform = normalizePlatform(platform);
174
+ const pluginId = toPluginId(normalizedPlatform);
175
+ const stored = this.store.getPluginConfig(pluginId);
176
+ const config = stored.config || {};
177
+
178
+ if (normalizedPlatform === 'dingtalk') {
179
+ if (!config.clientId || !config.clientSecret) {
180
+ throw new Error('请先配置 Client ID 和 Client Secret');
181
+ }
182
+ } else if (!config.appId || !config.appSecret) {
183
+ throw new Error('请先配置 App ID 和 App Secret');
184
+ }
185
+
186
+ this.runtimeErrors[normalizedPlatform] = null;
187
+
188
+ try {
189
+ await this.pluginManager.start(normalizedPlatform, config, this.handleIncomingMessage.bind(this));
190
+ this.store.updatePluginRuntimeStatus(pluginId, true, 'running');
191
+ return this.getStatus(normalizedPlatform);
192
+ } catch (error) {
193
+ const defaultError = normalizedPlatform === 'dingtalk' ? 'DingTalk 启动失败' : 'Lark 启动失败';
194
+ this.runtimeErrors[normalizedPlatform] = error?.message || defaultError;
195
+ this.store.updatePluginRuntimeStatus(pluginId, false, 'error');
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ async stopPlugin(platform = 'lark') {
201
+ const normalizedPlatform = normalizePlatform(platform);
202
+ const pluginId = toPluginId(normalizedPlatform);
203
+
204
+ await this.pluginManager.stop(normalizedPlatform);
205
+ this.store.updatePluginRuntimeStatus(pluginId, false, 'stopped');
206
+ this.runtimeErrors[normalizedPlatform] = null;
207
+ return this.getStatus(normalizedPlatform);
208
+ }
209
+
210
+ listAllowedUsers(platform = 'lark') {
211
+ return this.store.listAllowedUsers(normalizePlatform(platform));
212
+ }
213
+
214
+ addAllowedUser(platform = 'lark', payload) {
215
+ return this.store.addAllowedUser(normalizePlatform(platform), payload);
216
+ }
217
+
218
+ toggleAllowedUser(platform = 'lark', id, isActive) {
219
+ return this.store.toggleAllowedUser(normalizePlatform(platform), id, isActive);
220
+ }
221
+
222
+ removeAllowedUser(platform = 'lark', id) {
223
+ return this.store.removeAllowedUser(normalizePlatform(platform), id);
224
+ }
225
+
226
+ async handleIncomingMessage(incoming) {
227
+ const platform = normalizePlatform(incoming?.platform);
228
+ const queueKey = `${platform}:${incoming?.effectiveUserId || 'unknown'}:${incoming?.chatId || 'unknown'}`;
229
+
230
+ return this.enqueue(queueKey, async () => {
231
+ await this.processIncomingMessage({ ...incoming, platform });
232
+ });
233
+ }
234
+
235
+ enqueue(key, task) {
236
+ const previous = this.processingQueues.get(key) || Promise.resolve();
237
+
238
+ const next = previous
239
+ .catch(() => {})
240
+ .then(task)
241
+ .finally(() => {
242
+ if (this.processingQueues.get(key) === next) {
243
+ this.processingQueues.delete(key);
244
+ }
245
+ });
246
+
247
+ this.processingQueues.set(key, next);
248
+ return next;
249
+ }
250
+
251
+ async sendUnknownUserMessage(plugin, platform, chatId) {
252
+ if (platform === 'lark') {
253
+ await plugin.sendCard(chatId, createUnknownUserIdCard());
254
+ return;
255
+ }
256
+
257
+ await plugin.sendText(chatId, '无法识别你的钉钉用户ID,请联系管理员检查机器人事件权限。');
258
+ }
259
+
260
+ async sendUnauthorizedMessage(plugin, platform, chatId, effectiveUserId) {
261
+ if (platform === 'lark') {
262
+ await plugin.sendCard(chatId, createUnauthorizedUserCard(effectiveUserId));
263
+ return;
264
+ }
265
+
266
+ const message = [
267
+ `你的钉钉用户ID: ${effectiveUserId}`,
268
+ '',
269
+ '请将此 ID 添加到 AxHub 设置 > Channels > DingTalk 的允许用户列表。',
270
+ ].join('\n');
271
+
272
+ await plugin.sendText(chatId, message);
273
+ }
274
+
275
+ async processIncomingMessage(incoming) {
276
+ const platform = normalizePlatform(incoming?.platform);
277
+ const plugin = this.pluginManager.getInstance(platform);
278
+ if (!plugin) {
279
+ return;
280
+ }
281
+
282
+ const chatId = incoming?.chatId;
283
+ const effectiveUserId = incoming?.effectiveUserId;
284
+
285
+ if (!chatId) {
286
+ return;
287
+ }
288
+
289
+ if (!effectiveUserId) {
290
+ await this.sendUnknownUserMessage(plugin, platform, chatId);
291
+ return;
292
+ }
293
+
294
+ if (!this.store.isAllowedUser(platform, effectiveUserId)) {
295
+ await this.sendUnauthorizedMessage(plugin, platform, chatId, effectiveUserId);
296
+ return;
297
+ }
298
+
299
+ if (!isTextMessage(incoming?.messageType)) {
300
+ await plugin.sendText(chatId, '目前仅支持文本消息。');
301
+ return;
302
+ }
303
+
304
+ const text = String(incoming?.text || '').trim();
305
+ if (!text) {
306
+ await plugin.sendText(chatId, '请输入消息内容。');
307
+ return;
308
+ }
309
+
310
+ const config = this.store.getPluginConfig(toPluginId(platform)).config || {};
311
+ const backend = config.defaultBackend || 'codex';
312
+ const model = config.defaultModel || undefined;
313
+ const projectPath = config.defaultProjectPath || '';
314
+
315
+ if (!projectPath) {
316
+ const platformName = platform === 'dingtalk' ? 'DingTalk' : 'Lark';
317
+ await plugin.sendText(chatId, `请先在设置中配置默认项目路径(Channels > ${platformName} > 默认项目)。`);
318
+ return;
319
+ }
320
+
321
+ const existingSession = this.store.getChannelSession({
322
+ platform,
323
+ userId: effectiveUserId,
324
+ chatId,
325
+ backend,
326
+ });
327
+
328
+ const writer =
329
+ platform === 'dingtalk'
330
+ ? new DingTalkStreamWriter(plugin, chatId)
331
+ : new LarkStreamWriter(plugin, chatId);
332
+
333
+ let resolvedSessionId = existingSession?.providerSessionId || null;
334
+
335
+ try {
336
+ await writer.start('⏳ Thinking...');
337
+
338
+ const result = await runBackendSession({
339
+ backend,
340
+ message: text,
341
+ projectPath,
342
+ sessionId: resolvedSessionId,
343
+ model,
344
+ onSessionId: (sessionId) => {
345
+ resolvedSessionId = sessionId;
346
+ this.store.upsertChannelSession({
347
+ platform,
348
+ userId: effectiveUserId,
349
+ chatId,
350
+ backend,
351
+ providerSessionId: resolvedSessionId,
352
+ projectPath,
353
+ });
354
+ },
355
+ onDelta: (delta) => {
356
+ writer.append(delta);
357
+ },
358
+ });
359
+
360
+ if (result?.sessionId && !resolvedSessionId) {
361
+ resolvedSessionId = result.sessionId;
362
+ }
363
+
364
+ if (resolvedSessionId) {
365
+ this.store.upsertChannelSession({
366
+ platform,
367
+ userId: effectiveUserId,
368
+ chatId,
369
+ backend,
370
+ providerSessionId: resolvedSessionId,
371
+ projectPath,
372
+ });
373
+ }
374
+
375
+ await writer.finish();
376
+ } catch (error) {
377
+ await writer.fail(error?.message || '处理失败,请稍后重试。');
378
+ }
379
+ }
380
+
381
+ // Compatibility wrappers for legacy callers
382
+ async startLark() {
383
+ return this.startPlugin('lark');
384
+ }
385
+
386
+ async stopLark() {
387
+ return this.stopPlugin('lark');
388
+ }
389
+ }
390
+
391
+ let manager = null;
392
+
393
+ export function getChannelManager() {
394
+ if (!manager) {
395
+ manager = new ChannelManager();
396
+ }
397
+
398
+ return manager;
399
+ }
@@ -0,0 +1,59 @@
1
+ export class PluginManager {
2
+ constructor() {
3
+ this.registry = new Map();
4
+ this.instances = new Map();
5
+ }
6
+
7
+ register(type, PluginClass) {
8
+ this.registry.set(type, PluginClass);
9
+ }
10
+
11
+ getInstance(type) {
12
+ return this.instances.get(type) || null;
13
+ }
14
+
15
+ async start(type, config, messageHandler) {
16
+ if (this.instances.has(type)) {
17
+ return this.instances.get(type);
18
+ }
19
+
20
+ const PluginClass = this.registry.get(type);
21
+ if (!PluginClass) {
22
+ throw new Error(`Unknown plugin type: ${type}`);
23
+ }
24
+
25
+ const plugin = new PluginClass();
26
+ if (messageHandler) {
27
+ plugin.onMessage(messageHandler);
28
+ }
29
+
30
+ await plugin.initialize(config);
31
+ await plugin.start();
32
+ this.instances.set(type, plugin);
33
+ return plugin;
34
+ }
35
+
36
+ async stop(type) {
37
+ const plugin = this.instances.get(type);
38
+ if (!plugin) {
39
+ return;
40
+ }
41
+
42
+ await plugin.stop();
43
+ this.instances.delete(type);
44
+ }
45
+
46
+ async stopAll() {
47
+ const stops = [];
48
+ for (const [type, plugin] of this.instances.entries()) {
49
+ stops.push(
50
+ plugin.stop().catch((error) => {
51
+ console.error(`[PluginManager] Failed to stop plugin ${type}:`, error);
52
+ })
53
+ );
54
+ }
55
+
56
+ await Promise.allSettled(stops);
57
+ this.instances.clear();
58
+ }
59
+ }
@@ -0,0 +1,3 @@
1
+ export { getChannelManager, ChannelManager } from './core/ChannelManager.js';
2
+ export { LarkPlugin } from './plugins/lark/LarkPlugin.js';
3
+ export { DingTalkPlugin } from './plugins/dingtalk/DingTalkPlugin.js';
@@ -0,0 +1,46 @@
1
+ export class BasePlugin {
2
+ constructor(type) {
3
+ this.type = type;
4
+ this.status = 'created';
5
+ this.config = null;
6
+ this.messageHandler = null;
7
+ }
8
+
9
+ onMessage(handler) {
10
+ this.messageHandler = handler;
11
+ }
12
+
13
+ async initialize(config) {
14
+ this.config = config;
15
+ this.status = 'initializing';
16
+ await this.onInitialize(config);
17
+ this.status = 'ready';
18
+ }
19
+
20
+ async start() {
21
+ if (this.status !== 'ready' && this.status !== 'stopped') {
22
+ return;
23
+ }
24
+
25
+ this.status = 'starting';
26
+ await this.onStart();
27
+ this.status = 'running';
28
+ }
29
+
30
+ async stop() {
31
+ if (this.status !== 'running' && this.status !== 'starting') {
32
+ return;
33
+ }
34
+
35
+ this.status = 'stopping';
36
+ await this.onStop();
37
+ this.status = 'stopped';
38
+ }
39
+
40
+ // eslint-disable-next-line no-unused-vars
41
+ async onInitialize(_config) {}
42
+
43
+ async onStart() {}
44
+
45
+ async onStop() {}
46
+ }
@@ -0,0 +1,156 @@
1
+ export const DINGTALK_MESSAGE_LIMIT = 4000;
2
+
3
+ export function encodeChatId(data = {}) {
4
+ if (String(data.conversationType || '') === '1') {
5
+ return `user:${data.senderStaffId || data.chatbotUserId || ''}`;
6
+ }
7
+ return `group:${data.conversationId || ''}`;
8
+ }
9
+
10
+ export function parseChatId(chatId = '') {
11
+ if (chatId.startsWith('user:')) {
12
+ return { type: 'user', id: chatId.slice(5) };
13
+ }
14
+ if (chatId.startsWith('group:')) {
15
+ return { type: 'group', id: chatId.slice(6) };
16
+ }
17
+ return { type: 'user', id: chatId };
18
+ }
19
+
20
+ function normalizeText(text, conversationType) {
21
+ let result = String(text || '');
22
+ if (String(conversationType || '') === '2') {
23
+ result = result.replace(/@\S+\s*/g, '').trim();
24
+ }
25
+ return result;
26
+ }
27
+
28
+ function extractTextContent(data = {}) {
29
+ const msgType = String(data.msgtype || '');
30
+
31
+ if (msgType === 'text') {
32
+ return normalizeText(data.text?.content || '', data.conversationType);
33
+ }
34
+
35
+ if (msgType === 'richText') {
36
+ const text = (data.richText?.richTextList || [])
37
+ .filter((item) => item?.type === 'text')
38
+ .map((item) => item?.text || '')
39
+ .join('');
40
+ return normalizeText(text, data.conversationType);
41
+ }
42
+
43
+ return '';
44
+ }
45
+
46
+ export function toIncomingMessage(data = {}) {
47
+ const effectiveUserId = String(data.senderStaffId || '').trim();
48
+ const chatId = encodeChatId(data);
49
+ const messageType = String(data.msgtype || 'text');
50
+ const text = extractTextContent(data);
51
+
52
+ if (!effectiveUserId || !chatId) {
53
+ return null;
54
+ }
55
+
56
+ return {
57
+ id: data.msgId || `${Date.now()}`,
58
+ platform: 'dingtalk',
59
+ chatId,
60
+ effectiveUserId,
61
+ text,
62
+ messageType,
63
+ displayName: data.senderNick || `User ${effectiveUserId.slice(-6)}`,
64
+ raw: data,
65
+ };
66
+ }
67
+
68
+ export function toDingTalkSendParams(message = {}) {
69
+ if (message.replyMarkup && typeof message.replyMarkup === 'object') {
70
+ return {
71
+ contentType: 'actionCard',
72
+ content: message.replyMarkup,
73
+ };
74
+ }
75
+
76
+ if (Array.isArray(message.buttons) && message.buttons.length > 0) {
77
+ const card = buildActionCard(message.text || '', message.buttons);
78
+ return {
79
+ contentType: 'actionCard',
80
+ content: card,
81
+ };
82
+ }
83
+
84
+ const text = String(message.text || '');
85
+ return {
86
+ contentType: 'markdown',
87
+ content: {
88
+ title: 'Message',
89
+ text,
90
+ },
91
+ rawText: text,
92
+ };
93
+ }
94
+
95
+ function buildActionCard(text, buttons = []) {
96
+ const markdownText = convertHtmlToDingTalkMarkdown(text);
97
+ const btns = [];
98
+
99
+ for (const row of buttons) {
100
+ for (const button of row || []) {
101
+ btns.push({
102
+ title: button.label,
103
+ actionURL: `dingtalk://dingtalkclient/action/openAppAction?action=${encodeURIComponent(button.action || '')}&params=${encodeURIComponent(JSON.stringify(button.params || {}))}`,
104
+ });
105
+ }
106
+ }
107
+
108
+ return {
109
+ title: 'Message',
110
+ text: markdownText,
111
+ btnOrientation: '1',
112
+ btns,
113
+ };
114
+ }
115
+
116
+ export function convertHtmlToDingTalkMarkdown(input) {
117
+ if (!input) return '';
118
+
119
+ let text = String(input);
120
+
121
+ text = text
122
+ .replace(/"/gi, '"')
123
+ .replace(/'|'/gi, "'")
124
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
125
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10)));
126
+
127
+ text = text.replace(/<b>(.+?)<\/b>/gi, '**$1**');
128
+ text = text.replace(/<strong>(.+?)<\/strong>/gi, '**$1**');
129
+ text = text.replace(/<i>(.+?)<\/i>/gi, '*$1*');
130
+ text = text.replace(/<em>(.+?)<\/em>/gi, '*$1*');
131
+ text = text.replace(/<code>(.+?)<\/code>/gi, '`$1`');
132
+ text = text.replace(/<pre><code>([\s\S]+?)<\/code><\/pre>/gi, '```\n$1\n```');
133
+
134
+ text = text.replace(/<a href="([^"]+)">(.+?)<\/a>/gi, (_, url, label) => {
135
+ const normalized = String(url || '').trim().toLowerCase();
136
+ const safe = /^(https?:\/\/|mailto:|\/)|^[^:]*$/.test(normalized);
137
+ if (!safe) return label;
138
+ return `[${label}](${url})`;
139
+ });
140
+
141
+ let prev = '';
142
+ while (prev !== text) {
143
+ prev = text;
144
+ text = text.replace(/<[^>]+>/g, '');
145
+ }
146
+
147
+ return text;
148
+ }
149
+
150
+ export function truncateDingTalkText(text, maxLength = DINGTALK_MESSAGE_LIMIT) {
151
+ const normalized = String(text || '');
152
+ if (normalized.length <= maxLength) {
153
+ return normalized;
154
+ }
155
+ return `${normalized.slice(0, maxLength - 3)}...`;
156
+ }