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 +39 -0
- package/dist/__tests__/connection-manager.test.js +107 -0
- package/dist/__tests__/message-queue.test.js +298 -0
- package/dist/config.js +181 -0
- package/dist/connection-manager.js +103 -0
- package/dist/dingtalk/bot.js +2129 -0
- package/dist/dingtalk/dingtalk-api.js +126 -0
- package/dist/dingtalk/dingtalk-client.js +390 -0
- package/dist/dingtalk/types.js +8 -0
- package/dist/events.js +423 -0
- package/dist/index.js +164 -0
- package/dist/index.test.js +6 -0
- package/dist/instance.js +49 -0
- package/dist/lock.js +57 -0
- package/dist/logger.js +43 -0
- package/dist/message-queue.js +604 -0
- package/dist/registry.js +77 -0
- package/dist/standalone.js +110 -0
- package/dist/state.js +550 -0
- package/dist/types/dingtalk-stub.js +3 -0
- package/dist/utils.js +171 -0
- package/package.json +56 -0
package/dist/lock.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getDataDir } from "./utils.js";
|
|
4
|
+
function getLockFilePath(clientId) {
|
|
5
|
+
return path.join(getDataDir(), "bot-" + clientId + ".lock");
|
|
6
|
+
}
|
|
7
|
+
function isProcessAlive(pid) {
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Acquire a file-based lock so only one process can run the bot per clientId.
|
|
18
|
+
* Returns true if lock acquired, false if another live process holds the lock.
|
|
19
|
+
*/
|
|
20
|
+
export function acquireBotLock(clientId) {
|
|
21
|
+
const lockPath = getLockFilePath(clientId);
|
|
22
|
+
try {
|
|
23
|
+
const raw = fs.readFileSync(lockPath, "utf8");
|
|
24
|
+
const lock = JSON.parse(raw);
|
|
25
|
+
if (lock.pid !== process.pid && isProcessAlive(lock.pid)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// no lock file or unreadable, safe to acquire
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
34
|
+
const info = { pid: process.pid, startedAt: Date.now() };
|
|
35
|
+
fs.writeFileSync(lockPath, JSON.stringify(info) + "\n");
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Release the file lock, but only if current process owns it (PID matches).
|
|
44
|
+
*/
|
|
45
|
+
export function releaseBotLock(clientId) {
|
|
46
|
+
const lockPath = getLockFilePath(clientId);
|
|
47
|
+
try {
|
|
48
|
+
const raw = fs.readFileSync(lockPath, "utf8");
|
|
49
|
+
const lock = JSON.parse(raw);
|
|
50
|
+
if (lock.pid === process.pid) {
|
|
51
|
+
fs.unlinkSync(lockPath);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// ignore
|
|
56
|
+
}
|
|
57
|
+
}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { appendFileSync } from "fs";
|
|
2
|
+
import { format } from "util";
|
|
3
|
+
/**
|
|
4
|
+
* 获取当前小时的日志文件名
|
|
5
|
+
* 格式: opencode-dingtalk.2026-04-03_01.log
|
|
6
|
+
*/
|
|
7
|
+
function getLogFileName() {
|
|
8
|
+
const now = new Date();
|
|
9
|
+
const year = now.getFullYear();
|
|
10
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
11
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
12
|
+
const hour = String(now.getHours()).padStart(2, "0");
|
|
13
|
+
return `opencode-dingtalk.${year}-${month}-${day}_${hour}.log`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 获取日志文件完整路径
|
|
17
|
+
*/
|
|
18
|
+
function getLogFilePath() {
|
|
19
|
+
const fileName = getLogFileName();
|
|
20
|
+
return `/tmp/${fileName}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 将日志写入文件
|
|
24
|
+
*/
|
|
25
|
+
function writeToFile(level, ...args) {
|
|
26
|
+
try {
|
|
27
|
+
const logFilePath = getLogFilePath();
|
|
28
|
+
const timestamp = new Date().toISOString();
|
|
29
|
+
const message = format(...args);
|
|
30
|
+
const logLine = `[${timestamp}] [${level}] ${message}\n`;
|
|
31
|
+
appendFileSync(logFilePath, logLine, { encoding: "utf-8" });
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
// 如果写入失败,静默处理,避免影响主程序
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export const logger = {
|
|
38
|
+
log: (...args) => writeToFile("LOG", ...args),
|
|
39
|
+
info: (...args) => writeToFile("INFO", ...args),
|
|
40
|
+
warn: (...args) => writeToFile("WARN", ...args),
|
|
41
|
+
error: (...args) => writeToFile("ERROR", ...args),
|
|
42
|
+
debug: (...args) => writeToFile("DEBUG", ...args),
|
|
43
|
+
};
|
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { getDataDir } from "./utils.js";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
6
|
+
// ============ Type Definitions ============
|
|
7
|
+
export var MessageStatus;
|
|
8
|
+
(function (MessageStatus) {
|
|
9
|
+
MessageStatus["PENDING"] = "pending";
|
|
10
|
+
MessageStatus["SENDING"] = "sending";
|
|
11
|
+
MessageStatus["FAILED"] = "failed";
|
|
12
|
+
MessageStatus["EXPIRED"] = "expired";
|
|
13
|
+
MessageStatus["ACKED"] = "acked"; // 已确认成功
|
|
14
|
+
})(MessageStatus || (MessageStatus = {}));
|
|
15
|
+
export var MessageType;
|
|
16
|
+
(function (MessageType) {
|
|
17
|
+
MessageType["INBOUND"] = "inbound";
|
|
18
|
+
MessageType["OUTBOUND"] = "outbound"; // 出站消息(机器人发送给用户)
|
|
19
|
+
})(MessageType || (MessageType = {}));
|
|
20
|
+
// 兼容性别名
|
|
21
|
+
export const QueueType = MessageType;
|
|
22
|
+
// ============ Default Configuration ============
|
|
23
|
+
const DEFAULT_CONFIG = {
|
|
24
|
+
storagePath: getDataDir(),
|
|
25
|
+
instanceId: 'default',
|
|
26
|
+
inboundTTL: 5 * 60 * 1000, // 5 分钟
|
|
27
|
+
outboundTTL: 60 * 60 * 1000, // 1 小时
|
|
28
|
+
maxSize: 1000,
|
|
29
|
+
maxRetries: 3,
|
|
30
|
+
retryDelays: [2000, 5000, 10000], // 2s, 5s, 10s
|
|
31
|
+
processInterval: 30000, // 30 秒
|
|
32
|
+
batchSize: 10
|
|
33
|
+
};
|
|
34
|
+
// ============ MessageQueue Class ============
|
|
35
|
+
export class MessageQueue {
|
|
36
|
+
config;
|
|
37
|
+
items;
|
|
38
|
+
processTimer;
|
|
39
|
+
saveTimer;
|
|
40
|
+
lockPath;
|
|
41
|
+
filePath;
|
|
42
|
+
constructor(configOrInstanceId) {
|
|
43
|
+
// 支持两种调用方式:
|
|
44
|
+
// 1. new MessageQueue(instanceId: string)
|
|
45
|
+
// 2. new MessageQueue(config: Partial<QueueConfig> & { instanceId: string })
|
|
46
|
+
if (typeof configOrInstanceId === 'string') {
|
|
47
|
+
this.config = { ...DEFAULT_CONFIG, instanceId: configOrInstanceId };
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
this.config = { ...DEFAULT_CONFIG, ...configOrInstanceId };
|
|
51
|
+
}
|
|
52
|
+
this.items = new Map();
|
|
53
|
+
// 确保存储目录存在
|
|
54
|
+
this.ensureStorageDir();
|
|
55
|
+
// 设置文件路径
|
|
56
|
+
this.lockPath = path.join(this.config.storagePath, `message-queue-${this.config.instanceId}.lock`);
|
|
57
|
+
this.filePath = path.join(this.config.storagePath, `message-queue-${this.config.instanceId}.json`);
|
|
58
|
+
}
|
|
59
|
+
// ============ Storage Helpers ============
|
|
60
|
+
ensureStorageDir() {
|
|
61
|
+
try {
|
|
62
|
+
fs.mkdirSync(this.config.storagePath, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// 忽略目录创建错误
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
getQueueFilePath() {
|
|
69
|
+
return this.filePath;
|
|
70
|
+
}
|
|
71
|
+
getLockFilePath() {
|
|
72
|
+
return this.lockPath;
|
|
73
|
+
}
|
|
74
|
+
// ============ File Lock ============
|
|
75
|
+
async acquireLock(timeoutMs = 5000) {
|
|
76
|
+
const lockPath = this.getLockFilePath();
|
|
77
|
+
const startTime = Date.now();
|
|
78
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
79
|
+
try {
|
|
80
|
+
// 尝试创建锁文件(独占模式)
|
|
81
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
82
|
+
// 写入进程ID和时间戳
|
|
83
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, timestamp: Date.now() }));
|
|
84
|
+
return fd;
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err.code === 'EEXIST') {
|
|
88
|
+
// 锁文件已存在,检查是否过期(超过30秒视为过期)
|
|
89
|
+
try {
|
|
90
|
+
const lockData = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
91
|
+
if (Date.now() - lockData.timestamp > 30000) {
|
|
92
|
+
// 锁已过期,删除并重试
|
|
93
|
+
fs.unlinkSync(lockPath);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// 锁文件损坏,删除并重试
|
|
99
|
+
try {
|
|
100
|
+
fs.unlinkSync(lockPath);
|
|
101
|
+
}
|
|
102
|
+
catch { }
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// 等待100ms后重试
|
|
106
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// 其他错误,返回 null
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null; // 超时
|
|
115
|
+
}
|
|
116
|
+
releaseLock(fd) {
|
|
117
|
+
if (fd === null)
|
|
118
|
+
return;
|
|
119
|
+
try {
|
|
120
|
+
fs.closeSync(fd);
|
|
121
|
+
fs.unlinkSync(this.getLockFilePath());
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// 忽略释放锁时的错误
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// ============ Core Operations ============
|
|
128
|
+
/**
|
|
129
|
+
* 添加消息到队列
|
|
130
|
+
* 支持两种调用方式:
|
|
131
|
+
* 1. enqueue(item: Omit<QueueItem, ...>): string
|
|
132
|
+
* 2. enqueue(type: MessageType, payload: unknown, options?: {...}): string
|
|
133
|
+
*
|
|
134
|
+
* @returns 生成的消息 ID
|
|
135
|
+
*/
|
|
136
|
+
enqueue(itemOrType, payload, options = {}) {
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
const id = randomUUID();
|
|
139
|
+
let item;
|
|
140
|
+
// 判断调用方式
|
|
141
|
+
if (typeof itemOrType === 'string') {
|
|
142
|
+
// 方式2: enqueue(type, payload, options)
|
|
143
|
+
item = {
|
|
144
|
+
type: itemOrType,
|
|
145
|
+
content: typeof payload === 'string' ? payload : JSON.stringify(payload),
|
|
146
|
+
target: options.userId || 'unknown',
|
|
147
|
+
topic: options.topic,
|
|
148
|
+
maxRetries: options.maxRetries ?? this.config.maxRetries,
|
|
149
|
+
metadata: {
|
|
150
|
+
messageId: options.messageId,
|
|
151
|
+
originalPayload: payload,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// 方式1: enqueue(item)
|
|
157
|
+
item = itemOrType;
|
|
158
|
+
}
|
|
159
|
+
// 根据消息类型计算过期时间
|
|
160
|
+
const ttl = item.type === MessageType.INBOUND
|
|
161
|
+
? this.config.inboundTTL
|
|
162
|
+
: this.config.outboundTTL;
|
|
163
|
+
const queueItem = {
|
|
164
|
+
...item,
|
|
165
|
+
id,
|
|
166
|
+
status: MessageStatus.PENDING,
|
|
167
|
+
createdAt: now,
|
|
168
|
+
updatedAt: now,
|
|
169
|
+
expiresAt: now + ttl,
|
|
170
|
+
retryCount: 0,
|
|
171
|
+
maxRetries: item.maxRetries ?? this.config.maxRetries,
|
|
172
|
+
};
|
|
173
|
+
// 检查队列容量
|
|
174
|
+
if (this.items.size >= this.config.maxSize) {
|
|
175
|
+
this.evictOldest();
|
|
176
|
+
}
|
|
177
|
+
this.items.set(id, queueItem);
|
|
178
|
+
logger.info(`[MessageQueue] Message enqueued: ${id}, type=${item.type}, target=${item.target}`);
|
|
179
|
+
// 异步保存
|
|
180
|
+
this.save().catch(err => {
|
|
181
|
+
logger.error(`[MessageQueue] Failed to save after enqueue: ${err}`);
|
|
182
|
+
});
|
|
183
|
+
return id;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 获取下一个待处理消息
|
|
187
|
+
* @param type 可选,指定消息类型
|
|
188
|
+
* @returns 队列项或 null
|
|
189
|
+
*/
|
|
190
|
+
dequeue(type) {
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
for (const [id, item] of this.items) {
|
|
193
|
+
// 过滤条件:状态为 PENDING,未过期,类型匹配
|
|
194
|
+
if (item.status !== MessageStatus.PENDING)
|
|
195
|
+
continue;
|
|
196
|
+
if (item.expiresAt < now) {
|
|
197
|
+
// 标记为过期
|
|
198
|
+
item.status = MessageStatus.EXPIRED;
|
|
199
|
+
item.updatedAt = now;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (type && item.type !== type)
|
|
203
|
+
continue;
|
|
204
|
+
// 检查重试延迟
|
|
205
|
+
if (item.nextRetryAt && item.nextRetryAt > now)
|
|
206
|
+
continue;
|
|
207
|
+
// 更新状态为 SENDING
|
|
208
|
+
item.status = MessageStatus.SENDING;
|
|
209
|
+
item.updatedAt = now;
|
|
210
|
+
return item;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* 确认消息处理成功
|
|
216
|
+
* @param id 消息 ID
|
|
217
|
+
*/
|
|
218
|
+
ack(id) {
|
|
219
|
+
const item = this.items.get(id);
|
|
220
|
+
if (!item)
|
|
221
|
+
return;
|
|
222
|
+
item.status = MessageStatus.ACKED;
|
|
223
|
+
item.updatedAt = Date.now();
|
|
224
|
+
logger.info(`[MessageQueue] Message acked: ${id}`);
|
|
225
|
+
// 从队列中移除
|
|
226
|
+
this.items.delete(id);
|
|
227
|
+
// 异步保存
|
|
228
|
+
this.save().catch(err => {
|
|
229
|
+
logger.error(`[MessageQueue] Failed to save after ack: ${err}`);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 消息处理失败,重新入队
|
|
234
|
+
* @param id 消息 ID
|
|
235
|
+
* @param error 错误信息
|
|
236
|
+
*/
|
|
237
|
+
nack(id, error) {
|
|
238
|
+
const item = this.items.get(id);
|
|
239
|
+
if (!item)
|
|
240
|
+
return;
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
item.retryCount++;
|
|
243
|
+
item.lastError = error;
|
|
244
|
+
item.updatedAt = now;
|
|
245
|
+
// 记录错误历史
|
|
246
|
+
if (!item.errorHistory) {
|
|
247
|
+
item.errorHistory = [];
|
|
248
|
+
}
|
|
249
|
+
item.errorHistory.push(`[${new Date(now).toISOString()}] ${error || 'Unknown error'}`);
|
|
250
|
+
// 检查是否超过最大重试次数
|
|
251
|
+
if (item.retryCount >= item.maxRetries) {
|
|
252
|
+
item.status = MessageStatus.FAILED;
|
|
253
|
+
logger.warn(`[MessageQueue] Message failed after ${item.retryCount} retries: ${id}`);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
// 计算下次重试时间
|
|
257
|
+
const delayIndex = Math.min(item.retryCount - 1, this.config.retryDelays.length - 1);
|
|
258
|
+
const delay = this.config.retryDelays[delayIndex];
|
|
259
|
+
item.nextRetryAt = now + delay;
|
|
260
|
+
item.lastRetryAt = now;
|
|
261
|
+
item.status = MessageStatus.PENDING;
|
|
262
|
+
logger.info(`[MessageQueue] Message nacked, will retry in ${delay}ms: ${id}`);
|
|
263
|
+
}
|
|
264
|
+
// 异步保存
|
|
265
|
+
this.save().catch(err => {
|
|
266
|
+
logger.error(`[MessageQueue] Failed to save after nack: ${err}`);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* 查看队列头部消息(不移除)
|
|
271
|
+
* @param type 可选,指定消息类型
|
|
272
|
+
* @returns 队列项或 null
|
|
273
|
+
*/
|
|
274
|
+
peek(type) {
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
for (const [id, item] of this.items) {
|
|
277
|
+
if (item.status !== MessageStatus.PENDING)
|
|
278
|
+
continue;
|
|
279
|
+
if (item.expiresAt < now)
|
|
280
|
+
continue;
|
|
281
|
+
if (type && item.type !== type)
|
|
282
|
+
continue;
|
|
283
|
+
if (item.nextRetryAt && item.nextRetryAt > now)
|
|
284
|
+
continue;
|
|
285
|
+
return item;
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* 批量获取消息
|
|
291
|
+
* @param type 消息类型
|
|
292
|
+
* @param count 获取数量
|
|
293
|
+
* @returns 队列项数组
|
|
294
|
+
*/
|
|
295
|
+
drain(type, count) {
|
|
296
|
+
const result = [];
|
|
297
|
+
const now = Date.now();
|
|
298
|
+
for (const [id, item] of this.items) {
|
|
299
|
+
if (result.length >= count)
|
|
300
|
+
break;
|
|
301
|
+
if (item.status !== MessageStatus.PENDING)
|
|
302
|
+
continue;
|
|
303
|
+
if (item.expiresAt < now) {
|
|
304
|
+
item.status = MessageStatus.EXPIRED;
|
|
305
|
+
item.updatedAt = now;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (item.type !== type)
|
|
309
|
+
continue;
|
|
310
|
+
if (item.nextRetryAt && item.nextRetryAt > now)
|
|
311
|
+
continue;
|
|
312
|
+
item.status = MessageStatus.SENDING;
|
|
313
|
+
item.updatedAt = now;
|
|
314
|
+
result.push(item);
|
|
315
|
+
}
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* 清理过期消息
|
|
320
|
+
* @returns 清理的数量
|
|
321
|
+
*/
|
|
322
|
+
cleanup() {
|
|
323
|
+
const now = Date.now();
|
|
324
|
+
let expired = 0;
|
|
325
|
+
let evicted = 0;
|
|
326
|
+
for (const [id, item] of this.items) {
|
|
327
|
+
// 检查过期
|
|
328
|
+
if (item.expiresAt < now && item.status !== MessageStatus.EXPIRED) {
|
|
329
|
+
item.status = MessageStatus.EXPIRED;
|
|
330
|
+
item.updatedAt = now;
|
|
331
|
+
expired++;
|
|
332
|
+
}
|
|
333
|
+
// 移除已过期或已确认的消息
|
|
334
|
+
if (item.status === MessageStatus.EXPIRED || item.status === MessageStatus.ACKED) {
|
|
335
|
+
this.items.delete(id);
|
|
336
|
+
evicted++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (expired > 0 || evicted > 0) {
|
|
340
|
+
logger.info(`[MessageQueue] Cleanup: expired=${expired}, evicted=${evicted}`);
|
|
341
|
+
// 异步保存
|
|
342
|
+
this.save().catch(err => {
|
|
343
|
+
logger.error(`[MessageQueue] Failed to save after cleanup: ${err}`);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return { expired, evicted };
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* 获取队列统计信息
|
|
350
|
+
*/
|
|
351
|
+
getStats() {
|
|
352
|
+
const now = Date.now();
|
|
353
|
+
let pending = 0;
|
|
354
|
+
let sending = 0;
|
|
355
|
+
let failed = 0;
|
|
356
|
+
let expired = 0;
|
|
357
|
+
let totalRetryCount = 0;
|
|
358
|
+
let oldestTimestamp = now;
|
|
359
|
+
let oldestPendingTimestamp = now;
|
|
360
|
+
for (const item of this.items.values()) {
|
|
361
|
+
switch (item.status) {
|
|
362
|
+
case MessageStatus.PENDING:
|
|
363
|
+
pending++;
|
|
364
|
+
if (item.createdAt < oldestPendingTimestamp) {
|
|
365
|
+
oldestPendingTimestamp = item.createdAt;
|
|
366
|
+
}
|
|
367
|
+
break;
|
|
368
|
+
case MessageStatus.SENDING:
|
|
369
|
+
sending++;
|
|
370
|
+
break;
|
|
371
|
+
case MessageStatus.FAILED:
|
|
372
|
+
failed++;
|
|
373
|
+
break;
|
|
374
|
+
case MessageStatus.EXPIRED:
|
|
375
|
+
expired++;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
totalRetryCount += item.retryCount;
|
|
379
|
+
if (item.createdAt < oldestTimestamp) {
|
|
380
|
+
oldestTimestamp = item.createdAt;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const total = this.items.size;
|
|
384
|
+
const oldestAge = total > 0 ? now - oldestTimestamp : 0;
|
|
385
|
+
const oldestPendingAge = pending > 0 ? now - oldestPendingTimestamp : 0;
|
|
386
|
+
const avgRetryCount = total > 0 ? totalRetryCount / total : 0;
|
|
387
|
+
return {
|
|
388
|
+
total,
|
|
389
|
+
pending,
|
|
390
|
+
sending,
|
|
391
|
+
processing: sending, // 别名
|
|
392
|
+
failed,
|
|
393
|
+
expired,
|
|
394
|
+
oldestAge,
|
|
395
|
+
oldestPendingAge,
|
|
396
|
+
avgRetryCount
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* 获取入站队列统计
|
|
401
|
+
*/
|
|
402
|
+
getInboundStats() {
|
|
403
|
+
return this.getStatsByType(MessageType.INBOUND);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* 获取出站队列统计
|
|
407
|
+
*/
|
|
408
|
+
getOutboundStats() {
|
|
409
|
+
return this.getStatsByType(MessageType.OUTBOUND);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* 按类型获取队列统计信息
|
|
413
|
+
*/
|
|
414
|
+
getStatsByType(type) {
|
|
415
|
+
const now = Date.now();
|
|
416
|
+
let pending = 0;
|
|
417
|
+
let sending = 0;
|
|
418
|
+
let failed = 0;
|
|
419
|
+
let expired = 0;
|
|
420
|
+
let totalRetryCount = 0;
|
|
421
|
+
let oldestTimestamp = now;
|
|
422
|
+
let oldestPendingTimestamp = now;
|
|
423
|
+
for (const item of this.items.values()) {
|
|
424
|
+
if (item.type !== type)
|
|
425
|
+
continue;
|
|
426
|
+
switch (item.status) {
|
|
427
|
+
case MessageStatus.PENDING:
|
|
428
|
+
pending++;
|
|
429
|
+
if (item.createdAt < oldestPendingTimestamp) {
|
|
430
|
+
oldestPendingTimestamp = item.createdAt;
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
case MessageStatus.SENDING:
|
|
434
|
+
sending++;
|
|
435
|
+
break;
|
|
436
|
+
case MessageStatus.FAILED:
|
|
437
|
+
failed++;
|
|
438
|
+
break;
|
|
439
|
+
case MessageStatus.EXPIRED:
|
|
440
|
+
expired++;
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
totalRetryCount += item.retryCount;
|
|
444
|
+
if (item.createdAt < oldestTimestamp) {
|
|
445
|
+
oldestTimestamp = item.createdAt;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const total = pending + sending + failed + expired;
|
|
449
|
+
const oldestAge = total > 0 ? now - oldestTimestamp : 0;
|
|
450
|
+
const oldestPendingAge = pending > 0 ? now - oldestPendingTimestamp : 0;
|
|
451
|
+
const avgRetryCount = total > 0 ? totalRetryCount / total : 0;
|
|
452
|
+
return {
|
|
453
|
+
total,
|
|
454
|
+
pending,
|
|
455
|
+
sending,
|
|
456
|
+
processing: sending, // 别名
|
|
457
|
+
failed,
|
|
458
|
+
expired,
|
|
459
|
+
oldestAge,
|
|
460
|
+
oldestPendingAge,
|
|
461
|
+
avgRetryCount
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* 获取指定消息
|
|
466
|
+
*/
|
|
467
|
+
getItem(id) {
|
|
468
|
+
return this.items.get(id);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* 获取所有消息(用于诊断)
|
|
472
|
+
*/
|
|
473
|
+
getAllItems() {
|
|
474
|
+
return Array.from(this.items.values());
|
|
475
|
+
}
|
|
476
|
+
// ============ Persistence ============
|
|
477
|
+
/**
|
|
478
|
+
* 从磁盘加载队列
|
|
479
|
+
*/
|
|
480
|
+
async load() {
|
|
481
|
+
const filePath = this.getQueueFilePath();
|
|
482
|
+
const fd = await this.acquireLock();
|
|
483
|
+
if (fd === null) {
|
|
484
|
+
logger.warn('[MessageQueue] Failed to acquire lock for load');
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
if (!fs.existsSync(filePath)) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
492
|
+
const data = JSON.parse(raw);
|
|
493
|
+
// 验证数据格式
|
|
494
|
+
if (data.version !== 1) {
|
|
495
|
+
logger.warn(`[MessageQueue] Unknown version: ${data.version}, skipping load`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (data.instanceId !== this.config.instanceId) {
|
|
499
|
+
logger.warn(`[MessageQueue] Instance ID mismatch: ${data.instanceId} vs ${this.config.instanceId}`);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
// 加载消息
|
|
503
|
+
this.items.clear();
|
|
504
|
+
for (const item of data.items) {
|
|
505
|
+
// 验证必要字段
|
|
506
|
+
if (!item.id || !item.type || !item.status) {
|
|
507
|
+
logger.warn(`[MessageQueue] Invalid item, skipping: ${JSON.stringify(item)}`);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
this.items.set(item.id, item);
|
|
511
|
+
}
|
|
512
|
+
logger.info(`[MessageQueue] Loaded ${this.items.size} items from disk`);
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
logger.error(`[MessageQueue] Failed to load: ${err}`);
|
|
516
|
+
}
|
|
517
|
+
finally {
|
|
518
|
+
this.releaseLock(fd);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* 保存队列到磁盘
|
|
523
|
+
*/
|
|
524
|
+
async save() {
|
|
525
|
+
const filePath = this.getQueueFilePath();
|
|
526
|
+
const fd = await this.acquireLock();
|
|
527
|
+
if (fd === null) {
|
|
528
|
+
logger.warn('[MessageQueue] Failed to acquire lock for save');
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
this.ensureStorageDir();
|
|
533
|
+
const data = {
|
|
534
|
+
version: 1,
|
|
535
|
+
instanceId: this.config.instanceId,
|
|
536
|
+
updatedAt: Date.now(),
|
|
537
|
+
items: Array.from(this.items.values())
|
|
538
|
+
};
|
|
539
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
logger.error(`[MessageQueue] Failed to save: ${err}`);
|
|
543
|
+
}
|
|
544
|
+
finally {
|
|
545
|
+
this.releaseLock(fd);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// ============ Lifecycle ============
|
|
549
|
+
/**
|
|
550
|
+
* 启动队列处理定时器
|
|
551
|
+
*/
|
|
552
|
+
start() {
|
|
553
|
+
if (this.processTimer) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
// 启动处理定时器
|
|
557
|
+
this.processTimer = setInterval(() => {
|
|
558
|
+
this.cleanup();
|
|
559
|
+
}, this.config.processInterval);
|
|
560
|
+
// 启动保存定时器(每5秒保存一次)
|
|
561
|
+
this.saveTimer = setInterval(() => {
|
|
562
|
+
this.save().catch(err => {
|
|
563
|
+
logger.error(`[MessageQueue] Periodic save failed: ${err}`);
|
|
564
|
+
});
|
|
565
|
+
}, 5000);
|
|
566
|
+
logger.info(`[MessageQueue] Started with instanceId=${this.config.instanceId}`);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* 停止队列处理定时器
|
|
570
|
+
*/
|
|
571
|
+
stop() {
|
|
572
|
+
if (this.processTimer) {
|
|
573
|
+
clearInterval(this.processTimer);
|
|
574
|
+
this.processTimer = undefined;
|
|
575
|
+
}
|
|
576
|
+
if (this.saveTimer) {
|
|
577
|
+
clearInterval(this.saveTimer);
|
|
578
|
+
this.saveTimer = undefined;
|
|
579
|
+
}
|
|
580
|
+
// 最后保存一次
|
|
581
|
+
this.save().catch(err => {
|
|
582
|
+
logger.error(`[MessageQueue] Final save failed: ${err}`);
|
|
583
|
+
});
|
|
584
|
+
logger.info(`[MessageQueue] Stopped`);
|
|
585
|
+
}
|
|
586
|
+
// ============ Private Helpers ============
|
|
587
|
+
/**
|
|
588
|
+
* 驱逐最旧的消息
|
|
589
|
+
*/
|
|
590
|
+
evictOldest() {
|
|
591
|
+
let oldestId = null;
|
|
592
|
+
let oldestTimestamp = Date.now();
|
|
593
|
+
for (const [id, item] of this.items) {
|
|
594
|
+
if (item.createdAt < oldestTimestamp) {
|
|
595
|
+
oldestTimestamp = item.createdAt;
|
|
596
|
+
oldestId = id;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (oldestId) {
|
|
600
|
+
this.items.delete(oldestId);
|
|
601
|
+
logger.warn(`[MessageQueue] Evicted oldest message: ${oldestId}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|