opencode-dingtalk 0.2.0

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/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # @ali/opencode-dingtalk
2
+
3
+
4
+
5
+ ## Installation
6
+
7
+ Install my-project with npm
8
+
9
+ ```bash
10
+ npm install my-project
11
+ cd my-project
12
+ ```
13
+
14
+ ## Environment Variables
15
+
16
+ To run this project, you will need to add the following environment variables to your .env file
17
+
18
+ `API_KEY`
19
+
20
+ `ANOTHER_API_KEY`
21
+
22
+
23
+ ## Documentation
24
+
25
+ [Documentation](https://<your documentation link>)
26
+
27
+
28
+ ## Authors
29
+
30
+ - [@<you>](<how ot contact you>)
31
+
32
+
33
+ ## Contributing
34
+
35
+ Contributions are always welcome!
36
+
37
+ See `contributing.md` for ways to get started.
38
+
39
+ Please adhere to this project's `code of conduct`.
@@ -0,0 +1,107 @@
1
+ import { ConnectionManager, ConnectionState } from '../connection-manager.js';
2
+ describe('ConnectionManager', () => {
3
+ let manager;
4
+ beforeEach(() => {
5
+ manager = new ConnectionManager('test-instance', {
6
+ reconnectTimeoutMs: 200,
7
+ maxReconnectAttempts: 3,
8
+ watchdogIntervalMs: 100,
9
+ });
10
+ });
11
+ afterEach(() => {
12
+ manager.cleanup();
13
+ });
14
+ describe('state transitions', () => {
15
+ it('should start in DISCONNECTED state', () => {
16
+ expect(manager.getState()).toBe(ConnectionState.DISCONNECTED);
17
+ });
18
+ it('should transition to CONNECTED on start', () => {
19
+ const stateChanges = [];
20
+ manager.on('stateChange', (_oldState, newState) => {
21
+ stateChanges.push(newState);
22
+ });
23
+ manager.start();
24
+ expect(stateChanges).toContain(ConnectionState.CONNECTED);
25
+ expect(manager.getState()).toBe(ConnectionState.CONNECTED);
26
+ });
27
+ it('should transition to DISCONNECTED on stop', () => {
28
+ manager.start();
29
+ manager.stop();
30
+ expect(manager.getState()).toBe(ConnectionState.DISCONNECTED);
31
+ });
32
+ it('should not duplicate state on repeated start', () => {
33
+ manager.start();
34
+ manager.start();
35
+ expect(manager.getState()).toBe(ConnectionState.CONNECTED);
36
+ });
37
+ });
38
+ describe('metrics', () => {
39
+ it('should track connectedAt on start', () => {
40
+ manager.start();
41
+ const metrics = manager.getMetrics();
42
+ expect(metrics.connectedAt).not.toBeNull();
43
+ });
44
+ it('should clear connectedAt on stop', () => {
45
+ manager.start();
46
+ manager.stop();
47
+ const metrics = manager.getMetrics();
48
+ expect(metrics.connectedAt).toBeNull();
49
+ });
50
+ it('should accumulate totalConnectedMs', () => {
51
+ manager.start();
52
+ manager.stop();
53
+ const metrics = manager.getMetrics();
54
+ expect(metrics.totalConnectedMs).toBeGreaterThan(0);
55
+ });
56
+ it('should return empty metrics initially', () => {
57
+ const metrics = manager.getMetrics();
58
+ expect(metrics.connectedAt).toBeNull();
59
+ expect(metrics.totalConnectedMs).toBe(0);
60
+ expect(metrics.reconnectAttempts).toBe(0);
61
+ });
62
+ });
63
+ describe('isConnected', () => {
64
+ it('should return false when disconnected', () => {
65
+ expect(manager.isConnected).toBe(false);
66
+ });
67
+ it('should return true when connected', () => {
68
+ manager.start();
69
+ expect(manager.isConnected).toBe(true);
70
+ });
71
+ });
72
+ describe('config', () => {
73
+ it('should expose config via getter', () => {
74
+ const config = manager.config;
75
+ expect(config.reconnectTimeoutMs).toBe(200);
76
+ expect(config.maxReconnectAttempts).toBe(3);
77
+ });
78
+ it('should allow config updates', () => {
79
+ manager.updateConfig({ reconnectTimeoutMs: 500 });
80
+ expect(manager.config.reconnectTimeoutMs).toBe(500);
81
+ });
82
+ });
83
+ describe('events', () => {
84
+ it('should emit connected event on start', () => {
85
+ const connectedFn = jest.fn();
86
+ manager.on('connected', connectedFn);
87
+ manager.start();
88
+ expect(connectedFn).toHaveBeenCalled();
89
+ });
90
+ it('should emit disconnected event on stop', () => {
91
+ const disconnectedFn = jest.fn();
92
+ manager.on('disconnected', disconnectedFn);
93
+ manager.start();
94
+ manager.stop();
95
+ expect(disconnectedFn).toHaveBeenCalled();
96
+ });
97
+ });
98
+ describe('cleanup', () => {
99
+ it('should stop and remove all listeners', () => {
100
+ const handler = jest.fn();
101
+ manager.on('stateChange', handler);
102
+ manager.cleanup();
103
+ manager.start();
104
+ expect(handler).not.toHaveBeenCalled();
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,298 @@
1
+ import { MessageQueue, MessageType, MessageStatus } from '../message-queue.js';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ describe('MessageQueue', () => {
6
+ let queue;
7
+ let tempDir;
8
+ beforeEach(() => {
9
+ // 创建临时目录
10
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-test-'));
11
+ queue = new MessageQueue({
12
+ instanceId: 'test-instance',
13
+ storagePath: tempDir,
14
+ maxSize: 10,
15
+ inboundTTL: 1000,
16
+ outboundTTL: 2000,
17
+ maxRetries: 2,
18
+ retryDelays: [100, 200],
19
+ processInterval: 1000,
20
+ batchSize: 5,
21
+ });
22
+ });
23
+ afterEach(() => {
24
+ queue.stop();
25
+ // 清理临时目录
26
+ try {
27
+ fs.rmSync(tempDir, { recursive: true, force: true });
28
+ }
29
+ catch {
30
+ // 忽略清理错误
31
+ }
32
+ });
33
+ describe('enqueue', () => {
34
+ it('should add item to queue', () => {
35
+ const id = queue.enqueue({
36
+ type: MessageType.INBOUND,
37
+ content: 'test message',
38
+ target: 'user123',
39
+ maxRetries: 3,
40
+ });
41
+ expect(id).toBeDefined();
42
+ expect(typeof id).toBe('string');
43
+ const stats = queue.getStats();
44
+ expect(stats.total).toBe(1);
45
+ expect(stats.pending).toBe(1);
46
+ });
47
+ it('should generate unique IDs for each message', () => {
48
+ const id1 = queue.enqueue({
49
+ type: MessageType.INBOUND,
50
+ content: 'message 1',
51
+ target: 'user123',
52
+ maxRetries: 3,
53
+ });
54
+ const id2 = queue.enqueue({
55
+ type: MessageType.INBOUND,
56
+ content: 'message 2',
57
+ target: 'user123',
58
+ maxRetries: 3,
59
+ });
60
+ expect(id1).not.toBe(id2);
61
+ });
62
+ it('should evict oldest item when queue is full', () => {
63
+ // 添加 10 条消息(达到 maxSize)
64
+ const ids = [];
65
+ for (let i = 0; i < 10; i++) {
66
+ const id = queue.enqueue({
67
+ type: MessageType.INBOUND,
68
+ content: `message ${i}`,
69
+ target: 'user123',
70
+ maxRetries: 3,
71
+ });
72
+ ids.push(id);
73
+ }
74
+ // 添加第 11 条,应该驱逐第 1 条
75
+ const newId = queue.enqueue({
76
+ type: MessageType.INBOUND,
77
+ content: 'message 10',
78
+ target: 'user123',
79
+ maxRetries: 3,
80
+ });
81
+ const stats = queue.getStats();
82
+ // 队列应该保持在 maxSize 以内
83
+ expect(stats.total).toBeLessThanOrEqual(10);
84
+ // 新消息应该存在
85
+ const newItem = queue.getItem(newId);
86
+ expect(newItem).toBeDefined();
87
+ });
88
+ it('should set correct TTL based on message type', () => {
89
+ const inboundId = queue.enqueue({
90
+ type: MessageType.INBOUND,
91
+ content: 'inbound',
92
+ target: 'user123',
93
+ maxRetries: 3,
94
+ });
95
+ const outboundId = queue.enqueue({
96
+ type: MessageType.OUTBOUND,
97
+ content: 'outbound',
98
+ target: 'user123',
99
+ maxRetries: 3,
100
+ });
101
+ const inboundItem = queue.getItem(inboundId);
102
+ const outboundItem = queue.getItem(outboundId);
103
+ expect(inboundItem).toBeDefined();
104
+ expect(outboundItem).toBeDefined();
105
+ // 入站消息 TTL 应该更短
106
+ const inboundTtl = inboundItem.expiresAt - inboundItem.createdAt;
107
+ const outboundTtl = outboundItem.expiresAt - outboundItem.createdAt;
108
+ expect(inboundTtl).toBeLessThan(outboundTtl);
109
+ });
110
+ });
111
+ describe('dequeue', () => {
112
+ it('should return items in FIFO order', () => {
113
+ queue.enqueue({ type: MessageType.INBOUND, content: 'first', target: 'user123', maxRetries: 3 });
114
+ queue.enqueue({ type: MessageType.INBOUND, content: 'second', target: 'user123', maxRetries: 3 });
115
+ const first = queue.dequeue(MessageType.INBOUND);
116
+ const second = queue.dequeue(MessageType.INBOUND);
117
+ expect(first).toBeDefined();
118
+ expect(second).toBeDefined();
119
+ expect(first.content).toBe('first');
120
+ expect(second.content).toBe('second');
121
+ });
122
+ it('should filter by type', () => {
123
+ queue.enqueue({ type: MessageType.INBOUND, content: 'inbound', target: 'user123', maxRetries: 3 });
124
+ queue.enqueue({ type: MessageType.OUTBOUND, content: 'outbound', target: 'user123', maxRetries: 3 });
125
+ const inboundItem = queue.dequeue(MessageType.INBOUND);
126
+ expect(inboundItem).toBeDefined();
127
+ expect(inboundItem.type).toBe(MessageType.INBOUND);
128
+ const outboundItem = queue.dequeue(MessageType.OUTBOUND);
129
+ expect(outboundItem).toBeDefined();
130
+ expect(outboundItem.type).toBe(MessageType.OUTBOUND);
131
+ });
132
+ it('should return null when queue is empty', () => {
133
+ const item = queue.dequeue(MessageType.INBOUND);
134
+ expect(item).toBeNull();
135
+ });
136
+ it('should mark item as SENDING after dequeue', () => {
137
+ const id = queue.enqueue({ type: MessageType.INBOUND, content: 'test', target: 'user123', maxRetries: 3 });
138
+ queue.dequeue(MessageType.INBOUND);
139
+ const item = queue.getItem(id);
140
+ expect(item).toBeDefined();
141
+ expect(item.status).toBe(MessageStatus.SENDING);
142
+ });
143
+ });
144
+ describe('ack', () => {
145
+ it('should mark item as acked and remove from queue', () => {
146
+ const id = queue.enqueue({ type: MessageType.INBOUND, content: 'test', target: 'user123', maxRetries: 3 });
147
+ queue.ack(id);
148
+ const stats = queue.getStats();
149
+ expect(stats.total).toBe(0);
150
+ const item = queue.getItem(id);
151
+ expect(item).toBeUndefined();
152
+ });
153
+ it('should do nothing for non-existent ID', () => {
154
+ queue.ack('non-existent-id');
155
+ const stats = queue.getStats();
156
+ expect(stats.total).toBe(0);
157
+ });
158
+ });
159
+ describe('nack', () => {
160
+ it('should retry item if retries remaining', () => {
161
+ const id = queue.enqueue({ type: MessageType.OUTBOUND, content: 'test', target: 'user123', maxRetries: 3 });
162
+ queue.nack(id, 'network error');
163
+ const item = queue.getItem(id);
164
+ expect(item).toBeDefined();
165
+ expect(item.retryCount).toBe(1);
166
+ expect(item.status).toBe(MessageStatus.PENDING);
167
+ expect(item.lastError).toBe('network error');
168
+ });
169
+ it('should mark as failed if max retries exceeded', () => {
170
+ const id = queue.enqueue({ type: MessageType.OUTBOUND, content: 'test', target: 'user123', maxRetries: 1 });
171
+ queue.nack(id, 'error 1');
172
+ queue.nack(id, 'error 2');
173
+ const item = queue.getItem(id);
174
+ expect(item).toBeDefined();
175
+ expect(item.status).toBe(MessageStatus.FAILED);
176
+ expect(item.retryCount).toBe(2);
177
+ });
178
+ it('should record error history', () => {
179
+ const id = queue.enqueue({ type: MessageType.OUTBOUND, content: 'test', target: 'user123', maxRetries: 3 });
180
+ queue.nack(id, 'error 1');
181
+ queue.nack(id, 'error 2');
182
+ const item = queue.getItem(id);
183
+ expect(item).toBeDefined();
184
+ expect(item.errorHistory).toBeDefined();
185
+ expect(item.errorHistory.length).toBe(2);
186
+ });
187
+ });
188
+ describe('drain', () => {
189
+ it('should return multiple items of specified type', () => {
190
+ for (let i = 0; i < 5; i++) {
191
+ queue.enqueue({ type: MessageType.INBOUND, content: `msg ${i}`, target: 'user123', maxRetries: 3 });
192
+ }
193
+ const items = queue.drain(MessageType.INBOUND, 3);
194
+ expect(items.length).toBe(3);
195
+ expect(items[0].content).toBe('msg 0');
196
+ expect(items[1].content).toBe('msg 1');
197
+ expect(items[2].content).toBe('msg 2');
198
+ });
199
+ it('should mark items as SENDING', () => {
200
+ queue.enqueue({ type: MessageType.INBOUND, content: 'test', target: 'user123', maxRetries: 3 });
201
+ const items = queue.drain(MessageType.INBOUND, 10);
202
+ expect(items.length).toBe(1);
203
+ expect(items[0].status).toBe(MessageStatus.SENDING);
204
+ });
205
+ it('should return empty array when no items match', () => {
206
+ queue.enqueue({ type: MessageType.OUTBOUND, content: 'outbound', target: 'user123', maxRetries: 3 });
207
+ const items = queue.drain(MessageType.INBOUND, 10);
208
+ expect(items.length).toBe(0);
209
+ });
210
+ });
211
+ describe('cleanup', () => {
212
+ it('should remove expired items', async () => {
213
+ queue.enqueue({ type: MessageType.INBOUND, content: 'test', target: 'user123', maxRetries: 3 });
214
+ // 等待 TTL 过期
215
+ await new Promise(resolve => setTimeout(resolve, 1500));
216
+ const result = queue.cleanup();
217
+ expect(result.expired).toBe(1);
218
+ const stats = queue.getStats();
219
+ expect(stats.total).toBe(0);
220
+ });
221
+ it('should not remove non-expired items', () => {
222
+ queue.enqueue({ type: MessageType.OUTBOUND, content: 'test', target: 'user123', maxRetries: 3 });
223
+ const result = queue.cleanup();
224
+ expect(result.expired).toBe(0);
225
+ const stats = queue.getStats();
226
+ expect(stats.total).toBe(1);
227
+ });
228
+ });
229
+ describe('persistence', () => {
230
+ it('should save and load queue', async () => {
231
+ queue.enqueue({ type: MessageType.INBOUND, content: 'test', target: 'user123', maxRetries: 3 });
232
+ await queue.save();
233
+ // 创建新队列实例
234
+ const queue2 = new MessageQueue({
235
+ instanceId: 'test-instance',
236
+ storagePath: tempDir,
237
+ });
238
+ await queue2.load();
239
+ const stats = queue2.getStats();
240
+ expect(stats.total).toBe(1);
241
+ queue2.stop();
242
+ });
243
+ it('should handle missing file gracefully', async () => {
244
+ const queue2 = new MessageQueue({
245
+ instanceId: 'non-existent',
246
+ storagePath: tempDir,
247
+ });
248
+ await queue2.load();
249
+ const stats = queue2.getStats();
250
+ expect(stats.total).toBe(0);
251
+ queue2.stop();
252
+ });
253
+ });
254
+ describe('getStats', () => {
255
+ it('should return correct statistics', () => {
256
+ queue.enqueue({ type: MessageType.INBOUND, content: 'pending', target: 'user123', maxRetries: 3 });
257
+ queue.enqueue({ type: MessageType.INBOUND, content: 'sending', target: 'user123', maxRetries: 3 });
258
+ queue.enqueue({ type: MessageType.OUTBOUND, content: 'failed', target: 'user123', maxRetries: 1 });
259
+ // 标记一个为 SENDING
260
+ queue.dequeue(MessageType.INBOUND);
261
+ // 标记一个为 FAILED
262
+ const failedId = queue.dequeue(MessageType.OUTBOUND).id;
263
+ queue.nack(failedId, 'error');
264
+ queue.nack(failedId, 'error');
265
+ const stats = queue.getStats();
266
+ expect(stats.total).toBe(3);
267
+ expect(stats.pending).toBe(1);
268
+ expect(stats.sending).toBe(1);
269
+ expect(stats.failed).toBe(1);
270
+ });
271
+ });
272
+ describe('peek', () => {
273
+ it('should return next item without removing it', () => {
274
+ queue.enqueue({ type: MessageType.INBOUND, content: 'first', target: 'user123', maxRetries: 3 });
275
+ queue.enqueue({ type: MessageType.INBOUND, content: 'second', target: 'user123', maxRetries: 3 });
276
+ const peeked = queue.peek(MessageType.INBOUND);
277
+ expect(peeked).toBeDefined();
278
+ expect(peeked.content).toBe('first');
279
+ const stats = queue.getStats();
280
+ expect(stats.total).toBe(2);
281
+ });
282
+ it('should return null when queue is empty', () => {
283
+ const peeked = queue.peek(MessageType.INBOUND);
284
+ expect(peeked).toBeNull();
285
+ });
286
+ });
287
+ describe('lifecycle', () => {
288
+ it('should start and stop without errors', () => {
289
+ expect(() => queue.start()).not.toThrow();
290
+ expect(() => queue.stop()).not.toThrow();
291
+ });
292
+ it('should not start twice', () => {
293
+ queue.start();
294
+ queue.start(); // 应该不会抛出错误
295
+ queue.stop();
296
+ });
297
+ });
298
+ });
package/dist/config.js ADDED
@@ -0,0 +1,181 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { logger } from "./logger.js";
5
+ // ============ Config File Paths ============
6
+ const CONFIG_FILENAME = "opencode-dingtalk.json";
7
+ const LEGACY_CONFIG_FILENAME = "lingji-bot-core.json";
8
+ function getConfigPaths() {
9
+ const paths = [];
10
+ const opencodeHome = process.env.OPENCODE_HOME;
11
+ if (opencodeHome && opencodeHome.length > 0) {
12
+ paths.push(path.join(opencodeHome, CONFIG_FILENAME));
13
+ paths.push(path.join(opencodeHome, LEGACY_CONFIG_FILENAME));
14
+ }
15
+ const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
16
+ paths.push(path.join(xdgConfig, "opencode", CONFIG_FILENAME));
17
+ paths.push(path.join(xdgConfig, "opencode", LEGACY_CONFIG_FILENAME));
18
+ return paths;
19
+ }
20
+ // ============ Config File Loading ============
21
+ let cachedFileConfig = null;
22
+ let configFilePath = null;
23
+ export function loadFileConfig(forceReload = false) {
24
+ if (cachedFileConfig && !forceReload) {
25
+ return cachedFileConfig;
26
+ }
27
+ const paths = getConfigPaths();
28
+ for (const cp of paths) {
29
+ try {
30
+ const raw = fs.readFileSync(cp, "utf8");
31
+ const parsed = JSON.parse(raw);
32
+ // For single-instance callers, return the first object or first array element
33
+ if (Array.isArray(parsed)) {
34
+ cachedFileConfig = parsed[0] ?? {};
35
+ }
36
+ else {
37
+ cachedFileConfig = parsed;
38
+ }
39
+ configFilePath = cp;
40
+ logger.log(`[opencode-dingtalk] Config loaded from ${cp}`);
41
+ return cachedFileConfig;
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ }
47
+ cachedFileConfig = {};
48
+ configFilePath = null;
49
+ return {};
50
+ }
51
+ export function getConfigFilePath() {
52
+ return configFilePath;
53
+ }
54
+ export function getDefaultConfigPath() {
55
+ const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
56
+ return path.join(xdgConfig, "opencode", CONFIG_FILENAME);
57
+ }
58
+ // ============ Config Resolution (single instance) ============
59
+ export function resolveConfig(params = {}) {
60
+ const file = loadFileConfig();
61
+ const messageTypeRaw = params.messageType ||
62
+ process.env.DINGTALK_MESSAGE_TYPE ||
63
+ file.messageType ||
64
+ "markdown";
65
+ const messageType = messageTypeRaw === "text" ||
66
+ messageTypeRaw === "markdown" ||
67
+ messageTypeRaw === "card"
68
+ ? messageTypeRaw
69
+ : "markdown";
70
+ const clientId = params.clientId || process.env.DINGTALK_CLIENT_ID || file.clientId;
71
+ return {
72
+ name: file.name || clientId,
73
+ clientId,
74
+ clientSecret: params.clientSecret ||
75
+ process.env.DINGTALK_CLIENT_SECRET ||
76
+ file.clientSecret,
77
+ robotCode: params.robotCode || process.env.DINGTALK_ROBOT_CODE || file.robotCode,
78
+ messageType,
79
+ cardTemplateId: params.cardTemplateId ||
80
+ process.env.DINGTALK_CARD_TEMPLATE_ID ||
81
+ file.cardTemplateId,
82
+ opencodeUrl: params.opencodeUrl ||
83
+ process.env.OPENCODE_URL ||
84
+ file.opencodeUrl ||
85
+ "http://127.0.0.1:4096",
86
+ autoStart: file.autoStart ?? false,
87
+ notifyLevel: (() => {
88
+ const raw = process.env.DINGTALK_NOTIFY_LEVEL || file.notifyLevel || "normal";
89
+ return raw === "minimal" || raw === "normal" || raw === "verbose"
90
+ ? raw
91
+ : "normal";
92
+ })(),
93
+ atAllKeywords: file.atAllKeywords || [],
94
+ };
95
+ }
96
+ // ============ Multi-Instance Config Resolution ============
97
+ /**
98
+ * Resolve all instance configs from the config file.
99
+ *
100
+ * Config file root type determines the mode:
101
+ * - Object → single instance (wrapped in array)
102
+ * - Array → multiple instances
103
+ *
104
+ * Each element is resolved independently with env-var fallbacks.
105
+ */
106
+ export function resolveInstances() {
107
+ const paths = getConfigPaths();
108
+ for (const cp of paths) {
109
+ try {
110
+ const raw = fs.readFileSync(cp, "utf8");
111
+ const parsed = JSON.parse(raw);
112
+ configFilePath = cp;
113
+ const items = Array.isArray(parsed)
114
+ ? parsed
115
+ : [parsed];
116
+ return items.map((item) => resolveInstanceConfig(item));
117
+ }
118
+ catch {
119
+ continue;
120
+ }
121
+ }
122
+ // No config file found — try env vars as a single instance
123
+ return [resolveConfig()];
124
+ }
125
+ function resolveInstanceConfig(item) {
126
+ const messageTypeRaw = item.messageType || "markdown";
127
+ const messageType = messageTypeRaw === "text" ||
128
+ messageTypeRaw === "markdown" ||
129
+ messageTypeRaw === "card"
130
+ ? messageTypeRaw
131
+ : "markdown";
132
+ const clientId = item.clientId || process.env.DINGTALK_CLIENT_ID;
133
+ return {
134
+ name: item.name || clientId,
135
+ clientId,
136
+ clientSecret: item.clientSecret || process.env.DINGTALK_CLIENT_SECRET,
137
+ robotCode: item.robotCode,
138
+ messageType,
139
+ cardTemplateId: item.cardTemplateId,
140
+ opencodeUrl: item.opencodeUrl || "http://127.0.0.1:4096",
141
+ autoStart: item.autoStart ?? false,
142
+ notifyLevel: (() => {
143
+ const raw = item.notifyLevel || "normal";
144
+ return raw === "minimal" || raw === "normal" || raw === "verbose"
145
+ ? raw
146
+ : "normal";
147
+ })(),
148
+ atAllKeywords: item.atAllKeywords || [],
149
+ };
150
+ }
151
+ // ============ Config Template ============
152
+ export function generateConfigTemplate() {
153
+ const template = {
154
+ clientId: "your-dingtalk-app-key",
155
+ clientSecret: "your-dingtalk-app-secret",
156
+ robotCode: "your-robot-code",
157
+ messageType: "markdown",
158
+ cardTemplateId: "",
159
+ opencodeUrl: "http://127.0.0.1:4096",
160
+ autoStart: false,
161
+ notifyLevel: "normal",
162
+ atAllKeywords: [
163
+ "代码",
164
+ "程序",
165
+ "bug",
166
+ "错误",
167
+ "修复",
168
+ "开发",
169
+ "代码",
170
+ "program",
171
+ "error",
172
+ "fix",
173
+ "dev",
174
+ "帮助",
175
+ "怎么",
176
+ "如何",
177
+ "为什么",
178
+ ],
179
+ };
180
+ return JSON.stringify(template, null, 2) + "\n";
181
+ }