koishi-plugin-disaster-warning 0.1.2 → 0.1.4

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 CHANGED
@@ -1,61 +1,98 @@
1
- # koishi-plugin-disaster-warning
2
-
3
- [![npm](https://img.shields.io/npm/v/koishi-plugin-disaster-warning?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-disaster-warning)
4
-
5
- Koishi 灾害预警插件,支持多数据源(地震、海啸、气象预警)实时推送。
6
- 本插件移植自 [astrbot_plugin_disaster_warning](https://github.com/DBJD-CR/astrbot_plugin_disaster_warning),感谢原作者的开源贡献。
7
-
8
- ## ✨ 功能特性
9
-
10
- ### 🌍 多数据源支持
11
- 插件支持多个可自由选择启用的细粒度数据源,覆盖全球主要地震监测机构:
12
- - **中国地震网地震预警 (FAN Studio / Wolfx)** - 实时地震预警信息。
13
- - **台湾中央气象署强震即时警报 (FAN Studio / Wolfx)** - 台湾地区地震预警。
14
- - **日本气象厅紧急地震速报 (P2P / Wolfx / FAN Studio)** - 日本紧急地震速报。
15
- - **中国地震台网地震测定 (FAN Studio / Wolfx)** - 正式地震测定信息。
16
- - **日本气象厅地震情报 (P2P / Wolfx)** - 详细地震情报。
17
- - **USGS地震测定 (FAN Studio)** - 美国地质调查局地震信息。
18
- - **Global Quake服务器** - 全球地震测站实时计算推送,精度有限。
19
- - **中国气象局气象预警 (FAN Studio)** - 气象灾害预警。
20
- - **自然资源部海啸预警中心 (FAN Studio)** - 海啸预警信息。
21
- - **日本气象厅海啸预报 (P2P)** - 日本海啸预报信息。
22
-
23
- ### 🎯 智能推送控制
24
- - **阈值过滤** - 根据震级、烈度、震度设置推送阈值。
25
- - **频率控制** - 智能推送逻辑,避免短时间内刷屏。
26
- - **首报推送保证** - 确保预警信息首次下达时总是推送。
27
- - **最终报保证** - 确保最终报总是推送。
28
-
29
- ### 🔁 事件去重功能
30
- 插件具备基础的事件去重功能,防止同一地震被同一个数据源重复推送。
31
-
32
- ## 🚀 安装与使用
33
-
34
- ### 安装
35
-
36
- ```bash
37
- npm install koishi-plugin-disaster-warning
38
- ```
39
-
40
- 或者在 Koishi 插件市场搜索 `disaster-warning` 安装。
41
-
42
- ### 配置
43
-
44
- 安装完成后,在 Koishi 控制台的「插件配置」页面进行配置:
45
-
46
- 1. **启用插件**:确保 `enabled` 为 `true`。
47
- 2. **目标群组**:在 `target_groups` 中添加需要推送预警消息的群号。
48
- 3. **数据源配置**:在 `data_sources` 中按需启用或禁用各个数据源。
49
- 4. **过滤器配置**:在 `earthquake_filters` 中设置震级和烈度阈值,避免过多打扰。
50
-
51
- ## 📋 使用命令
52
-
53
- 插件提供以下指令:
54
-
55
- - `disaster` - 查看插件帮助信息
56
- - `disaster.test` - 发送一条测试预警消息,用于验证配置是否生效
57
- - `disaster.history` - 查看最近的地震记录 (数据来源:CENC)
58
-
59
- ## 📄 许可证
60
-
61
- 本项目采用 [AGPL-3.0](./LICENSE) 许可证。
1
+ # koishi-plugin-disaster-warning
2
+
3
+ [![npm](https://img.shields.io/npm/v/koishi-plugin-disaster-warning?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-disaster-warning)
4
+
5
+ Koishi 灾害预警插件,支持多数据源(地震、海啸、气象预警)实时推送。
6
+ 本插件移植自 [astrbot_plugin_disaster_warning](https://github.com/DBJD-CR/astrbot_plugin_disaster_warning),感谢原作者的开源贡献。
7
+
8
+ ## ✨ 功能特性
9
+
10
+ ### 🌍 多数据源支持
11
+ 插件支持多个可自由选择启用的细粒度数据源,覆盖全球主要地震监测机构:
12
+ - **中国地震网地震预警 (FAN Studio / Wolfx)** - 实时地震预警信息。
13
+ - **台湾中央气象署强震即时警报 (FAN Studio / Wolfx)** - 台湾地区地震预警。
14
+ - **日本气象厅紧急地震速报 (P2P / Wolfx / FAN Studio)** - 日本紧急地震速报。
15
+ - **中国地震台网地震测定 (FAN Studio / Wolfx)** - 正式地震测定信息。
16
+ - **日本气象厅地震情报 (P2P / Wolfx)** - 详细地震情报。
17
+ - **USGS地震测定 (FAN Studio)** - 美国地质调查局地震信息。
18
+ - **Global Quake服务器** - 全球地震测站实时计算推送,精度有限。
19
+ - **中国气象局气象预警 (FAN Studio)** - 气象灾害预警。
20
+ - **自然资源部海啸预警中心 (FAN Studio)** - 海啸预警信息。
21
+ - **日本气象厅海啸预报 (P2P)** - 日本海啸预报信息。
22
+
23
+ ### 🎯 智能推送控制
24
+ - **阈值过滤** - 根据震级、烈度、震度设置推送阈值。
25
+ - **频率控制** - 智能推送逻辑,避免短时间内刷屏。
26
+ - **首报推送保证** - 确保预警信息首次下达时总是推送。
27
+ - **震中地区过滤** - 地震类事件会按震中地名与经纬度过滤,关闭全球地区后,CENC 等数据源发布的海外地震不会再误推送。
28
+ - **最终报保证** - 确保最终报总是推送。
29
+
30
+ ### 🔁 事件去重功能
31
+ 插件具备基础的事件去重功能,防止同一地震被同一个数据源重复推送。
32
+
33
+ ### 🤖 ChatLuna 工具调用
34
+ 可选注册 `disaster_warning` 工具给 ChatLuna 使用,让模型主动查询近期地震、按地点/震级/时间过滤,或查看数据源连接状态。适合回答“哪里地震了”“某地最近有没有地震”“我关心的人在某地,那里安全吗”等问题。
35
+
36
+ ## 🚀 安装与使用
37
+
38
+ ### 安装
39
+
40
+ ```bash
41
+ npm install koishi-plugin-disaster-warning
42
+ ```
43
+
44
+ 或者在 Koishi 插件市场搜索 `disaster-warning` 安装。
45
+
46
+ ### 配置
47
+
48
+ 安装完成后,在 Koishi 控制台的「插件配置」页面进行配置:
49
+
50
+ 1. **启用插件**:确保 `enabled` 为 `true`。
51
+ 2. **目标群组**:在 `target_groups` 中添加需要推送预警消息的群号。
52
+ 3. **灾害类型**:在 `data_types` 中选择是否接收地震预警、地震信息、海啸预警、气象预警。
53
+ 4. **接收地区**:在 `regions` 中选择中国大陆、台湾、日本或全球。数据源连接会依据此项自动开启,地震类事件还会按震中位置再次过滤。
54
+ 5. **过滤器配置**:在 `filter` 中设置震级、烈度、震度阈值,避免过多打扰。
55
+
56
+ ### 地区过滤说明
57
+
58
+ `regions` 同时控制数据源连接与地震震中地区过滤。例如仅开启中国大陆和台湾、关闭日本与全球时,插件仍可连接 CENC/CWA 等相关数据源,但智利、印尼等不在已开启地区内的地震会被丢弃,不会因为由 CENC 发布就被推送。
59
+
60
+ 当前震中识别优先使用地名关键词,其次使用经纬度范围;无法识别且带有非空地名的地震默认视为全球事件。
61
+
62
+ ### ChatLuna 工具设置
63
+
64
+ 如果安装了 `koishi-plugin-chatluna`,可以在 `chatluna` 配置项中开启工具注册:
65
+
66
+ - `chatluna.enabled`:开启后注册 ChatLuna 工具,默认关闭。
67
+ - `chatluna.name`:工具名称,默认 `disaster_warning`。
68
+ - `chatluna.default_source`:模型未指定来源时查询 `all`、`cenc`、`jma` 或 `usgs`。
69
+ - `chatluna.default_days`:默认查询最近多少天。
70
+ - `chatluna.default_limit`:默认最多返回多少条记录。
71
+ - `chatluna.min_magnitude`:默认最低震级。
72
+ - `chatluna.include_usgs_when_all`:综合查询时是否包含 USGS 全球地震。
73
+
74
+ 工具输入示例:
75
+
76
+ ```json
77
+ {
78
+ "action": "recent_earthquakes",
79
+ "location": "智利",
80
+ "min_magnitude": 5,
81
+ "days": 7,
82
+ "limit": 5
83
+ }
84
+ ```
85
+
86
+ 也可以使用 `{ "action": "status" }` 查询当前数据源连接状态。
87
+
88
+ ## 📋 使用命令
89
+
90
+ 插件提供以下指令:
91
+
92
+ - `disaster` - 查看插件帮助信息
93
+ - `disaster.test` - 发送一条测试预警消息,用于验证配置是否生效
94
+ - `disaster.history` - 查看最近的地震记录 (数据来源:CENC)
95
+
96
+ ## 📄 许可证
97
+
98
+ 本项目采用 [AGPL-3.0](./LICENSE) 许可证。
@@ -0,0 +1,41 @@
1
+ import { Context } from 'koishi';
2
+ import { StructuredTool } from '@langchain/core/tools';
3
+ import type { Config } from './index';
4
+ import { DisasterWarningService } from './service';
5
+ type ToolSource = 'all' | 'cenc' | 'jma' | 'usgs';
6
+ type ToolInput = {
7
+ action?: 'recent_earthquakes' | 'status';
8
+ source?: ToolSource;
9
+ location?: string;
10
+ min_magnitude?: number;
11
+ days?: number;
12
+ limit?: number;
13
+ };
14
+ interface ChatLunaToolConfig {
15
+ enabled: boolean;
16
+ name: string;
17
+ description: string;
18
+ default_source: ToolSource;
19
+ default_limit: number;
20
+ default_days: number;
21
+ min_magnitude: number;
22
+ include_usgs_when_all: boolean;
23
+ }
24
+ export declare class DisasterWarningTool extends StructuredTool<any, ToolInput, ToolInput, string> {
25
+ private ctx;
26
+ private config;
27
+ private service;
28
+ name: string;
29
+ description: string;
30
+ schema: any;
31
+ constructor(ctx: Context, config: Config, service: DisasterWarningService);
32
+ _call(input: ToolInput): Promise<string>;
33
+ private formatStatus;
34
+ private queryRecentEarthquakes;
35
+ private fetchSource;
36
+ private fetchWolfxList;
37
+ private fetchUsgs;
38
+ }
39
+ export declare function applyChatLunaTools(ctx: Context, config: Config, service: DisasterWarningService): void;
40
+ export declare function getChatLunaConfig(config: Config): ChatLunaToolConfig;
41
+ export {};
@@ -0,0 +1,346 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DisasterWarningTool = void 0;
4
+ exports.applyChatLunaTools = applyChatLunaTools;
5
+ exports.getChatLunaConfig = getChatLunaConfig;
6
+ const koishi_1 = require("koishi");
7
+ const tools_1 = require("@langchain/core/tools");
8
+ const zod_1 = require("zod");
9
+ const logger = new koishi_1.Logger('disaster-chatluna');
10
+ const sourceSchema = zod_1.z.enum(['all', 'cenc', 'jma', 'usgs']);
11
+ const actionSchema = zod_1.z.enum(['recent_earthquakes', 'status']);
12
+ const toolSchema = zod_1.z.object({
13
+ action: actionSchema.optional().describe('操作类型:recent_earthquakes 查询近期地震,status 查询数据源连接状态。默认 recent_earthquakes。'),
14
+ source: sourceSchema.optional().describe('数据源:all 综合查询,cenc 中国地震台网列表,jma 日本气象厅列表,usgs USGS 全球地震。默认使用插件配置。'),
15
+ location: zod_1.z.string().optional().describe('可选,地点关键词、国家、地区或城市,例如“四川”“台湾”“日本”“智利”“Chile”。'),
16
+ min_magnitude: zod_1.z.number().min(0).max(10).optional().describe('可选,最低震级,默认使用插件配置。'),
17
+ days: zod_1.z.number().int().min(1).max(30).optional().describe('可选,查询最近多少天,默认使用插件配置,最多 30 天。'),
18
+ limit: zod_1.z.number().int().min(1).max(50).optional().describe('可选,最多返回多少条地震记录,默认使用插件配置。')
19
+ });
20
+ const DEFAULT_CHATLUNA_CONFIG = {
21
+ enabled: false,
22
+ name: 'disaster_warning',
23
+ description: '查询近期地震和灾害预警数据源状态,可按地点、震级、时间范围过滤。适合回答“哪里地震了”“某地最近有没有地震”“关心的人所在地区是否有地震”等问题。',
24
+ default_source: 'all',
25
+ default_limit: 8,
26
+ default_days: 7,
27
+ min_magnitude: 4,
28
+ include_usgs_when_all: true
29
+ };
30
+ class DisasterWarningTool extends tools_1.StructuredTool {
31
+ constructor(ctx, config, service) {
32
+ super({});
33
+ this.ctx = ctx;
34
+ this.config = config;
35
+ this.service = service;
36
+ this.schema = toolSchema;
37
+ const cfg = getChatLunaConfig(config);
38
+ this.name = normalizeToolName(cfg.name);
39
+ this.description = cfg.description.trim() || DEFAULT_CHATLUNA_CONFIG.description;
40
+ }
41
+ async _call(input) {
42
+ const action = input.action || 'recent_earthquakes';
43
+ try {
44
+ if (action === 'status') {
45
+ return JSON.stringify(this.formatStatus(), null, 2);
46
+ }
47
+ const cfg = getChatLunaConfig(this.config);
48
+ const options = {
49
+ source: input.source || cfg.default_source,
50
+ location: input.location?.trim() || undefined,
51
+ minMagnitude: input.min_magnitude ?? cfg.min_magnitude,
52
+ days: clamp(input.days ?? cfg.default_days, 1, 30),
53
+ limit: clamp(input.limit ?? cfg.default_limit, 1, 50)
54
+ };
55
+ const result = await this.queryRecentEarthquakes(options);
56
+ return JSON.stringify(result, null, 2);
57
+ }
58
+ catch (error) {
59
+ const message = error instanceof Error ? error.message : String(error);
60
+ logger.warn(`ChatLuna tool call failed: ${message}`);
61
+ return `灾害预警查询失败:${message}`;
62
+ }
63
+ }
64
+ formatStatus() {
65
+ const status = this.service.getStatus();
66
+ return {
67
+ type: 'disaster_warning_status',
68
+ generated_at: new Date().toISOString(),
69
+ sources: Object.entries(status).map(([name, item]) => ({
70
+ name,
71
+ connected: item.connected,
72
+ retry_count: item.retryCount,
73
+ url: item.url
74
+ }))
75
+ };
76
+ }
77
+ async queryRecentEarthquakes(options) {
78
+ const cfg = getChatLunaConfig(this.config);
79
+ const sources = expandSources(options.source, cfg.include_usgs_when_all);
80
+ const settled = await Promise.allSettled(sources.map((source) => this.fetchSource(source, options)));
81
+ const errors = [];
82
+ const records = [];
83
+ settled.forEach((item, index) => {
84
+ const source = sources[index];
85
+ if (item.status === 'fulfilled') {
86
+ records.push(...item.value);
87
+ }
88
+ else {
89
+ const message = item.reason instanceof Error ? item.reason.message : String(item.reason);
90
+ errors.push({ source, message });
91
+ }
92
+ });
93
+ const terms = buildSearchTerms(options.location);
94
+ const filtered = records
95
+ .filter((record) => record.magnitude === undefined || record.magnitude >= options.minMagnitude)
96
+ .filter((record) => isWithinDays(record.time, options.days))
97
+ .filter((record) => matchesLocation(record, terms))
98
+ .sort((a, b) => timeValue(b.time) - timeValue(a.time))
99
+ .slice(0, options.limit);
100
+ return compact({
101
+ type: 'recent_earthquakes',
102
+ generated_at: new Date().toISOString(),
103
+ query: {
104
+ source: options.source,
105
+ expanded_sources: sources,
106
+ location: options.location,
107
+ min_magnitude: options.minMagnitude,
108
+ days: options.days,
109
+ limit: options.limit
110
+ },
111
+ count: filtered.length,
112
+ earthquakes: filtered,
113
+ errors: errors.length ? errors : undefined,
114
+ note: '地震查询工具用于对话查询,不会改变群推送过滤配置。regions.global 关闭时仍可通过工具主动查询全球地震。'
115
+ });
116
+ }
117
+ fetchSource(source, options) {
118
+ if (source === 'cenc')
119
+ return this.fetchWolfxList('cenc');
120
+ if (source === 'jma')
121
+ return this.fetchWolfxList('jma');
122
+ if (source === 'usgs')
123
+ return this.fetchUsgs(options);
124
+ return Promise.resolve([]);
125
+ }
126
+ async fetchWolfxList(type) {
127
+ const cache = this.service.getEqListCache();
128
+ let data = cache[type];
129
+ if (!data || Object.keys(data).length === 0) {
130
+ const url = type === 'cenc'
131
+ ? 'https://api.wolfx.jp/cenc_eqlist.json'
132
+ : 'https://api.wolfx.jp/jma_eqlist.json';
133
+ data = await this.ctx.http.get(url);
134
+ }
135
+ const keys = Object.keys(data || {})
136
+ .filter((key) => /^No\d+$/.test(key))
137
+ .sort((a, b) => Number(a.slice(2)) - Number(b.slice(2)));
138
+ return keys
139
+ .map((key) => parseWolfxRecord(type, data[key]))
140
+ .filter((record) => Boolean(record));
141
+ }
142
+ async fetchUsgs(options) {
143
+ const url = new URL('https://earthquake.usgs.gov/fdsnws/event/1/query');
144
+ url.searchParams.set('format', 'geojson');
145
+ url.searchParams.set('orderby', 'time');
146
+ url.searchParams.set('starttime', new Date(Date.now() - options.days * 86400000).toISOString());
147
+ url.searchParams.set('minmagnitude', String(options.minMagnitude));
148
+ url.searchParams.set('limit', String(Math.min(Math.max(options.limit * 5, 50), 200)));
149
+ const data = await this.ctx.http.get(url.toString());
150
+ const features = Array.isArray(data?.features) ? data.features : [];
151
+ return features.map(parseUsgsFeature).filter((record) => Boolean(record));
152
+ }
153
+ }
154
+ exports.DisasterWarningTool = DisasterWarningTool;
155
+ function applyChatLunaTools(ctx, config, service) {
156
+ ctx.on('ready', () => {
157
+ const cfg = getChatLunaConfig(config);
158
+ if (!cfg.enabled)
159
+ return;
160
+ const chatluna = ctx.chatluna;
161
+ if (!chatluna?.platform?.registerTool) {
162
+ logger.warn('未检测到 ChatLuna 服务,跳过注册灾害预警工具。');
163
+ return;
164
+ }
165
+ const toolName = normalizeToolName(cfg.name);
166
+ ctx.effect(() => chatluna.platform.registerTool(toolName, {
167
+ description: cfg.description.trim() || DEFAULT_CHATLUNA_CONFIG.description,
168
+ selector() {
169
+ return true;
170
+ },
171
+ createTool() {
172
+ return new DisasterWarningTool(ctx, config, service);
173
+ },
174
+ meta: {
175
+ source: 'extension',
176
+ group: 'disaster-warning',
177
+ tags: ['disaster', 'earthquake', 'warning'],
178
+ defaultAvailability: {
179
+ enabled: true,
180
+ main: true,
181
+ chatluna: true,
182
+ characterScope: 'all'
183
+ }
184
+ }
185
+ }));
186
+ logger.info(`已注册 ChatLuna 灾害预警工具:${toolName}`);
187
+ });
188
+ }
189
+ function getChatLunaConfig(config) {
190
+ return {
191
+ ...DEFAULT_CHATLUNA_CONFIG,
192
+ ...(config.chatluna || {})
193
+ };
194
+ }
195
+ function normalizeToolName(name) {
196
+ const normalized = (name || DEFAULT_CHATLUNA_CONFIG.name).trim();
197
+ return normalized || DEFAULT_CHATLUNA_CONFIG.name;
198
+ }
199
+ function expandSources(source, includeUsgsWhenAll) {
200
+ if (source !== 'all')
201
+ return [source];
202
+ return includeUsgsWhenAll ? ['cenc', 'jma', 'usgs'] : ['cenc', 'jma'];
203
+ }
204
+ function parseWolfxRecord(type, item) {
205
+ if (!item || typeof item !== 'object')
206
+ return null;
207
+ const magnitude = toNumber(item.magnitude);
208
+ return compact({
209
+ source: type,
210
+ source_name: type === 'cenc' ? 'CENC 中国地震台网' : 'JMA 日本气象厅',
211
+ id: item.md5 || item.id,
212
+ time: normalizeTime(item.time),
213
+ location: item.location || item.hypocenter || '',
214
+ magnitude,
215
+ depth_km: parseDepth(item.depth),
216
+ intensity: item.intensity ? String(item.intensity) : undefined,
217
+ scale: item.shindo ? String(item.shindo) : undefined,
218
+ latitude: toNumber(item.latitude),
219
+ longitude: toNumber(item.longitude)
220
+ });
221
+ }
222
+ function parseUsgsFeature(feature) {
223
+ const props = feature?.properties || {};
224
+ const coordinates = feature?.geometry?.coordinates || [];
225
+ if (!props.place && props.mag === undefined)
226
+ return null;
227
+ return compact({
228
+ source: 'usgs',
229
+ source_name: 'USGS',
230
+ id: feature.id,
231
+ time: props.time ? new Date(props.time).toISOString() : undefined,
232
+ location: props.place || '',
233
+ magnitude: toNumber(props.mag),
234
+ depth_km: toNumber(coordinates[2]),
235
+ latitude: toNumber(coordinates[1]),
236
+ longitude: toNumber(coordinates[0]),
237
+ url: props.url
238
+ });
239
+ }
240
+ function normalizeTime(value) {
241
+ if (!value)
242
+ return undefined;
243
+ const text = String(value).trim();
244
+ const date = new Date(text.replace(/\//g, '-'));
245
+ if (!Number.isFinite(date.getTime()))
246
+ return text;
247
+ return date.toISOString();
248
+ }
249
+ function parseDepth(value) {
250
+ if (value === undefined || value === null)
251
+ return undefined;
252
+ const match = String(value).match(/-?\d+(?:\.\d+)?/);
253
+ return match ? Number(match[0]) : undefined;
254
+ }
255
+ function toNumber(value) {
256
+ if (value === undefined || value === null || value === '')
257
+ return undefined;
258
+ const num = Number(value);
259
+ return Number.isFinite(num) ? num : undefined;
260
+ }
261
+ function isWithinDays(time, days) {
262
+ const value = timeValue(time);
263
+ if (!value)
264
+ return true;
265
+ return value >= Date.now() - days * 86400000;
266
+ }
267
+ function timeValue(time) {
268
+ if (!time)
269
+ return 0;
270
+ const value = new Date(time).getTime();
271
+ return Number.isFinite(value) ? value : 0;
272
+ }
273
+ function matchesLocation(record, terms) {
274
+ if (!terms.length)
275
+ return true;
276
+ const haystack = normalizeText([
277
+ record.location,
278
+ record.source_name,
279
+ record.latitude,
280
+ record.longitude
281
+ ].filter((item) => item !== undefined).join(' '));
282
+ return terms.some((term) => haystack.includes(term));
283
+ }
284
+ function buildSearchTerms(location) {
285
+ if (!location)
286
+ return [];
287
+ const base = normalizeText(location);
288
+ const aliases = {
289
+ '智利': ['chile'],
290
+ '印尼': ['indonesia'],
291
+ '印度尼西亚': ['indonesia'],
292
+ '菲律宾': ['philippines'],
293
+ '秘鲁': ['peru'],
294
+ '墨西哥': ['mexico'],
295
+ '阿根廷': ['argentina'],
296
+ '土耳其': ['turkey'],
297
+ '希腊': ['greece'],
298
+ '俄罗斯': ['russia'],
299
+ '美国': ['unitedstates', 'usa'],
300
+ '阿拉斯加': ['alaska'],
301
+ '加州': ['california'],
302
+ '新西兰': ['newzealand'],
303
+ '巴布亚': ['papua'],
304
+ '汤加': ['tonga'],
305
+ '斐济': ['fiji'],
306
+ '瓦努阿图': ['vanuatu'],
307
+ '日本': ['japan'],
308
+ '台湾': ['taiwan', '臺灣', '台灣'],
309
+ '中国': ['china'],
310
+ '四川': ['sichuan'],
311
+ '云南': ['yunnan'],
312
+ '西藏': ['tibet', 'xizang'],
313
+ '新疆': ['xinjiang'],
314
+ '青海': ['qinghai'],
315
+ '甘肃': ['gansu']
316
+ };
317
+ const terms = new Set([base]);
318
+ for (const [key, values] of Object.entries(aliases)) {
319
+ if (base.includes(normalizeText(key))) {
320
+ values.forEach((value) => terms.add(normalizeText(value)));
321
+ }
322
+ }
323
+ return Array.from(terms).filter(Boolean);
324
+ }
325
+ function normalizeText(value) {
326
+ return String(value ?? '').toLowerCase().replace(/[\s,,、()()-]/g, '');
327
+ }
328
+ function clamp(value, min, max) {
329
+ return Math.min(Math.max(value, min), max);
330
+ }
331
+ function compact(value) {
332
+ if (Array.isArray(value))
333
+ return value.map((item) => compact(item));
334
+ if (!value || typeof value !== 'object')
335
+ return value;
336
+ const out = {};
337
+ for (const [key, item] of Object.entries(value)) {
338
+ if (item === undefined || item === null || item === '')
339
+ continue;
340
+ const next = compact(item);
341
+ if (Array.isArray(next) && next.length === 0)
342
+ continue;
343
+ out[key] = next;
344
+ }
345
+ return out;
346
+ }
package/lib/index.d.ts CHANGED
@@ -24,6 +24,16 @@ export interface Config {
24
24
  min_intensity_for_push: number;
25
25
  min_scale_for_push: number;
26
26
  };
27
+ chatluna: {
28
+ enabled: boolean;
29
+ name: string;
30
+ description: string;
31
+ default_source: 'all' | 'cenc' | 'jma' | 'usgs';
32
+ default_limit: number;
33
+ default_days: number;
34
+ min_magnitude: number;
35
+ include_usgs_when_all: boolean;
36
+ };
27
37
  }
28
38
  export declare const Config: Schema<Config>;
29
39
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -5,10 +5,11 @@ exports.apply = apply;
5
5
  const koishi_1 = require("koishi");
6
6
  const service_1 = require("./service");
7
7
  const commands_1 = require("./commands");
8
+ const chatluna_1 = require("./chatluna");
8
9
  exports.name = 'disaster-warning';
9
10
  exports.inject = {
10
11
  required: ['http'],
11
- optional: ['database']
12
+ optional: ['database', 'chatluna']
12
13
  };
13
14
  exports.Config = koishi_1.Schema.object({
14
15
  target_groups: koishi_1.Schema.array(koishi_1.Schema.string())
@@ -25,17 +26,33 @@ exports.Config = koishi_1.Schema.object({
25
26
  taiwan: koishi_1.Schema.boolean().default(true).description('台湾(CWA预警与地震报告)'),
26
27
  japan: koishi_1.Schema.boolean().default(true).description('日本(JMA EEW / P2P地震情报 / 海啸)'),
27
28
  global: koishi_1.Schema.boolean().default(false).description('全球(USGS 地震信息 / GlobalQuake 实时预警)'),
28
- }).description('接收的地区(数据源连接将依据此项自动开启)'),
29
+ }).description('接收的地区(数据源连接将依据此项自动开启,地震类事件会按震中位置过滤)'),
29
30
  filter: koishi_1.Schema.object({
30
31
  min_magnitude_absolute: koishi_1.Schema.number().default(3.0).description('绝对过滤震级:低于此震级直接丢弃(不推送)'),
31
32
  min_magnitude_for_push: koishi_1.Schema.number().default(4.0).description('推送震级门槛:震级达到此值则推送'),
32
33
  min_intensity_for_push: koishi_1.Schema.number().default(4.0).description('推送烈度门槛(中国):最大烈度达到此值则推送'),
33
34
  min_scale_for_push: koishi_1.Schema.number().default(4.0).description('推送震度门槛(日本):最大震度达到此值则推送(4 = 震度4)'),
34
35
  }).description('过滤阈值(地震类事件,海啸/气象不受此限制)'),
36
+ chatluna: koishi_1.Schema.object({
37
+ enabled: koishi_1.Schema.boolean().default(false).description('开启后注册 ChatLuna 工具,供模型主动查询近期地震与数据源状态'),
38
+ name: koishi_1.Schema.string().default('disaster_warning').description('ChatLuna 工具名称'),
39
+ description: koishi_1.Schema.string().default('查询近期地震和灾害预警数据源状态,可按地点、震级、时间范围过滤。适合回答“哪里地震了”“某地最近有没有地震”“关心的人所在地区是否有地震”等问题。').description('ChatLuna 工具描述'),
40
+ default_source: koishi_1.Schema.union([
41
+ koishi_1.Schema.const('all').description('综合 CENC / JMA / USGS'),
42
+ koishi_1.Schema.const('cenc').description('中国地震台网 / Wolfx CENC'),
43
+ koishi_1.Schema.const('jma').description('日本气象厅 / Wolfx JMA'),
44
+ koishi_1.Schema.const('usgs').description('USGS 全球地震')
45
+ ]).default('all').description('模型未指定数据源时的默认查询源'),
46
+ default_limit: koishi_1.Schema.number().default(8).min(1).max(50).description('模型未指定 limit 时最多返回多少条'),
47
+ default_days: koishi_1.Schema.number().default(7).min(1).max(30).description('模型未指定 days 时查询最近多少天'),
48
+ min_magnitude: koishi_1.Schema.number().default(4.0).min(0).max(10).description('模型未指定 min_magnitude 时的最低震级'),
49
+ include_usgs_when_all: koishi_1.Schema.boolean().default(true).description('default_source/all 查询时是否包含 USGS 全球地震')
50
+ }).description('ChatLuna 工具调用'),
35
51
  });
36
52
  function apply(ctx, config) {
37
53
  const service = new service_1.DisasterWarningService(ctx, config);
38
54
  ctx.on('ready', () => service.start());
39
55
  ctx.on('dispose', () => service.stop());
40
56
  (0, commands_1.applyCommands)(ctx, config, service);
57
+ (0, chatluna_1.applyChatLunaTools)(ctx, config, service);
41
58
  }
package/lib/service.d.ts CHANGED
@@ -19,6 +19,20 @@ export declare class DisasterWarningService {
19
19
  private startWolfxHttpPoller;
20
20
  private handleEvent;
21
21
  private shouldPushEvent;
22
+ private shouldPushEarthquakeByLocation;
23
+ private detectEarthquakeRegion;
24
+ private detectRegionByPlace;
25
+ private includesAny;
26
+ private validLatitude;
27
+ private validLongitude;
28
+ private isTaiwanCoordinate;
29
+ private isJapanCoordinate;
30
+ private isMainlandChinaCoordinate;
31
+ private pointInPolygon;
32
+ private fallbackRegionForMissingCoordinates;
33
+ private shouldAllowUnknownEarthquakeRegion;
34
+ private fallbackRegionBySource;
35
+ private isRegionEnabled;
22
36
  getStatus(): Record<string, {
23
37
  connected: boolean;
24
38
  retryCount: number;
package/lib/service.js CHANGED
@@ -210,8 +210,192 @@ class DisasterWarningService {
210
210
  return false;
211
211
  if (globalSources.includes(src) && !regions.global)
212
212
  return false;
213
+ if ((isEarthquakeWarning || isEarthquakeInfo) && !this.shouldPushEarthquakeByLocation(event)) {
214
+ const data = event.data;
215
+ logger.debug(`[region] Skipping earthquake outside enabled regions: ${data.place_name || event.id}`);
216
+ return false;
217
+ }
213
218
  return true;
214
219
  }
220
+ shouldPushEarthquakeByLocation(event) {
221
+ const region = this.detectEarthquakeRegion(event.data, event.source);
222
+ switch (region) {
223
+ case 'china':
224
+ return this.config.regions.china;
225
+ case 'taiwan':
226
+ return this.config.regions.taiwan;
227
+ case 'japan':
228
+ return this.config.regions.japan;
229
+ case 'global':
230
+ return this.config.regions.global;
231
+ case 'unknown':
232
+ return this.shouldAllowUnknownEarthquakeRegion(event.source);
233
+ }
234
+ }
235
+ detectEarthquakeRegion(data, source) {
236
+ const place = data.place_name?.replace(/\s+/g, '').toLowerCase() || '';
237
+ const placeRegion = this.detectRegionByPlace(place);
238
+ if (placeRegion)
239
+ return placeRegion;
240
+ const lat = this.validLatitude(data.latitude);
241
+ const lon = this.validLongitude(data.longitude);
242
+ if (lat === undefined || lon === undefined)
243
+ return this.fallbackRegionForMissingCoordinates(data, source, place);
244
+ // Many upstream cancellation packets use 0,0 when coordinates are unavailable.
245
+ if (lat === 0 && lon === 0)
246
+ return this.fallbackRegionForMissingCoordinates(data, source, place);
247
+ if (this.isTaiwanCoordinate(lat, lon))
248
+ return 'taiwan';
249
+ if (this.isJapanCoordinate(lat, lon))
250
+ return 'japan';
251
+ if (this.isMainlandChinaCoordinate(lat, lon))
252
+ return 'china';
253
+ return 'global';
254
+ }
255
+ detectRegionByPlace(place) {
256
+ if (!place)
257
+ return null;
258
+ if (this.includesAny(place, [
259
+ '台湾', '臺灣', '台灣', 'taiwan', '花莲', '花蓮', '宜兰', '宜蘭', '台东', '臺東', '台東',
260
+ '台北', '臺北', '新北', '桃园', '桃園', '台中', '臺中', '台南', '臺南', '高雄', '嘉义', '嘉義',
261
+ '屏东', '屏東', '南投', '澎湖'
262
+ ]))
263
+ return 'taiwan';
264
+ if (this.includesAny(place, [
265
+ '日本', 'japan', '北海道', '本州', '四国', '四國', '九州', '沖縄', '冲绳', '琉球', '小笠原',
266
+ '伊豆', '鳥島', '鸟岛', '青森', '岩手', '宮城', '宫城', '秋田', '山形', '福島', '福岛',
267
+ '茨城', '栃木', '群馬', '群马', '埼玉', '千葉', '千叶', '東京', '东京', '神奈川', '新潟',
268
+ '富山', '石川', '福井', '山梨', '長野', '长野', '岐阜', '静岡', '静冈', '愛知', '爱知',
269
+ '三重', '滋賀', '滋贺', '京都', '大阪', '兵庫', '兵库', '奈良', '和歌山', '鳥取', '鸟取',
270
+ '島根', '岛根', '岡山', '冈山', '広島', '广岛', '山口', '徳島', '德岛', '香川', '愛媛',
271
+ '爱媛', '高知', '福岡', '福冈', '佐賀', '佐贺', '長崎', '长崎', '熊本', '大分', '宮崎',
272
+ '宫崎', '鹿児島', '鹿儿岛'
273
+ ]))
274
+ return 'japan';
275
+ if (this.includesAny(place, [
276
+ '中国', 'china', '北京', '天津', '河北', '山西', '内蒙古', '辽宁', '遼寧', '吉林', '黑龙江',
277
+ '黑龍江', '上海', '江苏', '江蘇', '浙江', '安徽', '福建', '江西', '山东', '山東', '河南',
278
+ '湖北', '湖南', '广东', '廣東', '广西', '廣西', '海南', '重庆', '重慶', '四川', '贵州',
279
+ '貴州', '云南', '雲南', '西藏', '陕西', '陝西', '甘肃', '甘肅', '青海', '宁夏', '寧夏',
280
+ '新疆', '香港', '澳门', '澳門'
281
+ ]))
282
+ return 'china';
283
+ return null;
284
+ }
285
+ includesAny(text, keywords) {
286
+ return keywords.some((keyword) => text.includes(keyword.toLowerCase()));
287
+ }
288
+ validLatitude(value) {
289
+ return Number.isFinite(value) && value >= -90 && value <= 90 ? value : undefined;
290
+ }
291
+ validLongitude(value) {
292
+ return Number.isFinite(value) && value >= -180 && value <= 180 ? value : undefined;
293
+ }
294
+ isTaiwanCoordinate(lat, lon) {
295
+ return lat >= 21.5 && lat <= 26.5 && lon >= 119 && lon <= 123.8;
296
+ }
297
+ isJapanCoordinate(lat, lon) {
298
+ const mainIslands = lat >= 30 && lat <= 46.5 && lon >= 129 && lon <= 146.5;
299
+ const ryukyu = lat >= 24 && lat < 30 && lon >= 122 && lon <= 131.5;
300
+ const izuOgasawara = lat >= 20 && lat <= 34 && lon >= 139 && lon <= 154.5;
301
+ return mainIslands || ryukyu || izuOgasawara;
302
+ }
303
+ isMainlandChinaCoordinate(lat, lon) {
304
+ if (lat >= 18 && lat <= 20.5 && lon >= 108 && lon <= 111.5)
305
+ return true;
306
+ // Rough mainland China outline. Place-name detection runs first, this is only a coordinate fallback.
307
+ return this.pointInPolygon(lon, lat, [
308
+ [73.5, 39.5],
309
+ [74.9, 37.2],
310
+ [75.5, 35.4],
311
+ [78.4, 32.5],
312
+ [78.7, 30.2],
313
+ [80.5, 30.0],
314
+ [81.8, 28.5],
315
+ [85.2, 28.3],
316
+ [88.9, 27.3],
317
+ [91.0, 27.8],
318
+ [93.3, 28.7],
319
+ [95.5, 28.2],
320
+ [97.3, 23.8],
321
+ [98.9, 24.1],
322
+ [100.1, 21.4],
323
+ [101.8, 21.2],
324
+ [103.8, 22.5],
325
+ [106.8, 20.0],
326
+ [108.7, 18.2],
327
+ [111.3, 18.0],
328
+ [113.7, 21.8],
329
+ [116.7, 22.7],
330
+ [118.5, 24.5],
331
+ [121.8, 29.0],
332
+ [122.2, 31.8],
333
+ [121.5, 35.0],
334
+ [124.0, 39.8],
335
+ [126.8, 41.9],
336
+ [130.0, 42.3],
337
+ [134.7, 48.2],
338
+ [131.0, 47.7],
339
+ [128.0, 49.6],
340
+ [123.3, 53.5],
341
+ [119.5, 50.0],
342
+ [117.0, 49.6],
343
+ [111.0, 49.2],
344
+ [105.0, 45.0],
345
+ [97.0, 42.8],
346
+ [91.0, 45.2],
347
+ [87.0, 49.0],
348
+ [82.0, 47.2],
349
+ [79.0, 43.0],
350
+ ]);
351
+ }
352
+ pointInPolygon(lon, lat, polygon) {
353
+ let inside = false;
354
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
355
+ const xi = polygon[i][0];
356
+ const yi = polygon[i][1];
357
+ const xj = polygon[j][0];
358
+ const yj = polygon[j][1];
359
+ const intersects = ((yi > lat) !== (yj > lat)) &&
360
+ (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi);
361
+ if (intersects)
362
+ inside = !inside;
363
+ }
364
+ return inside;
365
+ }
366
+ fallbackRegionForMissingCoordinates(data, source, place) {
367
+ if (place && !data.is_cancel)
368
+ return 'global';
369
+ return this.fallbackRegionBySource(source);
370
+ }
371
+ shouldAllowUnknownEarthquakeRegion(source) {
372
+ const fallback = this.fallbackRegionBySource(source);
373
+ if (fallback === 'unknown')
374
+ return this.config.regions.global;
375
+ return this.isRegionEnabled(fallback);
376
+ }
377
+ fallbackRegionBySource(source) {
378
+ if ([models_1.DataSource.FAN_STUDIO_CEA, models_1.DataSource.FAN_STUDIO_CENC, models_1.DataSource.WOLFX_CENC_EEW, models_1.DataSource.WOLFX_CENC_EQ].includes(source))
379
+ return 'china';
380
+ if ([models_1.DataSource.FAN_STUDIO_CWA, models_1.DataSource.WOLFX_CWA_EEW].includes(source))
381
+ return 'taiwan';
382
+ if ([models_1.DataSource.FAN_STUDIO_JMA, models_1.DataSource.P2P_EEW, models_1.DataSource.P2P_EARTHQUAKE, models_1.DataSource.WOLFX_JMA_EEW, models_1.DataSource.WOLFX_JMA_EQ].includes(source))
383
+ return 'japan';
384
+ if ([models_1.DataSource.FAN_STUDIO_USGS, models_1.DataSource.GLOBAL_QUAKE].includes(source))
385
+ return 'global';
386
+ return 'unknown';
387
+ }
388
+ isRegionEnabled(region) {
389
+ if (region === 'china')
390
+ return this.config.regions.china;
391
+ if (region === 'taiwan')
392
+ return this.config.regions.taiwan;
393
+ if (region === 'japan')
394
+ return this.config.regions.japan;
395
+ if (region === 'global')
396
+ return this.config.regions.global;
397
+ return false;
398
+ }
215
399
  // ---- 状态查询(供 commands 使用)------------------------------------
216
400
  getStatus() {
217
401
  const result = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-disaster-warning",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Koishi 灾害预警插件,支持多数据源(地震、海啸、气象预警)",
5
5
  "contributors": [
6
6
  "lumia.wang <fenglian19980510@gmail.com>"
@@ -27,19 +27,20 @@
27
27
  "chatbot",
28
28
  "koishi",
29
29
  "plugin",
30
+ "chatluna",
30
31
  "disaster",
31
32
  "earthquake",
32
33
  "warning"
33
34
  ],
34
35
  "peerDependencies": {
35
- "koishi": "^4.18.0"
36
+ "koishi": "^4.18.0",
37
+ "koishi-plugin-chatluna": "^1.3.0 || ^1.4.0-alpha.0"
36
38
  },
37
39
  "devDependencies": {
38
40
  "@types/node": "^20.0.0",
39
41
  "@types/ws": "^8.5.10",
40
42
  "koishi": "^4.18.0",
41
- "typescript": "^5.0.0",
42
- "ws": "^8.18.0"
43
+ "typescript": "^5.0.0"
43
44
  },
44
45
  "koishi": {
45
46
  "description": {
@@ -51,8 +52,19 @@
51
52
  "http"
52
53
  ],
53
54
  "optional": [
54
- "database"
55
+ "database",
56
+ "chatluna"
55
57
  ]
56
58
  }
59
+ },
60
+ "dependencies": {
61
+ "@langchain/core": "^0.3.80",
62
+ "ws": "^8.18.0",
63
+ "zod": "^3.25.76"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "koishi-plugin-chatluna": {
67
+ "optional": true
68
+ }
57
69
  }
58
70
  }