koishi-plugin-docker-control 0.0.1

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.
@@ -0,0 +1,509 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DockerNode = void 0;
4
+ /**
5
+ * Docker 节点类 - 通过 SSH 执行 docker 命令
6
+ */
7
+ const koishi_1 = require("koishi");
8
+ const constants_1 = require("../constants");
9
+ const connector_1 = require("./connector");
10
+ const logger_1 = require("../utils/logger");
11
+ // 容器事件类型映射
12
+ const CONTAINER_ACTIONS = ['start', 'stop', 'restart', 'die', 'create', 'destroy', 'pause', 'unpause', 'health_status'];
13
+ class DockerNode {
14
+ constructor(config, credential, debug = false) {
15
+ /** 节点状态 */
16
+ this.status = constants_1.NodeStatus.DISCONNECTED;
17
+ /** SSH 连接器 */
18
+ this.connector = null;
19
+ /** 监控定时器 (容器状态轮询) */
20
+ this.monitorTimer = null;
21
+ /** 事件监控定时器 (docker events) */
22
+ this.eventTimer = null;
23
+ /** 上次事件查询时间 */
24
+ this.lastEventTime = 0;
25
+ /** 上次容器状态快照 */
26
+ this.lastContainerStates = new Map();
27
+ /** 事件回调 */
28
+ this.eventCallbacks = new Set();
29
+ /** Debug 模式 */
30
+ this.debug = false;
31
+ /** 用于事件去重: 记录 "ID:Action:Time" -> Timestamp */
32
+ this.eventDedupMap = new Map();
33
+ /** [新增] 实例唯一标识,用于判断是否存在多实例冲突 */
34
+ this.instanceId = koishi_1.Random.id(4);
35
+ this.config = config;
36
+ this.credential = credential;
37
+ this.debug = debug;
38
+ }
39
+ /**
40
+ * 连接到 Docker (带重试)
41
+ */
42
+ async connect() {
43
+ if (this.status === constants_1.NodeStatus.CONNECTING) {
44
+ logger_1.nodeLogger.warn(`[${this.name}] 节点正在连接中,跳过`);
45
+ return;
46
+ }
47
+ this.status = constants_1.NodeStatus.CONNECTING;
48
+ let attempt = 0;
49
+ let lastError = null;
50
+ while (attempt < constants_1.MAX_RETRY_COUNT) {
51
+ attempt++;
52
+ logger_1.nodeLogger.info(`[${this.name}] 连接尝试 ${attempt}/${constants_1.MAX_RETRY_COUNT}...`);
53
+ try {
54
+ // 创建 connector
55
+ const connector = new connector_1.DockerConnector(this.config, { credentials: [this.credential], nodes: [this.config] });
56
+ this.connector = connector;
57
+ // 测试 SSH 连接和 docker 命令
58
+ await connector.exec('docker version --format "{{.Server.Version}}"');
59
+ // 标记连接可用,允许事件流自动重连
60
+ connector.setConnected(true);
61
+ this.status = constants_1.NodeStatus.CONNECTED;
62
+ logger_1.nodeLogger.info(`[${this.name}] 连接成功`);
63
+ // 启动监控
64
+ this.startMonitoring();
65
+ // 触发上线事件
66
+ this.emitEvent({
67
+ Type: 'node',
68
+ Action: 'online',
69
+ Actor: { ID: this.config.id, Attributes: {} },
70
+ scope: 'local',
71
+ time: Date.now(),
72
+ timeNano: Date.now() * 1e6,
73
+ });
74
+ return;
75
+ }
76
+ catch (error) {
77
+ lastError = error instanceof Error ? error : new Error(String(error));
78
+ logger_1.nodeLogger.warn(`[${this.name}] 连接失败: ${lastError.message}`);
79
+ // 清理连接
80
+ this.connector?.dispose();
81
+ this.connector = null;
82
+ // 如果还有重试次数,等待后重试
83
+ if (attempt < constants_1.MAX_RETRY_COUNT) {
84
+ await new Promise(resolve => setTimeout(resolve, constants_1.RETRY_INTERVAL));
85
+ }
86
+ }
87
+ }
88
+ // 所有重试都失败
89
+ this.status = constants_1.NodeStatus.ERROR;
90
+ logger_1.nodeLogger.error(`[${this.name}] 连接失败,已重试 ${constants_1.MAX_RETRY_COUNT} 次`);
91
+ }
92
+ /**
93
+ * 断开连接
94
+ */
95
+ async disconnect() {
96
+ this.stopMonitoring();
97
+ this.clearTimers();
98
+ this.connector?.dispose();
99
+ this.connector = null;
100
+ this.status = constants_1.NodeStatus.DISCONNECTED;
101
+ logger_1.nodeLogger.info(`[${this.name}] 已断开连接`);
102
+ }
103
+ /**
104
+ * 重新连接
105
+ */
106
+ async reconnect() {
107
+ await this.disconnect();
108
+ await this.connect();
109
+ }
110
+ /**
111
+ * 列出容器
112
+ */
113
+ async listContainers(all = true) {
114
+ if (!this.connector || this.status !== constants_1.NodeStatus.CONNECTED) {
115
+ throw new Error(`节点 ${this.name} 未连接`);
116
+ }
117
+ const output = await this.connector.listContainers(all);
118
+ return this.parseContainerList(output);
119
+ }
120
+ /**
121
+ * 启动容器
122
+ */
123
+ async startContainer(containerId) {
124
+ if (!this.connector)
125
+ throw new Error('未连接');
126
+ await this.connector.startContainer(containerId);
127
+ }
128
+ /**
129
+ * 停止容器
130
+ */
131
+ async stopContainer(containerId, timeout = 10) {
132
+ if (!this.connector)
133
+ throw new Error('未连接');
134
+ await this.connector.stopContainer(containerId, timeout);
135
+ }
136
+ /**
137
+ * 重启容器
138
+ */
139
+ async restartContainer(containerId, timeout = 10) {
140
+ if (!this.connector)
141
+ throw new Error('未连接');
142
+ await this.connector.restartContainer(containerId, timeout);
143
+ }
144
+ /**
145
+ * 获取容器日志
146
+ */
147
+ async getContainerLogs(containerId, tail = 100) {
148
+ if (!this.connector)
149
+ throw new Error('未连接');
150
+ return this.connector.getLogs(containerId, tail);
151
+ }
152
+ /**
153
+ * 执行容器内命令
154
+ */
155
+ async execContainer(containerId, cmd) {
156
+ if (!this.connector)
157
+ throw new Error('未连接');
158
+ return this.connector.execContainer(containerId, cmd);
159
+ }
160
+ /**
161
+ * 解析 docker ps 输出
162
+ */
163
+ parseContainerList(output) {
164
+ if (!output.trim())
165
+ return [];
166
+ return output.split('\n').filter(Boolean).map(line => {
167
+ const parts = line.split('|');
168
+ return {
169
+ Id: parts[0] || '',
170
+ Names: [parts[1] || ''],
171
+ Image: parts[2] || '',
172
+ State: this.mapState(parts[3] || ''),
173
+ Status: parts[4] || '',
174
+ ImageID: '',
175
+ Command: '',
176
+ Created: 0,
177
+ Ports: [],
178
+ Labels: {},
179
+ HostConfig: { NetworkMode: '' },
180
+ NetworkSettings: { Networks: {} },
181
+ };
182
+ });
183
+ }
184
+ /**
185
+ * 映射容器状态
186
+ */
187
+ mapState(state) {
188
+ const s = state.toLowerCase();
189
+ if (s.includes('up') || s.includes('running'))
190
+ return 'running';
191
+ if (s.includes('exited') || s.includes('stopped'))
192
+ return 'stopped';
193
+ if (s.includes('paused'))
194
+ return 'paused';
195
+ if (s.includes('restarting'))
196
+ return 'restarting';
197
+ return 'created';
198
+ }
199
+ /**
200
+ * 启动监控 (容器状态轮询 + 事件流监听)
201
+ */
202
+ startMonitoring() {
203
+ this.stopMonitoring();
204
+ // 初始化容器状态快照
205
+ this.initializeContainerStates();
206
+ // 事件流监听:使用 docker events 流式获取
207
+ this.startEventStream();
208
+ // 状态监控:每 60 秒检查容器状态并检测变更
209
+ // (用于捕获可能遗漏的状态变化,以及启动时的初始状态)
210
+ this.monitorTimer = setInterval(async () => {
211
+ if (this.status !== constants_1.NodeStatus.CONNECTED)
212
+ return;
213
+ try {
214
+ const containers = await this.listContainers(true);
215
+ this.checkContainerStateChanges(containers);
216
+ }
217
+ catch (e) {
218
+ logger_1.nodeLogger.warn(`[${this.name}] 监控失败: ${e}`);
219
+ }
220
+ }, constants_1.CONTAINER_POLL_INTERVAL);
221
+ logger_1.nodeLogger.info(`[${this.name}] 监控已启动 (事件流 + 每 ${constants_1.CONTAINER_POLL_INTERVAL / 1000} 秒状态检查)`);
222
+ }
223
+ /**
224
+ * 启动 Docker 事件流监听
225
+ */
226
+ startEventStream() {
227
+ if (!this.connector)
228
+ return;
229
+ // 防止并发启动:使用 _startingStream 标志
230
+ if (this._startingStream) {
231
+ logger_1.nodeLogger.debug(`[${this.name}] 事件流正在启动中,跳过`);
232
+ return;
233
+ }
234
+ ;
235
+ this._startingStream = true;
236
+ // 检查是否已有活跃的流
237
+ if (this._activeStreamCount > 0) {
238
+ logger_1.nodeLogger.debug(`[${this.name}] 已有 ${this._activeStreamCount} 个活跃事件流,跳过启动`);
239
+ this._startingStream = false;
240
+ return;
241
+ }
242
+ ;
243
+ this._activeStreamCount = this._activeStreamCount || 0;
244
+ this._activeStreamCount++;
245
+ logger_1.nodeLogger.debug(`[${this.name}] 启动事件流 (活跃数: ${this._activeStreamCount})`);
246
+ this.connector.startEventStream((line) => {
247
+ this.handleEventLine(line);
248
+ }).then((stop) => {
249
+ ;
250
+ this._eventStreamStop = stop;
251
+ this._startingStream = false;
252
+ logger_1.nodeLogger.debug(`[${this.name}] 事件流回调已注册`);
253
+ }).catch((err) => {
254
+ ;
255
+ this._activeStreamCount--;
256
+ this._startingStream = false;
257
+ logger_1.nodeLogger.warn(`[${this.name}] 事件流启动失败: ${err.message},5秒后重试`);
258
+ setTimeout(() => this.startEventStream(), 5000);
259
+ });
260
+ }
261
+ /**
262
+ * 处理事件流中的一行数据
263
+ */
264
+ handleEventLine(line) {
265
+ try {
266
+ const rawEvent = JSON.parse(line);
267
+ const { Type: type, Action: action, Actor: actor, time, timeNano } = rawEvent;
268
+ // 只处理容器相关事件
269
+ if (type !== 'container')
270
+ return;
271
+ if (!CONTAINER_ACTIONS.includes(action))
272
+ return;
273
+ const containerId = actor?.ID;
274
+ const containerName = actor?.Attributes?.name;
275
+ // [去重逻辑] 使用 timeNano (纳秒) 确保唯一性
276
+ const eventTimeNano = timeNano || (time ? time * 1e9 : Date.now() * 1e6);
277
+ const dedupKey = `${containerId}:${action}:${eventTimeNano}`;
278
+ const lastTime = this.eventDedupMap.get(dedupKey);
279
+ const now = Date.now();
280
+ // 100ms 内收到完全相同的事件则忽略
281
+ if (lastTime && (now - lastTime < 100)) {
282
+ return;
283
+ }
284
+ this.eventDedupMap.set(dedupKey, now);
285
+ // 清理
286
+ if (this.eventDedupMap.size > 200)
287
+ this.eventDedupMap.clear();
288
+ // 跳过无法识别名称的容器
289
+ if (!containerName || containerName === 'unknown')
290
+ return;
291
+ const image = actor?.Attributes?.image;
292
+ // [关键] 对于 die 和 stop,都标记为 stopped,保持状态同步
293
+ if (actor?.ID) {
294
+ const inferredState = (action === 'start' || action === 'restart') ? 'running' : 'stopped';
295
+ this.lastContainerStates.set(actor.ID, inferredState);
296
+ }
297
+ const event = {
298
+ Type: type,
299
+ Action: action,
300
+ Actor: {
301
+ ID: actor?.ID || '',
302
+ Attributes: {
303
+ name: containerName,
304
+ image: image || '',
305
+ },
306
+ },
307
+ scope: 'local',
308
+ time: time ? time * 1000 : Date.now(),
309
+ timeNano: timeNano || Date.now() * 1e6,
310
+ };
311
+ logger_1.nodeLogger.debug(`[${this.name}#${this.instanceId}] 事件流: ${containerName} ${action}`);
312
+ this.emitEvent(event);
313
+ }
314
+ catch (e) {
315
+ // 忽略非 JSON 行
316
+ }
317
+ }
318
+ /**
319
+ * 初始化容器状态快照
320
+ */
321
+ async initializeContainerStates() {
322
+ try {
323
+ const containers = await this.listContainers(true);
324
+ this.lastContainerStates.clear();
325
+ for (const c of containers) {
326
+ this.lastContainerStates.set(c.Id, c.State);
327
+ }
328
+ this.lastEventTime = Date.now();
329
+ logger_1.nodeLogger.debug(`[${this.name}] 初始化状态快照: ${this.lastContainerStates.size} 个容器`);
330
+ }
331
+ catch (e) {
332
+ logger_1.nodeLogger.warn(`[${this.name}] 初始化状态快照失败: ${e}`);
333
+ }
334
+ }
335
+ /**
336
+ * 检测容器状态变更并发送通知
337
+ */
338
+ checkContainerStateChanges(containers) {
339
+ const runningCount = containers.filter(c => c.State === 'running').length;
340
+ logger_1.nodeLogger.debug(`[${this.name}] 监控: ${runningCount} 个容器运行中`);
341
+ for (const c of containers) {
342
+ const lastState = this.lastContainerStates.get(c.Id);
343
+ const currentState = c.State;
344
+ // 状态发生变化
345
+ if (lastState !== undefined && lastState !== currentState) {
346
+ const containerName = c.Names[0]?.replace('/', '') || c.Id.slice(0, 8);
347
+ // 推断操作类型
348
+ let action;
349
+ if (lastState !== 'running' && currentState === 'running') {
350
+ action = 'start';
351
+ }
352
+ else if (lastState === 'running' && currentState !== 'running') {
353
+ action = 'stop';
354
+ }
355
+ else {
356
+ action = currentState;
357
+ }
358
+ logger_1.nodeLogger.info(`[${this.name}] 状态变更: ${containerName} ${lastState} -> ${currentState}`);
359
+ // 发送事件通知
360
+ const event = {
361
+ Type: 'container',
362
+ Action: action,
363
+ Actor: {
364
+ ID: c.Id,
365
+ Attributes: {
366
+ name: containerName,
367
+ image: c.Image,
368
+ },
369
+ },
370
+ scope: 'local',
371
+ time: Date.now(),
372
+ timeNano: Date.now() * 1e6,
373
+ };
374
+ this.emitEvent(event);
375
+ }
376
+ // 更新状态快照
377
+ this.lastContainerStates.set(c.Id, currentState);
378
+ }
379
+ }
380
+ /**
381
+ * 轮询 Docker 事件
382
+ */
383
+ async pollEvents() {
384
+ if (!this.connector)
385
+ return;
386
+ try {
387
+ // 查询指定时间之后的事件
388
+ // 查询指定时间之后的事件 - 使用 JSON 格式以避免解析问题
389
+ const since = new Date(this.lastEventTime).toISOString();
390
+ const output = await this.connector.exec(`docker events --since "${since}" --format "{{json .}}" --filter "type=container"`);
391
+ this.lastEventTime = Date.now();
392
+ if (!output.trim())
393
+ return;
394
+ const lines = output.split('\n').filter(Boolean);
395
+ for (const line of lines) {
396
+ try {
397
+ const rawEvent = JSON.parse(line);
398
+ const { Type: type, Action: action, Actor: actor, time, timeNano } = rawEvent;
399
+ // 只处理容器相关事件
400
+ if (type !== 'container')
401
+ continue;
402
+ if (!CONTAINER_ACTIONS.includes(action))
403
+ continue;
404
+ const containerName = actor?.Attributes?.name;
405
+ const image = actor?.Attributes?.image;
406
+ // 跳过无法识别名称的容器
407
+ if (!containerName || containerName === 'unknown')
408
+ continue;
409
+ const event = {
410
+ Type: type,
411
+ Action: action,
412
+ Actor: {
413
+ ID: actor?.ID || '',
414
+ Attributes: {
415
+ name: containerName,
416
+ image: image || '',
417
+ },
418
+ },
419
+ scope: 'local',
420
+ time: time ? time * 1000 : Date.now(), // docker event time is usually unix timestamp (seconds)
421
+ timeNano: timeNano || Date.now() * 1e6,
422
+ };
423
+ logger_1.nodeLogger.debug(`[${this.name}] 事件: ${containerName} ${action}`);
424
+ this.emitEvent(event);
425
+ }
426
+ catch (e) {
427
+ logger_1.nodeLogger.warn(`[${this.name}] 解析事件失败: ${e} (Line: ${line})`);
428
+ }
429
+ }
430
+ }
431
+ catch (e) {
432
+ // 忽略事件查询错误(可能是没有新事件)
433
+ logger_1.nodeLogger.warn(`[${this.name}] 事件轮询失败: ${e}`);
434
+ }
435
+ }
436
+ /**
437
+ * 停止监控
438
+ */
439
+ stopMonitoring() {
440
+ // 停止状态轮询
441
+ if (this.monitorTimer) {
442
+ clearInterval(this.monitorTimer);
443
+ this.monitorTimer = null;
444
+ }
445
+ // 停止事件流
446
+ if (this._eventStreamStop) {
447
+ ;
448
+ this._eventStreamStop();
449
+ this._eventStreamStop = null;
450
+ }
451
+ // 重置事件流计数
452
+ ;
453
+ this._activeStreamCount = 0;
454
+ this._startingStream = false;
455
+ // 停止重试定时器
456
+ if (this.eventTimer) {
457
+ clearTimeout(this.eventTimer);
458
+ this.eventTimer = null;
459
+ }
460
+ // 标记连接断开,防止自动重连
461
+ if (this.connector) {
462
+ this.connector.setConnected(false);
463
+ }
464
+ }
465
+ /**
466
+ * 订阅事件
467
+ */
468
+ onEvent(callback) {
469
+ this.eventCallbacks.add(callback);
470
+ return () => this.eventCallbacks.delete(callback);
471
+ }
472
+ /**
473
+ * 触发事件
474
+ */
475
+ emitEvent(event) {
476
+ for (const callback of this.eventCallbacks) {
477
+ try {
478
+ callback(event);
479
+ }
480
+ catch (e) {
481
+ logger_1.nodeLogger.error(`[${this.name}] 事件回调错误: ${e}`);
482
+ }
483
+ }
484
+ }
485
+ /**
486
+ * 清理定时器
487
+ */
488
+ clearTimers() {
489
+ if (this.monitorTimer) {
490
+ clearInterval(this.monitorTimer);
491
+ this.monitorTimer = null;
492
+ }
493
+ if (this.eventTimer) {
494
+ clearInterval(this.eventTimer);
495
+ this.eventTimer = null;
496
+ }
497
+ }
498
+ /**
499
+ * 销毁节点
500
+ */
501
+ async dispose() {
502
+ await this.disconnect();
503
+ this.eventCallbacks.clear();
504
+ }
505
+ get name() { return this.config.name; }
506
+ get id() { return this.config.id; }
507
+ get tags() { return this.config.tags; }
508
+ }
509
+ exports.DockerNode = DockerNode;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 通知器
3
+ * 负责组装消息模板并调用 Bot 发送
4
+ */
5
+ import { Context } from 'koishi';
6
+ import type { NotificationEventType, NotificationConfig } from '../types';
7
+ export declare class Notifier {
8
+ /** Koishi Context */
9
+ private readonly ctx;
10
+ /** 通知配置 */
11
+ private readonly config;
12
+ constructor(ctx: Context, config: NotificationConfig);
13
+ /**
14
+ * 发送通知
15
+ */
16
+ send(eventType: NotificationEventType, data: any): Promise<void>;
17
+ /**
18
+ * 构建消息
19
+ */
20
+ private buildMessage;
21
+ /**
22
+ * 获取容器状态 Emoji
23
+ */
24
+ private getContainerEmoji;
25
+ /**
26
+ * 获取目标频道
27
+ */
28
+ private getTargetChannels;
29
+ /**
30
+ * 发送自定义消息
31
+ */
32
+ notifyCustom(content: string, targets?: string[]): Promise<void>;
33
+ }