koishi-plugin-disaster-warning 0.0.10 → 0.1.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.
package/lib/commands.js CHANGED
@@ -5,46 +5,75 @@ const koishi_1 = require("koishi");
5
5
  const logger = new koishi_1.Logger('disaster-commands');
6
6
  function applyCommands(ctx, config, service) {
7
7
  ctx.command('disaster', '灾害预警插件');
8
+ // disaster.status — 查看各 WebSocket 连接状态
9
+ ctx.command('disaster.status', '查看各数据源连接状态')
10
+ .action(() => {
11
+ const status = service.getStatus();
12
+ const lines = ['📡 数据源连接状态:'];
13
+ for (const [name, s] of Object.entries(status)) {
14
+ const icon = s.connected ? '🟢' : '🔴';
15
+ const retry = s.retryCount > 0 ? ` (重试: ${s.retryCount})` : '';
16
+ lines.push(`${icon} ${name}${retry}`);
17
+ }
18
+ return lines.join('\n');
19
+ });
20
+ // disaster.test — 发送测试消息
8
21
  ctx.command('disaster.test', '发送测试预警消息')
9
- .action(async ({ session }) => {
10
- if (!session)
11
- return;
12
- await session.send('正在发送测试消息...');
13
- const msg = [
22
+ .action(() => {
23
+ return [
14
24
  '【测试消息】',
15
25
  '这是一个测试用的地震预警消息。',
16
26
  '震源:测试地点',
17
- `时间:${new Date().toLocaleString()}`,
27
+ `时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
18
28
  '震级:M5.0',
19
29
  '最大烈度:4.0'
20
30
  ].join('\n');
21
- return msg;
22
31
  });
23
- ctx.command('disaster.history', '查看最近地震记录 (CENC)')
24
- .action(async ({ session }) => {
25
- try {
26
- const data = await ctx.http.get('https://api.wolfx.jp/cenc_eqlist.json');
27
- if (!data)
28
- return '获取数据失败';
29
- const list = [];
30
- let count = 0;
31
- for (const key in data) {
32
- if (key.startsWith('No') && count < 5) {
33
- const eq = data[key];
34
- list.push(eq);
35
- count++;
36
- }
32
+ // disaster.history 查询最近地震记录
33
+ ctx.command('disaster.history [source:string] [count:number]', '查询最近地震记录')
34
+ .usage('source: cenc(中国)或 jma(日本),默认 cenc\ncount: 条数,默认 5,最多 10')
35
+ .action(async ({ session }, source = 'cenc', count = 5) => {
36
+ source = source.toLowerCase();
37
+ if (source !== 'cenc' && source !== 'jma') {
38
+ return '❌ 数据源只支持 cenc 或 jma';
39
+ }
40
+ count = Math.min(Math.max(1, count), 10);
41
+ // 先尝试从内存缓存读取(避免重复 HTTP 请求)
42
+ const cache = service.getEqListCache();
43
+ let data = cache[source];
44
+ // 缓存为空时实时拉取
45
+ if (!data || Object.keys(data).length === 0) {
46
+ try {
47
+ const url = source === 'cenc'
48
+ ? 'https://api.wolfx.jp/cenc_eqlist.json'
49
+ : 'https://api.wolfx.jp/jma_eqlist.json';
50
+ data = await ctx.http.get(url);
51
+ }
52
+ catch (e) {
53
+ logger.error('Failed to fetch eqlist:', e);
54
+ return '❌ 获取数据失败,请检查网络或日志。';
37
55
  }
38
- if (list.length === 0)
39
- return '未找到最近地震记录';
40
- const messages = list.map(eq => {
41
- return `时间: ${eq.time}\n地点: ${eq.location}\n震级: M${eq.magnitude}\n深度: ${eq.depth}km`;
42
- });
43
- return messages.join('\n\n');
44
56
  }
45
- catch (e) {
46
- logger.error('Failed to fetch history:', e);
47
- return '获取历史记录失败,请检查网络或日志。';
57
+ if (!data)
58
+ return ' 未获取到数据';
59
+ const items = [];
60
+ let n = 1;
61
+ while (items.length < count && data[`No${n}`]) {
62
+ items.push(data[`No${n}`]);
63
+ n++;
48
64
  }
65
+ if (!items.length)
66
+ return '暂无地震记录';
67
+ const sourceName = source === 'cenc' ? 'CENC 中国地震台网' : 'JMA 日本气象厅';
68
+ const lines = [`📋 最近 ${items.length} 条地震记录(${sourceName}):`, ''];
69
+ items.forEach((eq, i) => {
70
+ const intensity = eq.shindo
71
+ ? `震度 ${eq.shindo}`
72
+ : eq.intensity
73
+ ? `烈度 ${eq.intensity}`
74
+ : '';
75
+ lines.push(`[${i + 1}] ${eq.time}`, ` 📍 ${eq.location} M${eq.magnitude} 深度 ${eq.depth}${intensity ? ` ${intensity}` : ''}`, '');
76
+ });
77
+ return lines.join('\n').trim();
49
78
  });
50
79
  }
@@ -3,7 +3,9 @@ import { DisasterEvent } from '../models';
3
3
  export declare class FanStudioHandler extends BaseDataHandler {
4
4
  constructor();
5
5
  parseMessage(data: any): DisasterEvent | null;
6
- private detectSource;
6
+ private parseUpdate;
7
+ private parseInitialAll;
8
+ private dispatchBySource;
7
9
  private parseEarthquakeWarning;
8
10
  private parseEarthquakeInfo;
9
11
  private parseWeather;
@@ -3,33 +3,37 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FanStudioHandler = void 0;
4
4
  const base_1 = require("./base");
5
5
  const models_1 = require("../models");
6
+ /**
7
+ * FanStudio WebSocket /all 端点消息解析器
8
+ *
9
+ * 协议格式:
10
+ * { type: "update", source: "<src>", Data: { ... } }
11
+ * { type: "initial_all", "<src>": { ... }, ... }
12
+ *
13
+ * source 取值:cea | cenc | jma | cwa | cwa-eew | usgs | weatheralarm | tsunami | cea-pr
14
+ */
15
+ const FS_SOURCE_MAP = {
16
+ 'cea': models_1.DataSource.FAN_STUDIO_CEA,
17
+ 'cea-pr': models_1.DataSource.FAN_STUDIO_CEA,
18
+ 'cenc': models_1.DataSource.FAN_STUDIO_CENC,
19
+ 'jma': models_1.DataSource.FAN_STUDIO_JMA,
20
+ 'cwa': models_1.DataSource.FAN_STUDIO_CWA,
21
+ 'cwa-eew': models_1.DataSource.FAN_STUDIO_CWA,
22
+ 'usgs': models_1.DataSource.FAN_STUDIO_USGS,
23
+ 'weatheralarm': models_1.DataSource.FAN_STUDIO_WEATHER,
24
+ 'tsunami': models_1.DataSource.FAN_STUDIO_TSUNAMI,
25
+ };
6
26
  class FanStudioHandler extends base_1.BaseDataHandler {
7
27
  constructor() {
8
28
  super('fan_studio');
9
29
  }
10
30
  parseMessage(data) {
11
31
  try {
12
- // FanStudio data usually comes in a 'Data' or 'data' field, or just the object itself
13
- const msgData = data.Data || data.data || data;
14
- if (!msgData)
15
- return null;
16
- // Detect data source based on message content
17
- const source = this.detectSource(msgData);
18
- // Earthquake Warning (CEA, CWA, JMA EEW)
19
- if (msgData.epiIntensity !== undefined || (msgData.magnitude !== undefined && msgData.isFinal !== undefined)) {
20
- return this.parseEarthquakeWarning(msgData, source);
21
- }
22
- // Earthquake Info (CENC, USGS, JMA Info)
23
- if (msgData.eventId && msgData.magnitude !== undefined && msgData.epiIntensity === undefined) {
24
- return this.parseEarthquakeInfo(msgData, source);
25
- }
26
- // Weather
27
- if (msgData.headline && msgData.description) {
28
- return this.parseWeather(msgData);
32
+ if (data.type === 'update') {
33
+ return this.parseUpdate(data);
29
34
  }
30
- // Tsunami
31
- if (msgData.warningInfo || (msgData.title && msgData.level && msgData.forecasts)) {
32
- return this.parseTsunami(msgData);
35
+ else if (data.type === 'initial_all') {
36
+ return this.parseInitialAll(data);
33
37
  }
34
38
  return null;
35
39
  }
@@ -38,51 +42,63 @@ class FanStudioHandler extends base_1.BaseDataHandler {
38
42
  return null;
39
43
  }
40
44
  }
41
- detectSource(data) {
42
- // Check for explicit type field
43
- if (data.type) {
44
- const typeMap = {
45
- 'cenc_eew': models_1.DataSource.FAN_STUDIO_CEA,
46
- 'cwa_eew': models_1.DataSource.FAN_STUDIO_CWA,
47
- 'jma_eew': models_1.DataSource.FAN_STUDIO_JMA,
48
- 'cenc_eq': models_1.DataSource.FAN_STUDIO_CENC,
49
- 'usgs_eq': models_1.DataSource.FAN_STUDIO_USGS,
50
- 'weather': models_1.DataSource.FAN_STUDIO_WEATHER,
51
- 'tsunami': models_1.DataSource.FAN_STUDIO_TSUNAMI,
52
- };
53
- if (typeMap[data.type])
54
- return typeMap[data.type];
55
- }
56
- // Check for source field
57
- if (data.source) {
58
- const sourceStr = String(data.source).toLowerCase();
59
- if (sourceStr.includes('cenc'))
60
- return models_1.DataSource.FAN_STUDIO_CENC;
61
- if (sourceStr.includes('cwa') || sourceStr.includes('taiwan'))
62
- return models_1.DataSource.FAN_STUDIO_CWA;
63
- if (sourceStr.includes('jma') || sourceStr.includes('japan'))
64
- return models_1.DataSource.FAN_STUDIO_JMA;
65
- if (sourceStr.includes('usgs'))
66
- return models_1.DataSource.FAN_STUDIO_USGS;
67
- }
68
- // Check province for Taiwan
69
- if (data.province && String(data.province).includes('台湾')) {
70
- return models_1.DataSource.FAN_STUDIO_CWA;
71
- }
72
- // Check for Japan-specific fields (scale instead of intensity)
73
- if (data.scale !== undefined && data.epiIntensity === undefined) {
74
- return models_1.DataSource.FAN_STUDIO_JMA;
45
+ parseUpdate(msg) {
46
+ const src = String(msg.source || '').toLowerCase();
47
+ const source = FS_SOURCE_MAP[src];
48
+ if (!source)
49
+ return null;
50
+ const payload = msg.Data || msg.data;
51
+ if (!payload)
52
+ return null;
53
+ return this.dispatchBySource(source, src, payload);
54
+ }
55
+ parseInitialAll(msg) {
56
+ // initial_all 包含多个数据源快照,取第一个有效的推送
57
+ for (const [key, payload] of Object.entries(msg)) {
58
+ if (key === 'type')
59
+ continue;
60
+ const src = key.toLowerCase();
61
+ const source = FS_SOURCE_MAP[src];
62
+ if (!source || !payload)
63
+ continue;
64
+ const event = this.dispatchBySource(source, src, payload);
65
+ if (event)
66
+ return event;
75
67
  }
76
- // Check for USGS fields
77
- if (data.net === 'us' || data.properties?.net === 'us') {
78
- return models_1.DataSource.FAN_STUDIO_USGS;
68
+ return null;
69
+ }
70
+ dispatchBySource(source, src, data) {
71
+ switch (source) {
72
+ case models_1.DataSource.FAN_STUDIO_CEA:
73
+ return this.parseEarthquakeWarning(data, source);
74
+ case models_1.DataSource.FAN_STUDIO_CWA:
75
+ // cwa-eew 是预警,cwa 是地震报告
76
+ if (src === 'cwa-eew')
77
+ return this.parseEarthquakeWarning(data, source);
78
+ return this.parseEarthquakeInfo(data, source);
79
+ case models_1.DataSource.FAN_STUDIO_JMA:
80
+ // jma 消息既有 EEW 也有地震信息,通过 isFinal/epiIntensity 判断
81
+ if (data.epiIntensity !== undefined || data.isFinal !== undefined) {
82
+ return this.parseEarthquakeWarning(data, source);
83
+ }
84
+ return this.parseEarthquakeInfo(data, source);
85
+ case models_1.DataSource.FAN_STUDIO_CENC:
86
+ return this.parseEarthquakeInfo(data, source);
87
+ case models_1.DataSource.FAN_STUDIO_USGS:
88
+ return this.parseEarthquakeInfo(data, source);
89
+ case models_1.DataSource.FAN_STUDIO_WEATHER:
90
+ return this.parseWeather(data);
91
+ case models_1.DataSource.FAN_STUDIO_TSUNAMI:
92
+ return this.parseTsunami(data);
93
+ default:
94
+ return null;
79
95
  }
80
- // Default to CEA for Chinese earthquake warnings
81
- return models_1.DataSource.FAN_STUDIO_CEA;
82
96
  }
83
97
  parseEarthquakeWarning(data, source) {
98
+ if (!data.id && !data.eventId)
99
+ return null;
84
100
  const earthquake = {
85
- id: data.id || '',
101
+ id: data.id || data.eventId || '',
86
102
  event_id: data.eventId || data.id || '',
87
103
  source: source,
88
104
  disaster_type: models_1.DisasterType.EARTHQUAKE_WARNING,
@@ -91,7 +107,7 @@ class FanStudioHandler extends base_1.BaseDataHandler {
91
107
  longitude: Number(data.longitude) || 0,
92
108
  depth: Number(data.depth),
93
109
  magnitude: Number(data.magnitude),
94
- intensity: Number(data.epiIntensity),
110
+ intensity: data.epiIntensity !== undefined ? Number(data.epiIntensity) : undefined,
95
111
  scale: data.scale !== undefined ? Number(data.scale) : undefined,
96
112
  place_name: data.placeName || '',
97
113
  province: data.province,
@@ -111,18 +127,20 @@ class FanStudioHandler extends base_1.BaseDataHandler {
111
127
  };
112
128
  }
113
129
  parseEarthquakeInfo(data, source) {
114
- // Determine if USGS or CENC based on source detection
115
- const finalSource = source === models_1.DataSource.FAN_STUDIO_CEA ? models_1.DataSource.FAN_STUDIO_CENC : source;
130
+ if (!data.id && !data.eventId)
131
+ return null;
116
132
  const earthquake = {
117
- id: data.id || '',
133
+ id: data.id || data.eventId || '',
118
134
  event_id: data.eventId || data.id || '',
119
- source: finalSource,
135
+ source: source,
120
136
  disaster_type: models_1.DisasterType.EARTHQUAKE,
121
137
  shock_time: this.parseDateTime(data.shockTime) || new Date().toISOString(),
122
138
  latitude: Number(data.latitude) || 0,
123
139
  longitude: Number(data.longitude) || 0,
124
140
  depth: Number(data.depth),
125
141
  magnitude: Number(data.magnitude),
142
+ intensity: data.epiIntensity !== undefined ? Number(data.epiIntensity) : undefined,
143
+ scale: data.scale !== undefined ? Number(data.scale) : undefined,
126
144
  place_name: data.placeName || '',
127
145
  updates: 1,
128
146
  is_final: true,
@@ -132,7 +150,7 @@ class FanStudioHandler extends base_1.BaseDataHandler {
132
150
  return {
133
151
  id: earthquake.id,
134
152
  data: earthquake,
135
- source: finalSource,
153
+ source: source,
136
154
  disaster_type: models_1.DisasterType.EARTHQUAKE,
137
155
  receive_time: new Date().toISOString(),
138
156
  push_count: 0,
@@ -140,12 +158,14 @@ class FanStudioHandler extends base_1.BaseDataHandler {
140
158
  };
141
159
  }
142
160
  parseWeather(data) {
161
+ if (!data.headline && !data.title)
162
+ return null;
143
163
  const weather = {
144
164
  id: data.id || `weather_${Date.now()}`,
145
165
  source: models_1.DataSource.FAN_STUDIO_WEATHER,
146
- headline: data.headline,
147
- title: data.title || data.headline,
148
- description: data.description,
166
+ headline: data.headline || data.title || '',
167
+ title: data.title || data.headline || '',
168
+ description: data.description || '',
149
169
  type: data.type || 'unknown',
150
170
  effective_time: this.parseDateTime(data.effectiveTime) || new Date().toISOString(),
151
171
  disaster_type: models_1.DisasterType.WEATHER_ALARM,
@@ -164,6 +184,8 @@ class FanStudioHandler extends base_1.BaseDataHandler {
164
184
  };
165
185
  }
166
186
  parseTsunami(data) {
187
+ if (!data.title && !data.warningInfo)
188
+ return null;
167
189
  const tsunami = {
168
190
  id: data.id || `tsunami_${Date.now()}`,
169
191
  code: data.code || '',
@@ -1,5 +1,13 @@
1
1
  import { BaseDataHandler } from './base';
2
2
  import { DisasterEvent } from '../models';
3
+ /**
4
+ * GlobalQuake WebSocket 消息解析器
5
+ *
6
+ * JSON 格式字段(来自 astrbot 参考实现):
7
+ * id, latitude, longitude, depth, magnitude, region,
8
+ * originTimeIso / origin_time_iso / origin_time_ms,
9
+ * revisionId / revision_id
10
+ */
3
11
  export declare class GlobalQuakeHandler extends BaseDataHandler {
4
12
  constructor();
5
13
  parseMessage(data: any): DisasterEvent | null;
@@ -3,35 +3,44 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GlobalQuakeHandler = void 0;
4
4
  const base_1 = require("./base");
5
5
  const models_1 = require("../models");
6
+ /**
7
+ * GlobalQuake WebSocket 消息解析器
8
+ *
9
+ * JSON 格式字段(来自 astrbot 参考实现):
10
+ * id, latitude, longitude, depth, magnitude, region,
11
+ * originTimeIso / origin_time_iso / origin_time_ms,
12
+ * revisionId / revision_id
13
+ */
6
14
  class GlobalQuakeHandler extends base_1.BaseDataHandler {
7
15
  constructor() {
8
16
  super('global_quake');
9
17
  }
10
18
  parseMessage(data) {
11
19
  try {
12
- // Assuming GlobalQuake format based on typical JSON structure or inferring from usage
13
- // Since I didn't see explicit GlobalQuake handler code in the file list (maybe I missed it or it's simple)
14
- // I'll assume a generic structure or try to find it.
15
- // Wait, `global_sources.py` might contain it.
16
- // For now, let's implement a placeholder or basic structure.
17
- // If data has 'magnitude' and 'latitude', it's likely an earthquake.
18
- if (!data.uuid || !data.magnitude)
20
+ const id = data.id;
21
+ if (!id || data.magnitude == null)
19
22
  return null;
23
+ const lat = Number(data.latitude);
24
+ const lon = Number(data.longitude);
25
+ if (isNaN(lat) || isNaN(lon))
26
+ return null;
27
+ const originTime = data.originTimeIso ||
28
+ data.origin_time_iso ||
29
+ (data.origin_time_ms ? new Date(data.origin_time_ms).toISOString() : null);
20
30
  const earthquake = {
21
- id: data.uuid,
22
- event_id: data.uuid,
31
+ id: String(id),
32
+ event_id: String(id),
23
33
  source: models_1.DataSource.GLOBAL_QUAKE,
24
- disaster_type: models_1.DisasterType.EARTHQUAKE_WARNING, // GQ is usually real-time
25
- shock_time: this.parseDateTime(data.origin) || new Date().toISOString(),
26
- latitude: Number(data.lat),
27
- longitude: Number(data.lon),
28
- depth: Number(data.depth),
34
+ disaster_type: models_1.DisasterType.EARTHQUAKE_WARNING,
35
+ shock_time: (originTime && this.parseDateTime(originTime)) || new Date().toISOString(),
36
+ latitude: lat,
37
+ longitude: lon,
38
+ depth: data.depth != null ? Number(data.depth) : undefined,
29
39
  magnitude: Number(data.magnitude),
30
- place_name: data.region || 'Unknown',
31
- updates: data.revision || 1,
32
- is_final: false, // GQ updates frequently
40
+ place_name: data.region || 'Global',
41
+ updates: data.revisionId ?? data.revision_id ?? 1,
42
+ is_final: false,
33
43
  is_cancel: false,
34
- max_pga: data.maxPGA,
35
44
  raw_data: data
36
45
  };
37
46
  return {
@@ -196,9 +196,10 @@ class P2PHandler extends base_1.BaseDataHandler {
196
196
  };
197
197
  }
198
198
  convertP2PScale(scale) {
199
+ // 46 = "5弱以上(暫定)",显示同 5弱(4.5)
199
200
  const mapping = {
200
201
  10: 1.0, 20: 2.0, 30: 3.0, 40: 4.0,
201
- 45: 4.5, 46: 4.6, 50: 5.0, 55: 5.5,
202
+ 45: 4.5, 46: 4.5, 50: 5.0, 55: 5.5,
202
203
  60: 6.0, 70: 7.0
203
204
  };
204
205
  return mapping[scale];
@@ -1,7 +1,17 @@
1
1
  import { BaseDataHandler } from './base';
2
2
  import { DisasterEvent } from '../models';
3
3
  export declare class WolfxHandler extends BaseDataHandler {
4
+ private eqListCache;
4
5
  constructor(sourceId: string);
6
+ getEqListCache(): {
7
+ cenc: Record<string, any>;
8
+ jma: Record<string, any>;
9
+ };
10
+ /**
11
+ * 解析 Wolfx HTTP eqlist JSON(cenc 或 jma),
12
+ * 提取最新一条记录作为 DisasterEvent 推送。
13
+ */
14
+ parseEqList(data: any, type: 'cenc' | 'jma'): DisasterEvent | null;
5
15
  parseMessage(data: any): DisasterEvent | null;
6
16
  private parseJMAEEW;
7
17
  private parseCENCEEW;
@@ -6,6 +6,30 @@ const models_1 = require("../models");
6
6
  class WolfxHandler extends base_1.BaseDataHandler {
7
7
  constructor(sourceId) {
8
8
  super(sourceId);
9
+ // HTTP 轮询缓存(用于 commands history 复用,避免重复请求)
10
+ this.eqListCache = { cenc: {}, jma: {} };
11
+ }
12
+ getEqListCache() {
13
+ return this.eqListCache;
14
+ }
15
+ /**
16
+ * 解析 Wolfx HTTP eqlist JSON(cenc 或 jma),
17
+ * 提取最新一条记录作为 DisasterEvent 推送。
18
+ */
19
+ parseEqList(data, type) {
20
+ try {
21
+ // 更新缓存
22
+ this.eqListCache[type] = data;
23
+ // 提取 No1(最新一条)
24
+ const eqInfo = data['No1'];
25
+ if (!eqInfo || typeof eqInfo !== 'object')
26
+ return null;
27
+ return type === 'cenc' ? this.parseCENCEqList(data) : this.parseJMAEqList(data);
28
+ }
29
+ catch (e) {
30
+ this.logger.error(`[${this.sourceId}] parseEqList error:`, e);
31
+ return null;
32
+ }
9
33
  }
10
34
  parseMessage(data) {
11
35
  try {
@@ -227,6 +251,7 @@ class WolfxHandler extends base_1.BaseDataHandler {
227
251
  parseJMAScale(scaleStr) {
228
252
  if (!scaleStr)
229
253
  return undefined;
254
+ // JMA 震度映射:5弱→4.5, 5強→5.0, 6弱→5.5, 6強→6.0
230
255
  const match = scaleStr.match(/(\d+)(弱|強)?/);
231
256
  if (match) {
232
257
  const base = parseInt(match[1]);
@@ -234,7 +259,7 @@ class WolfxHandler extends base_1.BaseDataHandler {
234
259
  if (suffix === '弱')
235
260
  return base - 0.5;
236
261
  if (suffix === '強')
237
- return base + 0.5;
262
+ return base; // 5強=5.0, 6強=6.0
238
263
  return base;
239
264
  }
240
265
  return undefined;
package/lib/index.d.ts CHANGED
@@ -5,7 +5,6 @@ export declare const inject: {
5
5
  optional: string[];
6
6
  };
7
7
  export interface Config {
8
- enabled: boolean;
9
8
  target_groups: string[];
10
9
  data_types: {
11
10
  earthquake_warning: boolean;
@@ -19,10 +18,12 @@ export interface Config {
19
18
  japan: boolean;
20
19
  global: boolean;
21
20
  };
22
- source_priority: 'auto' | 'wolfx' | 'fanstudio' | 'p2p';
23
- min_magnitude: number;
24
- min_intensity: number;
25
- min_scale: number;
21
+ filter: {
22
+ min_magnitude_absolute: number;
23
+ min_magnitude_for_push: number;
24
+ min_intensity_for_push: number;
25
+ min_scale_for_push: number;
26
+ };
26
27
  }
27
28
  export declare const Config: Schema<Config>;
28
29
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -11,8 +11,9 @@ exports.inject = {
11
11
  optional: ['database']
12
12
  };
13
13
  exports.Config = koishi_1.Schema.object({
14
- enabled: koishi_1.Schema.boolean().default(true).description('启用灾害预警插件'),
15
- target_groups: koishi_1.Schema.array(koishi_1.Schema.string()).default([]).description('推送目标群号列表,格式: 平台:群号(如 onebot:123456)'),
14
+ target_groups: koishi_1.Schema.array(koishi_1.Schema.string())
15
+ .default([])
16
+ .description('推送目标群号列表,直接填写群号即可,例如 123456789'),
16
17
  data_types: koishi_1.Schema.object({
17
18
  earthquake_warning: koishi_1.Schema.boolean().default(true).description('地震预警(实时速报,震前预警)'),
18
19
  earthquake_info: koishi_1.Schema.boolean().default(true).description('地震信息(震后测定报告)'),
@@ -20,20 +21,17 @@ exports.Config = koishi_1.Schema.object({
20
21
  weather_alarm: koishi_1.Schema.boolean().default(false).description('气象预警(中国)'),
21
22
  }).description('接收的灾害类型'),
22
23
  regions: koishi_1.Schema.object({
23
- china: koishi_1.Schema.boolean().default(true).description('中国大陆'),
24
- taiwan: koishi_1.Schema.boolean().default(true).description('台湾'),
25
- japan: koishi_1.Schema.boolean().default(true).description('日本'),
26
- global: koishi_1.Schema.boolean().default(false).description('全球(USGS/GlobalQuake'),
27
- }).description('接收的地区'),
28
- source_priority: koishi_1.Schema.union([
29
- koishi_1.Schema.const('auto').description('自动选择最佳数据源'),
30
- koishi_1.Schema.const('wolfx').description('优先使用 Wolfx API'),
31
- koishi_1.Schema.const('fanstudio').description('优先使用 FAN Studio'),
32
- koishi_1.Schema.const('p2p').description('优先使用 P2P地震情報'),
33
- ]).default('auto').description('数据源优先级'),
34
- min_magnitude: koishi_1.Schema.number().default(4.0).description('最小推送震级(M)'),
35
- min_intensity: koishi_1.Schema.number().default(4.0).description('最小推送烈度(中国标准)'),
36
- min_scale: koishi_1.Schema.number().default(3.0).description('最小推送震度(日本标准,3.0=震度3)'),
24
+ china: koishi_1.Schema.boolean().default(true).description('中国大陆(CEA预警 / CENC地震台网 / 气象 / 海啸)'),
25
+ taiwan: koishi_1.Schema.boolean().default(true).description('台湾(CWA预警与地震报告)'),
26
+ japan: koishi_1.Schema.boolean().default(true).description('日本(JMA EEW / P2P地震情报 / 海啸)'),
27
+ global: koishi_1.Schema.boolean().default(false).description('全球(USGS 地震信息 / GlobalQuake 实时预警)'),
28
+ }).description('接收的地区(数据源连接将依据此项自动开启)'),
29
+ filter: koishi_1.Schema.object({
30
+ min_magnitude_absolute: koishi_1.Schema.number().default(3.0).description('绝对过滤震级:低于此震级直接丢弃(不推送)'),
31
+ min_magnitude_for_push: koishi_1.Schema.number().default(4.0).description('推送震级门槛:震级达到此值则推送'),
32
+ min_intensity_for_push: koishi_1.Schema.number().default(4.0).description('推送烈度门槛(中国):最大烈度达到此值则推送'),
33
+ min_scale_for_push: koishi_1.Schema.number().default(4.0).description('推送震度门槛(日本):最大震度达到此值则推送(4 = 震度4)'),
34
+ }).description('过滤阈值(地震类事件,海啸/气象不受此限制)'),
37
35
  });
38
36
  function apply(ctx, config) {
39
37
  const service = new service_1.DisasterWarningService(ctx, config);
package/lib/models.d.ts CHANGED
@@ -104,3 +104,16 @@ export interface DisasterEvent {
104
104
  push_count: number;
105
105
  raw_data: any;
106
106
  }
107
+ /**
108
+ * 跨数据源事件去重器
109
+ * 用 place+magnitude+分钟桶 作为指纹,窗口期内同一事件只推一次
110
+ */
111
+ export declare class EventDeduplicator {
112
+ private seen;
113
+ private windowMs;
114
+ constructor(windowMs?: number);
115
+ /** 返回 true 表示已见过(应丢弃),false 表示首次(应推送) */
116
+ isDuplicate(event: DisasterEvent): boolean;
117
+ private fingerprint;
118
+ private evict;
119
+ }
package/lib/models.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DATA_SOURCE_MAPPING = exports.DataSource = exports.DisasterType = void 0;
3
+ exports.EventDeduplicator = exports.DATA_SOURCE_MAPPING = exports.DataSource = exports.DisasterType = void 0;
4
4
  exports.getDataSourceFromId = getDataSourceFromId;
5
5
  var DisasterType;
6
6
  (function (DisasterType) {
@@ -53,3 +53,44 @@ exports.DATA_SOURCE_MAPPING = {
53
53
  function getDataSourceFromId(id) {
54
54
  return exports.DATA_SOURCE_MAPPING[id];
55
55
  }
56
+ /**
57
+ * 跨数据源事件去重器
58
+ * 用 place+magnitude+分钟桶 作为指纹,窗口期内同一事件只推一次
59
+ */
60
+ class EventDeduplicator {
61
+ constructor(windowMs = 8 * 60 * 1000) {
62
+ // fingerprint -> first-seen timestamp (ms)
63
+ this.seen = new Map();
64
+ this.windowMs = windowMs;
65
+ }
66
+ /** 返回 true 表示已见过(应丢弃),false 表示首次(应推送) */
67
+ isDuplicate(event) {
68
+ this.evict();
69
+ const fp = this.fingerprint(event);
70
+ if (this.seen.has(fp))
71
+ return true;
72
+ this.seen.set(fp, Date.now());
73
+ return false;
74
+ }
75
+ fingerprint(event) {
76
+ const data = event.data;
77
+ if (event.disaster_type === DisasterType.EARTHQUAKE || event.disaster_type === DisasterType.EARTHQUAKE_WARNING) {
78
+ // 优先用 event_id,相同机构的多数据源共享同一 event_id
79
+ if (data.event_id)
80
+ return `eq:${data.event_id}`;
81
+ // 降级:地点 + 震级 + 分钟桶
82
+ const bucket = data.shock_time ? data.shock_time.slice(0, 16) : 'unknown';
83
+ return `eq:${data.place_name}|${data.magnitude?.toFixed(1)}|${bucket}`;
84
+ }
85
+ // 海啸/气象用 id 即可
86
+ return `${event.disaster_type}:${event.id}`;
87
+ }
88
+ evict() {
89
+ const cutoff = Date.now() - this.windowMs;
90
+ for (const [fp, ts] of this.seen) {
91
+ if (ts < cutoff)
92
+ this.seen.delete(fp);
93
+ }
94
+ }
95
+ }
96
+ exports.EventDeduplicator = EventDeduplicator;
package/lib/pusher.d.ts CHANGED
@@ -14,5 +14,10 @@ export declare class MessagePushManager {
14
14
  private formatTime;
15
15
  private formatScale;
16
16
  private formatSource;
17
+ /**
18
+ * 直接向每个群号广播。
19
+ * target_groups 里存的是纯群号(如 "123456789"),
20
+ * 通过 ctx.bots 遍历所有在线 Bot 发送,这样无需硬编码平台前缀。
21
+ */
17
22
  private broadcast;
18
23
  }
package/lib/pusher.js CHANGED
@@ -4,10 +4,6 @@ exports.MessagePushManager = void 0;
4
4
  const koishi_1 = require("koishi");
5
5
  const models_1 = require("./models");
6
6
  const logger = new koishi_1.Logger('disaster-pusher');
7
- // Hardcoded filter thresholds - no user configuration needed
8
- const FILTER_THRESHOLDS = {
9
- MIN_MAGNITUDE_ABSOLUTE: 3.0, // Always ignore earthquakes below M3.0
10
- };
11
7
  class MessagePushManager {
12
8
  constructor(ctx, config) {
13
9
  this.ctx = ctx;
@@ -15,48 +11,40 @@ class MessagePushManager {
15
11
  }
16
12
  async pushEvent(event) {
17
13
  if (this.shouldFilter(event)) {
18
- logger.debug(`Event ${event.id} filtered.`);
14
+ logger.debug(`Event ${event.id} filtered by threshold.`);
19
15
  return;
20
16
  }
21
17
  const message = this.formatMessage(event);
22
18
  if (!message)
23
19
  return;
24
- logger.info(`Pushing event ${event.id} to ${this.config.target_groups.length} groups.`);
20
+ logger.info(`Pushing event ${event.id} to ${this.config.target_groups.length} group(s).`);
25
21
  await this.broadcast(message);
26
22
  }
23
+ // ---- 过滤逻辑 --------------------------------------------------------
27
24
  shouldFilter(event) {
28
- // Only filter earthquake events
29
25
  if (event.disaster_type !== models_1.DisasterType.EARTHQUAKE && event.disaster_type !== models_1.DisasterType.EARTHQUAKE_WARNING) {
30
- return false; // Don't filter tsunami/weather
26
+ return false;
31
27
  }
32
28
  const data = event.data;
33
- const { MIN_MAGNITUDE_ABSOLUTE } = FILTER_THRESHOLDS;
34
- const { min_magnitude, min_intensity, min_scale } = this.config;
35
- // Always filter out very small earthquakes
36
- if (data.magnitude !== undefined && data.magnitude < MIN_MAGNITUDE_ABSOLUTE) {
29
+ const { min_magnitude_absolute, min_magnitude_for_push, min_intensity_for_push, min_scale_for_push } = this.config.filter;
30
+ if (data.magnitude !== undefined && data.magnitude < min_magnitude_absolute)
37
31
  return true;
38
- }
39
- // Pass if magnitude is significant
40
- if (data.magnitude !== undefined && data.magnitude >= min_magnitude) {
32
+ if (data.magnitude !== undefined && data.magnitude >= min_magnitude_for_push)
41
33
  return false;
42
- }
43
- // Pass if intensity is significant (Chinese sources)
44
- if (data.intensity !== undefined && data.intensity >= min_intensity) {
34
+ if (data.intensity !== undefined && data.intensity >= min_intensity_for_push)
45
35
  return false;
46
- }
47
- // Pass if scale is significant (Japanese sources)
48
- if (data.scale !== undefined && data.scale >= min_scale) {
36
+ if (data.scale !== undefined && data.scale >= min_scale_for_push)
49
37
  return false;
50
- }
51
- // If magnitude is between 3.0 and min_magnitude, and no significant intensity/scale, filter out
52
- if (data.magnitude !== undefined && data.magnitude >= MIN_MAGNITUDE_ABSOLUTE && data.magnitude < min_magnitude) {
53
- // Only filter if we don't have intensity/scale data that would make it significant
54
- if (data.intensity === undefined && data.scale === undefined) {
55
- return true;
56
- }
38
+ // M 在 [min_magnitude_absolute, min_magnitude_for_push) 且无显著烈度/震度 → 过滤
39
+ if (data.magnitude !== undefined &&
40
+ data.magnitude >= min_magnitude_absolute &&
41
+ data.magnitude < min_magnitude_for_push &&
42
+ data.intensity === undefined && data.scale === undefined) {
43
+ return true;
57
44
  }
58
45
  return false;
59
46
  }
47
+ // ---- 格式化 ----------------------------------------------------------
60
48
  formatMessage(event) {
61
49
  switch (event.disaster_type) {
62
50
  case models_1.DisasterType.EARTHQUAKE:
@@ -79,12 +67,10 @@ class MessagePushManager {
79
67
  msg += `🕐 时间:${this.formatTime(data.shock_time)}\n`;
80
68
  msg += `📊 震级:M${data.magnitude?.toFixed(1) || '未知'}\n`;
81
69
  msg += `📏 深度:${data.depth !== undefined ? data.depth + 'km' : '未知'}\n`;
82
- if (data.intensity !== undefined) {
70
+ if (data.intensity !== undefined)
83
71
  msg += `🔥 最大烈度:${data.intensity.toFixed(1)}\n`;
84
- }
85
- if (data.scale !== undefined) {
72
+ if (data.scale !== undefined)
86
73
  msg += `🎚️ 最大震度:${this.formatScale(data.scale)}\n`;
87
- }
88
74
  msg += `📡 数据源:${this.formatSource(data.source)}`;
89
75
  return msg;
90
76
  }
@@ -92,7 +78,7 @@ class MessagePushManager {
92
78
  let msg = `🌊 【海啸预警】${data.title}\n`;
93
79
  msg += `⚠️ 级别:${data.level}\n`;
94
80
  msg += `🏛️ 发布单位:${data.org_unit}\n`;
95
- if (data.forecasts && data.forecasts.length > 0) {
81
+ if (data.forecasts?.length) {
96
82
  msg += `📍 预报区域:\n`;
97
83
  data.forecasts.slice(0, 5).forEach((f) => {
98
84
  msg += ` • ${f.name || f.areaName}: ${f.grade || f.level}\n`;
@@ -109,6 +95,7 @@ class MessagePushManager {
109
95
  msg += `📝 详情:${data.description}\n`;
110
96
  return msg;
111
97
  }
98
+ // ---- 工具 ------------------------------------------------------------
112
99
  formatTime(isoStr) {
113
100
  try {
114
101
  return new Date(isoStr).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
@@ -118,14 +105,14 @@ class MessagePushManager {
118
105
  }
119
106
  }
120
107
  formatScale(scale) {
121
- const scaleMap = {
108
+ const map = {
122
109
  1.0: '1', 2.0: '2', 3.0: '3', 4.0: '4',
123
110
  4.5: '5弱', 5.0: '5强', 5.5: '6弱', 6.0: '6强', 7.0: '7'
124
111
  };
125
- return scaleMap[scale] || scale.toString();
112
+ return map[scale] ?? scale.toString();
126
113
  }
127
114
  formatSource(source) {
128
- const sourceMap = {
115
+ const map = {
129
116
  'fan_studio_cea': '中国地震预警网',
130
117
  'fan_studio_cwa': '台湾中央气象署',
131
118
  'fan_studio_cenc': '中国地震台网',
@@ -143,16 +130,34 @@ class MessagePushManager {
143
130
  'wolfx_cenc_eq': 'Wolfx-CENC',
144
131
  'global_quake': 'GlobalQuake'
145
132
  };
146
- return sourceMap[source] || source;
133
+ return map[source] ?? source;
147
134
  }
135
+ // ---- 推送 ------------------------------------------------------------
136
+ /**
137
+ * 直接向每个群号广播。
138
+ * target_groups 里存的是纯群号(如 "123456789"),
139
+ * 通过 ctx.bots 遍历所有在线 Bot 发送,这样无需硬编码平台前缀。
140
+ */
148
141
  async broadcast(message) {
149
- for (const groupId of this.config.target_groups) {
150
- const channelId = groupId.includes(':') ? groupId : `onebot:${groupId}`;
151
- try {
152
- await this.ctx.broadcast([channelId], message);
142
+ if (!this.config.target_groups.length)
143
+ return;
144
+ for (const gid of this.config.target_groups) {
145
+ const groupId = String(gid).trim();
146
+ if (!groupId)
147
+ continue;
148
+ let sent = false;
149
+ for (const bot of this.ctx.bots) {
150
+ try {
151
+ await bot.sendMessage(groupId, message);
152
+ sent = true;
153
+ break; // 同一群由第一个能发送的 bot 处理即可
154
+ }
155
+ catch {
156
+ // 该 bot 不在此群,继续尝试下一个
157
+ }
153
158
  }
154
- catch (e) {
155
- logger.error(`Failed to send to ${channelId}:`, e);
159
+ if (!sent) {
160
+ logger.warn(`Could not send to group ${groupId}: no available bot.`);
156
161
  }
157
162
  }
158
163
  }
package/lib/service.d.ts CHANGED
@@ -2,22 +2,31 @@ import { Context } from 'koishi';
2
2
  import { Config } from './index';
3
3
  export declare class DisasterWarningService {
4
4
  private config;
5
- private connections;
6
- private reconnectTimers;
7
- private pusher;
8
5
  private ctx;
9
6
  private stopped;
7
+ private pusher;
8
+ private deduplicator;
10
9
  private handlers;
11
- private wolfxHandlers;
10
+ private wolfxHandler;
11
+ private connections;
12
+ private wolfxHttpTimer;
12
13
  constructor(ctx: Context, config: Config);
13
14
  start(): Promise<void>;
14
15
  stop(): Promise<void>;
15
- private connectBasedOnConfig;
16
- private connectWebSocket;
16
+ private connectAll;
17
+ private openConnection;
18
+ private doConnect;
19
+ private startWolfxHttpPoller;
17
20
  private handleEvent;
18
21
  private shouldPushEvent;
19
- private connectFanStudio;
20
- private connectP2P;
21
- private connectWolfxSource;
22
- private connectGlobalQuake;
22
+ getStatus(): Record<string, {
23
+ connected: boolean;
24
+ retryCount: number;
25
+ url: string;
26
+ }>;
27
+ /** 给 commands 用:拿到 wolfxHandler 最新的 eqlist 缓存 */
28
+ getEqListCache(): {
29
+ cenc: Record<string, any>;
30
+ jma: Record<string, any>;
31
+ };
23
32
  }
package/lib/service.js CHANGED
@@ -7,12 +7,20 @@ const models_1 = require("./models");
7
7
  const handlers_1 = require("./handlers");
8
8
  const pusher_1 = require("./pusher");
9
9
  const logger = new koishi_1.Logger('disaster-warning');
10
+ // Wolfx HTTP 列表轮询间隔(5 分钟)
11
+ const WOLFX_HTTP_INTERVAL_MS = 5 * 60 * 1000;
12
+ // FanStudio 备用服务器
13
+ const FAN_STUDIO_PRIMARY = 'wss://ws.fanstudio.tech/all';
14
+ const FAN_STUDIO_BACKUP = 'wss://ws.fanstudio.hk/all';
15
+ // WebSocket 重连延迟,超过阈值次数后切换到备用服务器
16
+ const RECONNECT_DELAY_MS = 10000;
17
+ const FALLBACK_RETRY_THRESHOLD = 5;
10
18
  class DisasterWarningService {
11
19
  constructor(ctx, config) {
20
+ this.stopped = false;
21
+ this.deduplicator = new models_1.EventDeduplicator();
12
22
  this.connections = {};
13
- this.reconnectTimers = {};
14
- this.stopped = false; // Flag to prevent reconnection after stop
15
- this.wolfxHandlers = {};
23
+ this.wolfxHttpTimer = null;
16
24
  this.ctx = ctx;
17
25
  this.config = config;
18
26
  this.pusher = new pusher_1.MessagePushManager(ctx, config);
@@ -21,135 +29,160 @@ class DisasterWarningService {
21
29
  p2p: new handlers_1.P2PHandler(),
22
30
  globalQuake: new handlers_1.GlobalQuakeHandler()
23
31
  };
24
- // Initialize Wolfx handlers
25
- const wolfxKeys = ['jma_eew', 'cenc_eew', 'cwa_eew', 'jma_eqlist', 'cenc_eqlist'];
26
- for (const key of wolfxKeys) {
27
- this.wolfxHandlers[key] = new handlers_1.WolfxHandler(`wolfx_${key}`);
28
- }
32
+ this.wolfxHandler = new handlers_1.WolfxHandler('wolfx_all');
29
33
  }
30
34
  async start() {
31
- if (!this.config.enabled)
32
- return;
33
- this.stopped = false; // Reset stopped flag on start
35
+ this.stopped = false;
34
36
  logger.info('Disaster Warning Service starting...');
35
- this.connectBasedOnConfig();
37
+ this.connectAll();
38
+ this.startWolfxHttpPoller();
36
39
  }
37
40
  async stop() {
38
41
  logger.info('Disaster Warning Service stopping...');
39
- this.stopped = true; // Set stopped flag to prevent reconnection
40
- // Clear all reconnect timers first
41
- for (const key in this.reconnectTimers) {
42
- clearTimeout(this.reconnectTimers[key]);
43
- delete this.reconnectTimers[key];
42
+ this.stopped = true;
43
+ if (this.wolfxHttpTimer) {
44
+ clearInterval(this.wolfxHttpTimer);
45
+ this.wolfxHttpTimer = null;
44
46
  }
45
- // Close all connections
46
- for (const key in this.connections) {
47
- const ws = this.connections[key];
48
- // Remove all listeners to prevent any further events
49
- ws.removeAllListeners();
50
- // Force close the connection
51
- ws.terminate();
52
- delete this.connections[key];
47
+ for (const name in this.connections) {
48
+ const entry = this.connections[name];
49
+ if (entry.reconnectTimer)
50
+ clearTimeout(entry.reconnectTimer);
51
+ if (entry.ws) {
52
+ entry.ws.removeAllListeners('close');
53
+ entry.ws.close();
54
+ }
53
55
  }
56
+ this.connections = {};
54
57
  logger.info('Disaster Warning Service stopped.');
55
58
  }
56
- connectBasedOnConfig() {
57
- const { regions, data_types, source_priority } = this.config;
58
- // Determine which connections to make based on regions and source priority
59
- const needsJapan = regions.japan;
60
- const needsChina = regions.china;
61
- const needsTaiwan = regions.taiwan;
62
- const needsGlobal = regions.global;
63
- // Connect to appropriate sources based on priority
64
- if (source_priority === 'auto' || source_priority === 'wolfx') {
65
- // Wolfx is best for real-time EEW
66
- if (needsJapan && data_types.earthquake_warning) {
67
- this.connectWolfxSource('jma_eew', 'wss://ws-api.wolfx.jp/jma_eew');
68
- }
69
- if (needsChina && data_types.earthquake_warning) {
70
- this.connectWolfxSource('cenc_eew', 'wss://ws-api.wolfx.jp/cenc_eew');
71
- }
72
- if (needsTaiwan && data_types.earthquake_warning) {
73
- this.connectWolfxSource('cwa_eew', 'wss://ws-api.wolfx.jp/cwa_eew');
74
- }
75
- if (needsJapan && data_types.earthquake_info) {
76
- this.connectWolfxSource('jma_eqlist', 'wss://ws-api.wolfx.jp/jma_eqlist');
77
- }
78
- if (needsChina && data_types.earthquake_info) {
79
- this.connectWolfxSource('cenc_eqlist', 'wss://ws-api.wolfx.jp/cenc_eqlist');
80
- }
59
+ // ---- 连接调度 --------------------------------------------------------
60
+ connectAll() {
61
+ const { regions, data_types } = this.config;
62
+ // FanStudio /all:覆盖中国(CEA/CENC/气象/海啸)、台湾(CWA)、日本(JMA)、全球(USGS)
63
+ const needFanStudio = (regions.china && (data_types.earthquake_warning || data_types.earthquake_info || data_types.weather_alarm || data_types.tsunami_warning)) ||
64
+ (regions.taiwan && (data_types.earthquake_warning || data_types.earthquake_info)) ||
65
+ (regions.japan && (data_types.earthquake_warning || data_types.earthquake_info)) ||
66
+ (regions.global && data_types.earthquake_info);
67
+ if (needFanStudio) {
68
+ this.openConnection('fan_studio', FAN_STUDIO_PRIMARY, FAN_STUDIO_BACKUP, (data) => {
69
+ this.handleEvent(this.handlers.fanStudio.parseMessage(data));
70
+ });
81
71
  }
82
- if (source_priority === 'auto' || source_priority === 'p2p') {
83
- // P2P is good for Japan data including tsunami
84
- if (needsJapan) {
85
- if (data_types.earthquake_warning || data_types.earthquake_info || data_types.tsunami_warning) {
86
- this.connectP2P();
87
- }
88
- }
72
+ // P2P:日本 EEW / 地震情报 / 海啸
73
+ if (regions.japan &&
74
+ (data_types.earthquake_warning || data_types.earthquake_info || data_types.tsunami_warning)) {
75
+ this.openConnection('p2p', 'wss://api.p2pquake.net/v2/ws', undefined, (data) => {
76
+ this.handleEvent(this.handlers.p2p.parseMessage(data));
77
+ });
89
78
  }
90
- if (source_priority === 'auto' || source_priority === 'fanstudio') {
91
- // FAN Studio has Chinese weather and tsunami
92
- const needsFanStudio = (needsChina && data_types.weather_alarm) ||
93
- (needsChina && data_types.tsunami_warning) ||
94
- (needsGlobal && data_types.earthquake_info); // USGS via FanStudio
95
- if (needsFanStudio) {
96
- this.connectFanStudio();
97
- }
79
+ // Wolfx /all_eew:中国/台湾/日本 EEW(仅预警类型)
80
+ if (data_types.earthquake_warning &&
81
+ (regions.china || regions.taiwan || regions.japan)) {
82
+ this.openConnection('wolfx_eew', 'wss://ws-api.wolfx.jp/all_eew', undefined, (data) => {
83
+ this.handleEvent(this.wolfxHandler.parseMessage(data));
84
+ });
98
85
  }
99
- // Global Quake for global coverage
100
- if (needsGlobal && data_types.earthquake_warning) {
101
- this.connectGlobalQuake();
86
+ // GlobalQuake:全球实时预警
87
+ if (regions.global && data_types.earthquake_warning) {
88
+ this.openConnection('global_quake', 'wss://gqm.aloys233.top/ws', undefined, (data) => {
89
+ this.handleEvent(this.handlers.globalQuake.parseMessage(data));
90
+ });
102
91
  }
103
92
  }
104
- connectWebSocket(name, url, onMessage) {
105
- // Don't connect if service is stopped
93
+ // ---- WebSocket 生命周期 ----------------------------------------------
94
+ openConnection(name, url, backupUrl, onMessage) {
106
95
  if (this.stopped)
107
96
  return;
108
- if (this.connections[name]) {
109
- this.connections[name].removeAllListeners('close');
110
- this.connections[name].close();
111
- }
112
- logger.info(`Connecting to ${name} at ${url}...`);
97
+ const entry = {
98
+ url,
99
+ backupUrl,
100
+ retryCount: 0,
101
+ ws: null,
102
+ reconnectTimer: null
103
+ };
104
+ this.connections[name] = entry;
105
+ this.doConnect(name, onMessage);
106
+ }
107
+ doConnect(name, onMessage) {
108
+ if (this.stopped)
109
+ return;
110
+ const entry = this.connections[name];
111
+ if (!entry)
112
+ return;
113
+ // 超过阈值切换到备用服务器
114
+ const useBackup = entry.backupUrl && entry.retryCount >= FALLBACK_RETRY_THRESHOLD;
115
+ const url = useBackup ? entry.backupUrl : entry.url;
116
+ logger.info(`[${name}] Connecting to ${url}${useBackup ? ' (backup)' : ''}...`);
113
117
  const ws = new ws_1.WebSocket(url);
118
+ entry.ws = ws;
114
119
  ws.on('open', () => {
115
- logger.info(`Connected to ${name}`);
120
+ logger.info(`[${name}] Connected`);
121
+ entry.retryCount = 0;
116
122
  });
117
- ws.on('message', (data) => {
118
- if (this.stopped)
119
- return;
123
+ ws.on('message', (raw) => {
120
124
  try {
121
- const parsed = JSON.parse(data.toString());
122
- onMessage(parsed);
125
+ onMessage(JSON.parse(raw.toString()));
123
126
  }
124
127
  catch (e) {
125
- logger.warn(`Failed to parse message from ${name}:`, e);
128
+ logger.warn(`[${name}] Parse error:`, e);
126
129
  }
127
130
  });
128
131
  ws.on('close', () => {
129
- // Only reconnect if service is not stopped
130
- if (!this.stopped) {
131
- logger.warn(`Disconnected from ${name}, reconnecting in 10s...`);
132
- delete this.connections[name];
133
- this.reconnectTimers[name] = setTimeout(() => {
134
- this.connectWebSocket(name, url, onMessage);
135
- }, 10000);
136
- }
132
+ if (this.stopped)
133
+ return;
134
+ entry.retryCount++;
135
+ logger.warn(`[${name}] Disconnected (retry #${entry.retryCount}), reconnecting in ${RECONNECT_DELAY_MS / 1000}s...`);
136
+ entry.reconnectTimer = setTimeout(() => this.doConnect(name, onMessage), RECONNECT_DELAY_MS);
137
137
  });
138
138
  ws.on('error', (err) => {
139
- logger.error(`Error in ${name} connection:`, err);
139
+ logger.error(`[${name}] Error:`, err);
140
140
  });
141
- this.connections[name] = ws;
142
141
  }
142
+ // ---- Wolfx HTTP 轮询(地震列表)------------------------------------
143
+ startWolfxHttpPoller() {
144
+ const { regions, data_types } = this.config;
145
+ if (!data_types.earthquake_info)
146
+ return;
147
+ if (!regions.china && !regions.japan)
148
+ return;
149
+ const poll = async () => {
150
+ if (this.stopped)
151
+ return;
152
+ try {
153
+ if (regions.china) {
154
+ const data = await this.ctx.http.get('https://api.wolfx.jp/cenc_eqlist.json');
155
+ if (data)
156
+ this.handleEvent(this.wolfxHandler.parseEqList(data, 'cenc'));
157
+ }
158
+ if (regions.japan) {
159
+ const data = await this.ctx.http.get('https://api.wolfx.jp/jma_eqlist.json');
160
+ if (data)
161
+ this.handleEvent(this.wolfxHandler.parseEqList(data, 'jma'));
162
+ }
163
+ }
164
+ catch (e) {
165
+ logger.warn('[wolfx_http] Fetch failed:', e);
166
+ }
167
+ };
168
+ // 延迟首次拉取,避免重启时将旧地震当新事件推送
169
+ // 去重窗口(8分钟)> 轮询间隔(5分钟),不会漏报
170
+ this.wolfxHttpTimer = setInterval(poll, WOLFX_HTTP_INTERVAL_MS);
171
+ }
172
+ // ---- 事件处理 --------------------------------------------------------
143
173
  async handleEvent(event) {
144
174
  if (!event)
145
175
  return;
146
176
  if (!this.shouldPushEvent(event))
147
177
  return;
178
+ if (this.deduplicator.isDuplicate(event)) {
179
+ logger.debug(`[dedup] Skipping duplicate event: ${event.id}`);
180
+ return;
181
+ }
148
182
  await this.pusher.pushEvent(event);
149
183
  }
150
184
  shouldPushEvent(event) {
151
185
  const { data_types, regions } = this.config;
152
- // Check disaster type
153
186
  const isEarthquakeWarning = event.disaster_type === models_1.DisasterType.EARTHQUAKE_WARNING;
154
187
  const isEarthquakeInfo = event.disaster_type === models_1.DisasterType.EARTHQUAKE;
155
188
  const isTsunami = event.disaster_type === models_1.DisasterType.TSUNAMI;
@@ -162,59 +195,43 @@ class DisasterWarningService {
162
195
  return false;
163
196
  if (isWeather && !data_types.weather_alarm)
164
197
  return false;
165
- // Check region based on data source
166
- const source = event.source;
167
- const isJapanSource = [
168
- models_1.DataSource.P2P_EEW, models_1.DataSource.P2P_EARTHQUAKE, models_1.DataSource.P2P_TSUNAMI,
169
- models_1.DataSource.WOLFX_JMA_EEW, models_1.DataSource.WOLFX_JMA_EQ, models_1.DataSource.FAN_STUDIO_JMA
170
- ].includes(source);
171
- const isChinaSource = [
172
- models_1.DataSource.FAN_STUDIO_CEA, models_1.DataSource.FAN_STUDIO_CENC, models_1.DataSource.FAN_STUDIO_WEATHER,
173
- models_1.DataSource.FAN_STUDIO_TSUNAMI, models_1.DataSource.WOLFX_CENC_EEW, models_1.DataSource.WOLFX_CENC_EQ
174
- ].includes(source);
175
- const isTaiwanSource = [
176
- models_1.DataSource.FAN_STUDIO_CWA, models_1.DataSource.WOLFX_CWA_EEW
177
- ].includes(source);
178
- const isGlobalSource = [
179
- models_1.DataSource.FAN_STUDIO_USGS, models_1.DataSource.GLOBAL_QUAKE
180
- ].includes(source);
181
- if (isJapanSource && !regions.japan)
198
+ const src = event.source;
199
+ const japanSources = [models_1.DataSource.P2P_EEW, models_1.DataSource.P2P_EARTHQUAKE, models_1.DataSource.P2P_TSUNAMI,
200
+ models_1.DataSource.WOLFX_JMA_EEW, models_1.DataSource.WOLFX_JMA_EQ, models_1.DataSource.FAN_STUDIO_JMA];
201
+ const chinaSources = [models_1.DataSource.FAN_STUDIO_CEA, models_1.DataSource.FAN_STUDIO_CENC, models_1.DataSource.FAN_STUDIO_WEATHER,
202
+ models_1.DataSource.FAN_STUDIO_TSUNAMI, models_1.DataSource.WOLFX_CENC_EEW, models_1.DataSource.WOLFX_CENC_EQ];
203
+ const taiwanSources = [models_1.DataSource.FAN_STUDIO_CWA, models_1.DataSource.WOLFX_CWA_EEW];
204
+ const globalSources = [models_1.DataSource.FAN_STUDIO_USGS, models_1.DataSource.GLOBAL_QUAKE];
205
+ if (japanSources.includes(src) && !regions.japan)
182
206
  return false;
183
- if (isChinaSource && !regions.china)
207
+ if (chinaSources.includes(src) && !regions.china)
184
208
  return false;
185
- if (isTaiwanSource && !regions.taiwan)
209
+ if (taiwanSources.includes(src) && !regions.taiwan)
186
210
  return false;
187
- if (isGlobalSource && !regions.global)
211
+ if (globalSources.includes(src) && !regions.global)
188
212
  return false;
189
213
  return true;
190
214
  }
191
- connectFanStudio() {
192
- const url = "wss://ws.fanstudio.tech/all";
193
- this.connectWebSocket('fan_studio', url, (data) => {
194
- const event = this.handlers.fanStudio.parseMessage(data);
195
- this.handleEvent(event);
196
- });
197
- }
198
- connectP2P() {
199
- const url = "wss://api.p2pquake.net/v2/ws";
200
- this.connectWebSocket('p2p', url, (data) => {
201
- const event = this.handlers.p2p.parseMessage(data);
202
- this.handleEvent(event);
203
- });
204
- }
205
- connectWolfxSource(key, url) {
206
- const handler = this.wolfxHandlers[key];
207
- this.connectWebSocket(`wolfx_${key}`, url, (data) => {
208
- const event = handler.parseMessage(data);
209
- this.handleEvent(event);
210
- });
215
+ // ---- 状态查询(供 commands 使用)------------------------------------
216
+ getStatus() {
217
+ const result = {};
218
+ for (const [name, entry] of Object.entries(this.connections)) {
219
+ result[name] = {
220
+ connected: entry.ws?.readyState === ws_1.WebSocket.OPEN,
221
+ retryCount: entry.retryCount,
222
+ url: entry.url
223
+ };
224
+ }
225
+ result['wolfx_http_poller'] = {
226
+ connected: this.wolfxHttpTimer !== null,
227
+ retryCount: 0,
228
+ url: 'https://api.wolfx.jp/{cenc,jma}_eqlist.json'
229
+ };
230
+ return result;
211
231
  }
212
- connectGlobalQuake() {
213
- const url = "wss://gqm.aloys233.top/ws";
214
- this.connectWebSocket('global_quake', url, (data) => {
215
- const event = this.handlers.globalQuake.parseMessage(data);
216
- this.handleEvent(event);
217
- });
232
+ /** 给 commands 用:拿到 wolfxHandler 最新的 eqlist 缓存 */
233
+ getEqListCache() {
234
+ return this.wolfxHandler.getEqListCache();
218
235
  }
219
236
  }
220
237
  exports.DisasterWarningService = DisasterWarningService;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-disaster-warning",
3
- "version": "0.0.10",
3
+ "version": "0.1.1",
4
4
  "description": "Koishi 灾害预警插件,支持多数据源(地震、海啸、气象预警)",
5
5
  "contributors": [
6
6
  "lumia.wang <fenglian19980510@gmail.com>"
@@ -34,14 +34,12 @@
34
34
  "peerDependencies": {
35
35
  "koishi": "^4.18.0"
36
36
  },
37
- "dependencies": {
38
- "ws": "^8.18.0"
39
- },
40
37
  "devDependencies": {
41
38
  "@types/node": "^20.0.0",
42
39
  "@types/ws": "^8.5.10",
43
40
  "koishi": "^4.18.0",
44
- "typescript": "^5.0.0"
41
+ "typescript": "^5.0.0",
42
+ "ws": "^8.18.0"
45
43
  },
46
44
  "koishi": {
47
45
  "description": {
@@ -57,4 +55,4 @@
57
55
  ]
58
56
  }
59
57
  }
60
- }
58
+ }