426 lines
12 KiB
TypeScript
426 lines
12 KiB
TypeScript
import WebSocket from "ws";
|
|
import { differenceInMinutes, subMinutes } from "date-fns";
|
|
import sendMail from "../src/mailer.js";
|
|
|
|
import config from "../config.js";
|
|
|
|
let rateLimit: Date | null = null;
|
|
|
|
class P2PEarthquakeClient {
|
|
private ws: WebSocket | null = null;
|
|
private reconnectInterval: number = config.earthquake.reconnectTimes;
|
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
private isConnecting: boolean = false;
|
|
|
|
public start(): void {
|
|
this.connect();
|
|
this.setupCleanup();
|
|
}
|
|
|
|
private connect(): void {
|
|
if (this.isConnecting) return;
|
|
|
|
this.isConnecting = true;
|
|
console.log("地震情報サーバーに接続中");
|
|
|
|
try {
|
|
this.ws = new WebSocket(config.earthquake.websocketUrl);
|
|
|
|
this.ws.on("open", () => {
|
|
console.log("地震情報サーバーに接続しました");
|
|
this.isConnecting = false;
|
|
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
});
|
|
|
|
this.ws.on("message", (data: WebSocket) => {
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
this.handleMessage(message);
|
|
} catch (error) {
|
|
console.error("メッセージのパースに失敗:", error);
|
|
}
|
|
});
|
|
|
|
this.ws.on("close", (code: number, reason: Buffer) => {
|
|
console.log(`切断されました: ${code} - ${reason.toString()}`);
|
|
this.isConnecting = false;
|
|
this.scheduleReconnect();
|
|
});
|
|
|
|
this.ws.on("error", (error: Error) => {
|
|
console.error("WebSocketエラー:", error);
|
|
this.isConnecting = false;
|
|
this.scheduleReconnect();
|
|
});
|
|
} catch (error) {
|
|
console.error("接続エラー:", error);
|
|
this.isConnecting = false;
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
private handleMessage(message: any): void {
|
|
console.log("----------------");
|
|
|
|
switch (message.code) {
|
|
case 551: // 地震情報
|
|
console.log("地震情報を受信しました");
|
|
this.executeEventFunc(message);
|
|
break;
|
|
case 554: // 緊急地震速報
|
|
console.log("緊急地震速報を受信しました");
|
|
this.executeEventFunc(message);
|
|
break;
|
|
case 555: // 地域情報更新情報
|
|
console.log("地域情報更新を受信しました");
|
|
this.executeEventFunc(message);
|
|
break;
|
|
default:
|
|
console.log(`未対応の情報を受信しました(コード: ${message.code})`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private executeEventFunc(earthquakeInfo: any): void {
|
|
event(earthquakeInfo);
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
if (this.reconnectTimer) return;
|
|
|
|
console.log("地震情報サーバーから切断されました");
|
|
console.log(`${this.reconnectInterval / 1000}秒後に再接続を試みます`);
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null;
|
|
this.connect();
|
|
}, this.reconnectInterval);
|
|
}
|
|
|
|
private setupCleanup(): void {
|
|
const cleanup = () => {
|
|
this.stop();
|
|
process.exit(0);
|
|
};
|
|
|
|
process.on("SIGINT", cleanup);
|
|
process.on("SIGTERM", cleanup);
|
|
}
|
|
|
|
public stop(): void {
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 地名オブジェクトマッピング
|
|
async function areaMap(): Promise<Record<number, string>> {
|
|
const res = await fetch(config.earthquake.areasCsvUrl);
|
|
|
|
const text = await res.text();
|
|
|
|
const lines = text.split("\n");
|
|
const map: Record<number, string> = {};
|
|
|
|
for (const line of lines) {
|
|
const cols = line.split(",");
|
|
|
|
if (cols.length >= 3 && /^\d+$/.test(cols[0])) {
|
|
const id = Number(cols[0]);
|
|
const name = cols[2].trim();
|
|
|
|
map[id] = name;
|
|
}
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
// 情報受信
|
|
async function event(earthquakeInfo: any): Promise<void> {
|
|
console.log(`受信データ:${JSON.stringify(earthquakeInfo)}`);
|
|
|
|
// ----処理----
|
|
|
|
// 緊急地震速報の場合
|
|
if (earthquakeInfo.code === 554) {
|
|
// 地震詳細
|
|
let descriptionEarthquake: string = "";
|
|
|
|
if (earthquakeInfo.earthquake.description !== "") {
|
|
descriptionEarthquake = `この地震について:${earthquakeInfo.earthquake.description}`;
|
|
}
|
|
|
|
// 発令詳細
|
|
let description: string = "";
|
|
|
|
if (earthquakeInfo.comments.freeFormComment !== "") {
|
|
description = `この発令について:${earthquakeInfo.comments.freeFormComment}`;
|
|
}
|
|
|
|
// テスト・訓練
|
|
let test: string = "";
|
|
|
|
if (earthquakeInfo.test) {
|
|
test = "これはテスト、あるいは訓練です";
|
|
} else if (earthquakeInfo.test === false) {
|
|
test = "これはテスト・訓練ではありません";
|
|
} else {
|
|
test = "この情報にテスト・訓練かの情報はありません";
|
|
}
|
|
|
|
// 対象地域
|
|
let areas: string = "";
|
|
|
|
if (earthquakeInfo.areas !== null) {
|
|
const areaNames: Array<string> = Array.from(
|
|
new Set(
|
|
earthquakeInfo.areas.map((point: any) => point.name).filter(Boolean),
|
|
),
|
|
);
|
|
areas = `対象地域:${areaNames.join("・")}`;
|
|
}
|
|
|
|
// 速報取り消し
|
|
let cancelled: string = "";
|
|
|
|
if (earthquakeInfo.cancelled) {
|
|
cancelled = "※以下の緊急地震速報が取り消されました※";
|
|
}
|
|
|
|
// マグニチュード
|
|
let magnitude: string = "マグニチュード:";
|
|
|
|
if (
|
|
earthquakeInfo.earthquake.hypocenter.magnitude !== -1 ||
|
|
earthquakeInfo.earthquake.hypocenter.magnitude === null
|
|
) {
|
|
magnitude += "マグニチュードの情報はありません";
|
|
} else {
|
|
magnitude += `M${String(earthquakeInfo.earthquake.hypocenter.magnitude)}`;
|
|
}
|
|
|
|
ueuse(`
|
|
==地震情報==
|
|
【緊急地震速報】
|
|
${cancelled}
|
|
時刻:${earthquakeInfo.time}
|
|
${descriptionEarthquake}
|
|
${description}
|
|
${test}
|
|
${areas}
|
|
`);
|
|
}
|
|
|
|
// 地震情報
|
|
else if (earthquakeInfo.code === 551) {
|
|
// 国内津波
|
|
let domesticTsunami;
|
|
|
|
if (earthquakeInfo.earthquake.domesticTsunami === "None") {
|
|
domesticTsunami = "この地震による国内の津波の心配はありません";
|
|
} else if (earthquakeInfo.earthquake.domesticTsunami === "Unknown") {
|
|
domesticTsunami = "この地震による国内の津波情報はありません";
|
|
} else if (earthquakeInfo.earthquake.domesticTsunami === "Checking") {
|
|
domesticTsunami = "この地震による国内の津波情報を調査中です";
|
|
} else if (earthquakeInfo.earthquake.domesticTsunami === "NonEffective") {
|
|
domesticTsunami =
|
|
"この地震による国内の津波影響は若干の海面変動が予想されますが被害の心配はありません";
|
|
} else if (earthquakeInfo.earthquake.domesticTsunami === "Watch") {
|
|
domesticTsunami = "この地震により国内で津波注意報が発令しています";
|
|
} else if (earthquakeInfo.earthquake.domesticTsunami === "Warning") {
|
|
domesticTsunami = "この地震による国内の津波予報があります";
|
|
} else {
|
|
domesticTsunami = "この地震による国内の津波情報はありません";
|
|
}
|
|
|
|
// 最大震度
|
|
let maxScale: string = "最大深度:";
|
|
|
|
if (earthquakeInfo.earthquake.maxScale < config.earthquake.maxScaleMin) {
|
|
console.log("最低震度に満たしていないため投稿されませんでした");
|
|
return;
|
|
}
|
|
|
|
if (
|
|
earthquakeInfo.earthquake.maxScale === -1 ||
|
|
earthquakeInfo.earthquake.maxScale === null
|
|
) {
|
|
maxScale = "最大震度情報なし";
|
|
} else if (earthquakeInfo.earthquake.maxScale === 10) {
|
|
maxScale += "震度1";
|
|
} else if (earthquakeInfo.earthquake.maxScale === 20) {
|
|
maxScale += "震度2";
|
|
} else if (earthquakeInfo.earthquake.maxScale === 30) {
|
|
maxScale += "震度3";
|
|
} else if (earthquakeInfo.earthquake.maxScale === 40) {
|
|
maxScale += "震度4";
|
|
} else if (earthquakeInfo.earthquake.maxScale === 45) {
|
|
maxScale += "震度5弱";
|
|
} else if (earthquakeInfo.earthquake.maxScale === 50) {
|
|
maxScale += "震度5強";
|
|
} else if (earthquakeInfo.earthquake.maxScale === 55) {
|
|
maxScale += "震度6弱";
|
|
} else if (earthquakeInfo.earthquake.maxScale === 60) {
|
|
maxScale += "震度6強";
|
|
} else if (earthquakeInfo.earthquake.maxScale === 70) {
|
|
maxScale += "震度7";
|
|
}
|
|
|
|
// 警告
|
|
if (
|
|
earthquakeInfo.earthquake.maxScale >= 60 ||
|
|
config.emergency.function
|
|
) {
|
|
console.log("----------------");
|
|
|
|
console.log("震度6強以上の地震を受信しました");
|
|
console.log("サーバーがダウンする可能性があります");
|
|
|
|
// メール送信
|
|
if (config.emergency.function) {
|
|
sendMail({
|
|
from: "noticeUwuzu自動送信",
|
|
to: config.emergency.mail.to,
|
|
subject: "【警告】震度6強以上の地震を受信しました",
|
|
html: `
|
|
※noticeUwuzu自動送信によるメールです。
|
|
【警告】
|
|
BOT管理者さん、noticeUwuzu自動送信メールです。
|
|
震度6強以上の地震を受信したため警告メールが送信されました。
|
|
物理、システム的にサーバーがダウンする可能性があります。
|
|
ご自身の身をお守りください。
|
|
`
|
|
});
|
|
|
|
console.log("管理者へ警告メールを送信しました");
|
|
}
|
|
|
|
console.log("----------------");
|
|
}
|
|
|
|
// 対象地域
|
|
let areas: string = "";
|
|
|
|
if (earthquakeInfo.points !== null) {
|
|
const areaNames: Array<string> = Array.from(
|
|
new Set(
|
|
earthquakeInfo.points.map((point: any) => point.addr).filter(Boolean),
|
|
),
|
|
);
|
|
areas = `対象地域:${areaNames.join("・")}`;
|
|
}
|
|
|
|
// 詳細
|
|
let description: string = "";
|
|
|
|
if (earthquakeInfo.comments.freeFormComment !== "") {
|
|
description = `この地震について:${earthquakeInfo.comments.freeFormComment}`;
|
|
}
|
|
|
|
// 深さ
|
|
let depth: string = "";
|
|
|
|
if (
|
|
earthquakeInfo.earthquake.hypocenter.depth !== null ||
|
|
earthquakeInfo.earthquake.hypocenter.depth !== undefined ||
|
|
earthquakeInfo.earthquake.hypocenter.depth !== -1
|
|
) {
|
|
if (earthquakeInfo.earthquake.hypocenter.depth === 0) {
|
|
depth = "深さ:ごく浅い";
|
|
} else {
|
|
depth = `深さ:${earthquakeInfo.hypocenter.depth}km`;
|
|
}
|
|
}
|
|
|
|
// マグニチュード
|
|
let magnitude: string = "";
|
|
|
|
if(
|
|
earthquakeInfo.earthquake.hypocenter.magnitude !== null ||
|
|
earthquakeInfo.earthquake.hypocenter.magnitude !== undefined ||
|
|
earthquakeInfo.earthquake.hypocenter.magnitude !== -1
|
|
) {
|
|
magnitude = `マグニチュード:${earthquakeInfo.earthquake.hypocenter.magnitude}`;
|
|
}
|
|
|
|
ueuse(`
|
|
==地震情報==
|
|
【地震発生】
|
|
時刻:${earthquakeInfo.time}
|
|
${description}
|
|
${magnitude}
|
|
${depth}
|
|
${maxScale}
|
|
${areas}
|
|
国内の津波:${domesticTsunami}
|
|
`);
|
|
}
|
|
|
|
// 地域情報更新の場合
|
|
else if (earthquakeInfo.code === 555) {
|
|
if (rateLimit === null) {
|
|
rateLimit = subMinutes(new Date(), config.earthquake.rateLimit + 15);
|
|
}
|
|
|
|
// 対象地域マッピング
|
|
const areaMaps: any = await areaMap();
|
|
|
|
const areaNames: Array<string> = Array.from(
|
|
new Set(
|
|
earthquakeInfo.areas
|
|
.map((i: any) => {
|
|
return areaMaps[i.id];
|
|
})
|
|
.filter(Boolean),
|
|
),
|
|
);
|
|
|
|
const areas = areaNames.join("・");
|
|
|
|
if (Math.abs(differenceInMinutes(rateLimit, new Date())) >= config.earthquake.rateLimit) {
|
|
ueuse(`
|
|
==地震情報==
|
|
【地域情報更新】
|
|
時刻:${earthquakeInfo.time}
|
|
対象地域:${areas}
|
|
`);
|
|
|
|
rateLimit = new Date();
|
|
} else {
|
|
console.log("レート制限に満たしていないため投稿されませんでした");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function ueuse(text: string) {
|
|
const res = await fetch(`https://${config.uwuzuServer}/api/ueuse/create`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
token: config.apiToken,
|
|
text: text,
|
|
}),
|
|
});
|
|
|
|
const resData = await res.json();
|
|
|
|
console.log(`地震情報投稿:${JSON.stringify(resData)}`);
|
|
}
|
|
|
|
export default function earthquakeNotice(): void {
|
|
console.log("地震情報サーバーに接続します");
|
|
const client = new P2PEarthquakeClient();
|
|
client.start();
|
|
}
|