Compare commits

..

57 Commits

Author SHA1 Message Date
last2014 db719b8312 v8.1.2 2025-08-08 21:34:30 +09:00
last2014 2966eee889 v8.1.1 2025-08-08 21:31:43 +09:00
last2014 db5e174dd4 v8.1 2025-08-08 21:29:19 +09:00
last2014 2b53b8cd3d v8.0.2 2025-08-07 08:56:49 +09:00
last2014 d20f630e1b v8.0.1 2025-08-07 08:47:11 +09:00
last2014 644ba912b1 v8.0 2025-08-06 21:27:29 +09:00
last2014 1ff4b2c429 Merge branch 'main' of https://gitea.last2014.com/last2014/noticeUwuzu 2025-08-05 20:01:36 +09:00
last2014 0a3bb241a6 v7.5 2025-08-05 19:58:09 +09:00
last2014 f14999be13 デバッグ用コード削除 2025-08-04 23:45:52 +09:00
last2014 6ec9831ed4 v7.3@uwuzu1.5.4をリリース 2025-08-04 20:08:23 +09:00
last2014 718e97ed45 v7.2@uwuzu1.5.4をリリース 2025-08-02 19:39:50 +09:00
last2014 f6f7030d8c APIパス修正・無駄import削除 2025-08-01 22:03:36 +09:00
last2014 a2576f961b APIパスの問題・デバッグ用コメントアウトを修正 2025-08-01 19:53:41 +09:00
last2014 844a9cd058 APIの500問題を修正 2025-08-01 19:39:21 +09:00
last2014 2a30fdf1e9 v7.0.1(LTS)をリリース 2025-08-01 19:09:18 +09:00
last2014 dc67845bc8 v7.0(unconfirmed)をリリース 2025-08-01 18:57:15 +09:00
last2014 50db83cc8c バージョン表記を変更 2025-07-31 22:03:52 +09:00
last2014 d7a5d8a43e v6.5(LTS)をリリース 2025-07-31 22:02:54 +09:00
last2014 1c10aeb9e4 API確認の複数の問題を修正 2025-07-26 21:46:19 +09:00
last2014 081d1eca6e クライアントトークンの?の位置がずれていた問題を修正 2025-07-26 21:38:07 +09:00
last2014 0e9c8f8225 クライアントトークンの収集を任意に・起動時確認の順序を修正 2025-07-26 21:33:55 +09:00
last2014 724e18ba3b noticeUwuzu v6.0@uwuzu1.5.4 2025-07-26 21:28:07 +09:00
last2014 3955d91978 深さ判定に.earthquake.をスキップしてhypocenterを参照していた問題を修正(v5.1.1) 2025-07-08 13:47:10 +09:00
last2014 d28ca6ce4c メールに改行を追加・起動時にバージョンを表示・v5.1へ 2025-07-07 15:24:36 +09:00
last2014 ad0d975a12 fromをメールアドレスに統一(v5.0.3) 2025-07-07 15:11:49 +09:00
last2014 7f07710d23 地震情報でnullではなくundefinedによる存在確認を使用(v5.0.2) 2025-07-07 14:50:42 +09:00
last2014 3b58e7ee2d tsc後用に.jsを追加 2025-07-07 03:19:00 +09:00
last2014 22ec582e0a v5.0 2025-07-06 22:11:59 +09:00
last2014 05194ad7b8 停止時間のBooleanを使いまわしている問題を修正(v4.3.3) 2025-07-06 08:53:02 +09:00
last2014 ef374b1639 レート制限が聞かないのを修正(v4.3.2) 2025-07-05 16:24:59 +09:00
last2014 563d70aacd 地域情報更新のレート時刻初期化を修正(v4.3.1) 2025-07-05 16:18:02 +09:00
last2014 568ae7abf6 デバッグ表示を分かりやすく2(v4.3) 2025-07-05 14:06:37 +09:00
last2014 c3ab3a8456 時報休止期間を修正・package.jsonの情報を修正・v4.2 2025-07-05 11:46:43 +09:00
last2014 678f4aa1b1 誤字を修正・デバッグ表示の--線の位置を変更・v4.1.2 2025-07-04 21:55:13 +09:00
last2014 a96c3f0e4f サーバー起動時の時刻を使用した上HH:mm以外のフォーマットで時報をしている問題を修正(v4.1.1) 2025-07-04 21:44:54 +09:00
last2014 53ba26f417 デバッグ用の情報を分かりやすく・v4.1 2025-07-04 20:07:34 +09:00
last2014 6b68b6e1b5 地域情報更新のレート制限がnullで初期化されていない(v4.0.1) 2025-07-04 19:17:31 +09:00
last2014 e3451323f3 時報
の停止期間設定機能を追加・地震発生お知らせにマグニチュード/深さを追加・天気お知らせに%が2つ付く問題を修正・地域情報更新が正常に発信されない問題を修正・地域情報更新のレート制限のデフォルト設定を30分に一回に・v4.0
2025-07-04 19:07:05 +09:00
last2014 8be3684a78 <>のミスを修正(v3.5.6) 2025-07-03 15:02:00 +09:00
last2014 600626d071 v3.5.5 2025-07-03 07:57:08 +09:00
last2014 5c0c34bbb4 ゆずねっと向け早急対処 2025-07-03 07:56:57 +09:00
last2014 88bd2e88a5 緊急地震速報にも反映→v3.5.3 2025-07-02 16:49:06 +09:00
last2014 7c7dfdcf3c examples/config.tsにREADMEを参照する旨をコメント・v3.5.2へ 2025-07-02 16:44:18 +09:00
last2014 dd70fe5494 文字列を参照していた問題を修正 2025-07-02 16:42:06 +09:00
last2014 73091bb92f .filterによるクラッシュを修正 2025-07-01 18:54:02 +09:00
last2014 6c69ce80ef v3.5.1へ・ライセンス追加・.env.exampleを削除 2025-07-01 17:10:52 +09:00
last2014 7e3c4840d3 dotenvを廃止しconfig.tsへ・天気の分割数をconfigで変更できるように・デバッグ用のサンプルログを.gitignoreに追加・地震情報解析の修正 2025-07-01 16:59:45 +09:00
last2014 007efd85a8 v3.5 2025-07-01 07:46:38 +09:00
last2014 2689dfd777 地震速報の情報を増量 2025-07-01 07:44:44 +09:00
last2014 909258f421 v3.0へ 2025-07-01 00:02:06 +09:00
last2014 de3c970712 地震情報を追加(最低限情報) 2025-07-01 00:01:35 +09:00
last2014 c5bd22600a 天気機能を最適化 2025-06-30 12:24:03 +09:00
last2014 c4d2990118 天気お知らせを修正 2025-06-30 10:47:56 +09:00
last2014 5bd251f6eb v2.0 2025-06-29 18:56:27 +09:00
last2014 761af6eafc console.log()削除 2025-06-28 12:59:30 +09:00
last2014 f3cc634bb9 型を作成・コードの整形・フォローバック機能作成(使用未定)・README作成・.env.example作成・ts-node廃止tsx移行 2025-06-28 12:53:30 +09:00
last2014 4aa58eb55d v1.0@uwuzu1.5.4 2025-06-28 11:22:54 +09:00
73 changed files with 2505 additions and 27881 deletions
+5 -5
View File
@@ -1,5 +1,5 @@
/node_modules /dist/
/dist /.env*
/memory.json /node_modules/
/config/** /package-lock.json
!/config/example.yaml /config.ts
-24841
View File
File diff suppressed because it is too large Load Diff
-85
View File
@@ -1,85 +0,0 @@
# 2026.4.0
- Breaking: noticeUwuzuを0から作り直しました
- Breaking: 25.12.0-alpha.1までの方針を全て廃止しました
- Chg: パッケージマネージャーをnpmからpnpmへ変更しました
- Chg: 全コードをsrcへ移動しました
- Chg: ユーズのメッセージをyamlとi18nextで管理することで先頭と末尾の改行のある問題などに対策しました
- Chg: 全ての保存データをmemory.jsonとして保管し、memory.tsを実装しました
- Note: 以下のログは全て実装として記録しています
- Feat: ユーザーは以下のコマンドを使用することができます。
- `/weather`: 天気予報を取得できます。
- `/help`: コマンドの利用方法などを取得できます。
- `/miq`: Make it a Quoteを操作できます。
- `/follow`: Botからフォローされます。
- `/unfollow`: Botからフォロー解除されます。
- Feat: 地震情報が利用できます。以下の情報を投稿します。
- 地震発生情報
- 津波予報
- 緊急地震速報(警報)
- Feat: 時報が利用できます。毎時0分に投稿します。
- Feat: 天気予報を利用できます。毎日7:00に投稿します。
- Feat: 新年迎春の投稿を行います。遅延が最低限になるように出来ています。
- Feat: worker_threadsによる並列処理
- Feat: ユーズの再試行
- Feat: ユーズの文字数制限回避
- Feat: コマンドを並列処理
- Feat: 地震情報のWebSocket再接続
- Feat: 最大震度の要求を設定できる機能
- Feat: 最大震度が不明な場合に投稿するかどうかを設定できる機能
- Feat: デバッグ用とで過去の地震情報を読み込み10秒おきに投稿する機能
# 2026.4.0-beta.0
- Feat: 地震発生情報の地域を都道府県でグループ化する機能
- Feat: ユーズの再試行
- Feat: ユーズの文字数制限回避
- Feat: ユーズ送信関数
- Feat: コマンドを並列処理
- Feat: 地震情報のWebSocket再接続
- Feat: 天気予報の分割数を自動的に計算する機能
- Feat: 最大震度の要求を設定できる機能
- Feat: 最大震度が不明な場合に投稿するかどうかを設定できる機能
- Chg: 地震発生情報の時刻に頃を追加
- Chg: weatherNotice.tsのマジックナンバーに命名
- Fix: 震度0の地震情報に対応していない問題
- Fix: コマンド処理済みのユーズのid除外が正しく動作しない問題
- Fix: miqコマンドのコンソール出力に値が存在しない問題
# 2026.4.0-alpha.2
- Feat: 地震情報のid除外
- Feat: 新年迎春
- Fix: for文でスキップではなくループの終了を行っていた問題
- Feat: 返信済みかどうかをuniqidのみで判断する機能
- Chg: 時報の時刻フォーマットをHH:mmからH:mmに変更
- Feat: 緊急地震速報(警報)解除
# 2026.4.0-alpha.1
- Del: tsxの開発環境を削除
- Chg: typescriptを5.9.3にダウングレード
- Feat: 緊急地震速報の最大予測震度の上限に99(〜程度以上)を実装
- Chg: 緊急地震速報の最大予測震度で上限と下限が一致する場合に"から〜"を投稿内容に含めないように
- Feat: worker_threadsによる並列処理
- Del: 各地震情報の情報源の信頼できるかどうかの内容を削除
- Chg: 津波予報情報の各時刻情報を実際に取得できる値まで削減(例: 2:03:00 > 2:03)
- Fix: フォロー・フォロー解除に失敗した際にreturnできていない問題
- Feat: デバッグ用で過去の地震情報を読み込み10秒おきに投稿する機能
# 2026.4.0-alpha.0
- Breaking: noticeUwuzuを0から作り直しました
- Breaking: 25.12.0-alpha.1までの方針を全て廃止しました
- Chg: パッケージマネージャーをnpmからpnpmへ変更しました
- Chg: 全コードをsrcへ移動しました
- Chg: ユーズのメッセージをyamlとi18nextで管理することで先頭と末尾の改行のある問題などに対策しました
- Chg: 全ての保存データをmemory.jsonとして保管し、memory.tsを実装しました
- Note: 以下のログは全て重要な変更以外の再実装として判断しています
- Feat: ユーザーは以下のコマンドを使用することができます。
- `/weather`: 天気予報を取得できます。
- `/help`: コマンドの利用方法などを取得できます。
- `/miq`: Make it a Quoteを操作できます。
- `/follow`: Botからフォローされます。
- `/unfollow`: Botからフォロー解除されます。
- Feat: 地震情報が利用できます。以下の情報を投稿します。
- 地震発生情報
- 津波予報
- 緊急地震速報(警報)
- Feat: 時報が利用できます。毎時0分に投稿します。
- Feat: 天気予報を利用できます。毎日7:00に投稿します。
+13
View File
@@ -0,0 +1,13 @@
Copyright 2025 Last2014
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+41 -28
View File
@@ -1,31 +1,44 @@
# noticeUwuzu # noticeUwuzu
[uwuzu](https://www.uwuzu.com)向けのお知らせBotです。
uwuzu v1.6.3以上で利用可能です。
## 機能 # Overview
- 時報
毎時0分に「h:00になりました。」と投稿します。
- 天気予報
[天気予報 APIlivedoor 天気互換)](https://weather.tsukumijima.net/)を利用して、毎日7:00に天気予報を投稿します。
- 地震情報
[P2P地震情報](https://www.p2pquake.net/)のWebSocket APIを利用して、以下の情報を受信した際に投稿します。
- 地震発生
- 緊急地震速報(警報)
- 津波予報
- 新年迎春
毎年1/1 0:00に投稿します。
- デバッグモード
設定で有効化することで、以下の機能が利用できます。
開発以外では使用しないでください。
- NODE_TLS_REJECT_UNAUTHORIZED=1
暗号化通信を無効化します。
- 地震情報のWebSocketの接続先をテスト用サンドボックスに変更
P2P地震情報の接続先を本番環境から[テスト用サンドボックスのエンドポイント](https://www.p2pquake.net/develop/json_api_v2/)に変更します。
そのため、最新の情報は配信されません。
- i18nextのdebugを有効化
[i18nextでのdebug](https://www.i18next.com/overview/configuration-options#logging)が有効化されます。
## 260420.jsonについて Automatic notification bot for uwuzu
このデータは2026年4月20日に三陸で発生した地震の後21件を記録したものです。
P2P地震情報APIのhistoryで取得しました。 # Functions
検証などにご利用ください。
- Time notification
- Earthquake information notification
- Earthquake Early Warning
- Earthquake occurs
- Tsunami forecast
- Weather notification
- Commands that users can use freely
- About BOT
- Command Help
- Report to the operator
- Follow back
- Unfollow
- Weather Repost
- Startup requirements check
- Check package existence
- Required package version check
- Check uwuzu API
- Check the configuration file
- Version change notification
- ASCII art at startup
- Confirm normal completion
# Config
An example configuration file is available in `examples/config.ts`.
# Start the server
```bash
npm install
npm run build
npm run start
```
Recommended Node.js version: v22.16.0
# License
[Apache 2.0 License](https://www.apache.org/licenses/LICENSE-2.0)
+5 -5
View File
@@ -1,5 +1,5 @@
# # ###### ##### ### ###### ####### # # # # # # ###### # # # # ###### ##### ### ###### ####### # # # # # # ###### # #
## # # # # # # # # # # # # # # # # # ## # # # # # # # # # # # # # # # # #
# ## # # # # # # ####### # # # # # # # # ## # # # ## # # # # # # ####### # # # # # # # # ## # #
# ## # # # # # # # # # # # # # # # # ## # # # # # # # # # # # # # # #
# # ###### # ### ###### ####### ###### # # ###### ###### ###### # # ###### # ### ###### ####### ###### # # ###### ###### ######
+30
View File
@@ -0,0 +1,30 @@
import { styleText } from "util";
import config from "../config.js";
export default async function APICheck() {
try {
const req = await fetch(`${config.uwuzu.host}/api/me/`, {
method: "POST",
body: JSON.stringify({
token: config.uwuzu.apiToken,
}),
cache: "no-store",
});
const res = await req.json();
if (res.error_code !== undefined) {
console.log(styleText("red", "APIトークンあるいはuwuzuサーバーホストが無効です"));
process.exit();
}
if (res.isBot === false) {
setTimeout(() => {
console.log(styleText("yellow", "使用するアカウントでBOTフラグが設定されていません"));
}, 1500);
}
} catch (err) {
console.log(styleText("red", `uwuzuサーバーへ接続できませんでした: ${err}`));
process.exit();
}
}
+9
View File
@@ -0,0 +1,9 @@
import * as fs from "fs";
import { styleText } from "util";
export default function ConfigCheck() {
if (!fs.existsSync("config.ts")) {
console.log(styleText("red", "config.tsがありません"));
process.exit();
}
}
+12
View File
@@ -0,0 +1,12 @@
import config from "../config.js";
import { styleText } from "util";
export default function LegalCheck() {
if (
config.legal.terms.length <= 50 ||
config.legal.terms.length <= 50
) {
console.log(styleText("red", "利用規約とプライバシーポリシーは50文字以上にしてください。"));
process.exit();
}
}
+18
View File
@@ -0,0 +1,18 @@
import PackagesIsExist from "./packagesExist.js";
import PackagesCheck from "./packages.js";
import ConfigCheck from "./config.js";
import APICheck from "./api.js";
import VersionCheck from "./version.js";
import LegalCheck from "./legal.js";
import config from "../config.js";
export default async function Check() {
PackagesIsExist();
PackagesCheck();
ConfigCheck();
if (config.debug === undefined) {
LegalCheck()
}
await APICheck();
await VersionCheck();
}
+59
View File
@@ -0,0 +1,59 @@
import * as fs from "fs";
import { styleText } from "util";
export default function PackagesCheck() {
try {
if (!fs.existsSync("package.json")) {
console.log(styleText("red", "package.jsonがありません。正規のリポジトリでgit pullを実行してください。"));
process.exit();
}
// package.json取得
const packages = JSON.parse(fs.readFileSync("package.json", "utf-8"));
const dependencies = packages.dependencies;
const packageNames: Array<string> = [];
Object.keys(dependencies).forEach((packageName) => {
let version: string;
if (dependencies[packageName].charAt(0) === "^") {
version = dependencies[packageName].replace('^', '');
} else {
version = dependencies[packageName]
}
dependencies[packageName] = version;
packageNames.push(packageName);
});
// パッケージのバージョン取得
const mismatchPackages: Array<string> = [];
packageNames.forEach((packageName) => {
const packagePath = `node_modules/${packageName}/package.json`;
if (fs.existsSync(packagePath)) {
const modulePackage = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
if (modulePackage.version !== dependencies[packageName]) {
mismatchPackages.push(packageName);
}
} else {
console.log(styleText("red", `パッケージ「${packageName}」が見つかりません。`));
process.exit();
}
});
if (mismatchPackages.length !== 0) {
console.log(styleText("red", "以下のパッケージのバージョンが異なります:"));
mismatchPackages.forEach((mismatch) => {
console.log(styleText("red", mismatch));
console.log(styleText("red", ` 要求バージョン: ${dependencies[mismatch]}`));
});
process.exit();
}
} catch (err) {
console.log("パッケージの存在確認でエラーが発生しました: ", err);
}
}
+16
View File
@@ -0,0 +1,16 @@
import * as fs from "fs";
import { styleText } from "util";
export default function PackagesIsExist() {
try {
if (!fs.existsSync("node_modules/.package-lock.json")) {
console.log(styleText("red", `
node_modules/.package-lock.jsonがありません。
プロジェクト直下でnpm installを実行してください。
`));
process.exit();
}
} catch (err) {
console.log("node_modules/.package-lock.jsonの存在確認でエラーが発生しました: ", err);
}
}
+44
View File
@@ -0,0 +1,44 @@
import config from "../config.js";
import { readFileSync, writeFileSync, existsSync } from "fs";
export default async function VersionCheck() {
const packageJson = JSON.parse(readFileSync("package.json", "utf-8"));
// 初期化
if (!existsSync("logs/version.txt")) {
writeFileSync(
"logs/version.txt",
packageJson.version,
"utf-8",
);
}
// 最終起動バージョン取得
const oldVersion = readFileSync("logs/version.txt", "utf-8");
if (oldVersion !== packageJson.version) {
try {
writeFileSync(
"logs/version.txt",
packageJson.version,
"utf-8",
);
const releaseUrl = `${packageJson.repository.url}/releases/tag/${packageJson.tag}`;
await fetch(`${config.uwuzu.host}/api/ueuse/create`, {
method: "POST",
body: JSON.stringify({
token: config.uwuzu.apiToken,
text: `
${packageJson.version}にBOTがアップデートされました!
リリース内容:${releaseUrl}
`,
}),
cache: "no-store",
});
} catch (err) {
console.log("アップデート通知にエラーが発生しました: ", err);
}
}
}
-43
View File
@@ -1,43 +0,0 @@
command:
# コマンド処理の間隔(分) number
# 自然数のみが有効です。
interval: 10
# コマンドの最大並列処理数 number
# 例: 2を指定すると、コマンドを並列に2つずつ処理します。
# 並列処理の数であるため、最終的なコマンドの処理数は変わりません。
# 自然数のみが有効です。
maxParallels: 3
earthquake:
# 地震発生情報を投稿することに必要な最大震度 number
# 例: 30を指定すると、最大震度が震度3以上の地震発生情報のみを投稿します。
# 10(震度1), 20(震度2), 30(震度3), 40(震度4), 45(震度5弱), 50(震度5強), 55(震度6弱), 60(震度6強), 70(震度7)が有効です。
requireMaxScale: 30
# 最大震度が不明な地震発生情報を投稿するかどうか boolean
canUnknownMaxScale: true
# 過去のデバッグ用データを使用して地震情報を配信するかどうか boolean
# デバッグ用途のみで使用してください。
useHistoryData: false
# 再接続の間隔(ミリ秒) number
# 正数のみが有効です。
reconnectInterval: 3000
uwuzu:
# APIトークン string
# とくに理由がない限り、全権限を与えてください。
token: API_TOKEN
# uwuzuサーバーのorigin string
origin: https://uwuzu.example.com
ueuse:
# 最大再試行数 number
# 自然数のみが有効です。
maxRetries: 3
# 再試行の間隔(ミリ秒) number
# 正数のみが有効です。
retryInterval: 1000
# 公開されたユーズの自主規制文字数 number
# 返信ではない(公開された)ユーズで使用する自主的な文字数制限です。
# 返信では、サーバーの設定に従います。
# 0にすると、返信ではないユーズでもサーバーの設定に従います。
# 0以上の整数が有効です。
maxLengthWithPublic: 512
# デバッグモードにするかどうか boolean
debug: false
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+66
View File
@@ -0,0 +1,66 @@
import type { configTypes } from "types/config";
// READMEの設定項目を参照
const config: configTypes = {
// 時報設定
time: {
// 時報休止期間
stopTimes: {
start: 23, // 開始
stop: 6, // 停止
},
},
// 地震速報設定
earthquake: {
reconnectTimes: 5000, // 再接続時間(ミリ秒)
websocketUrl: "wss://api.p2pquake.net/v2/ws", // WebSocketのURL
areasCsvUrl: "https://raw.githubusercontent.com/p2pquake/epsp-specifications/master/epsp-area.csv", // 対象地域CSVファイルのURL
maxScaleMin: 30, // 地震発生の際の最低震度(10-70)
},
// 天気お知らせ設定
weather: {
splitCount: 4, // 返信の分割数
},
// 緊急時設定
emergency: {
isEnabled: true, // 緊急時のコンソール表示
mail: {
isEnabled: true, // 緊急時のメール送信
host: "smtp.example.com", // SMTPサーバー
port: 465, // SMTPポート
user: "mailUser@example.com", // BOTメール送信元
password: "mailPassword", // SMTPパスワード
secure: false, // SMTPsecure設定
to: "admin@noticeuwuzu.example.com", // 緊急時メール送信先(配列可)
},
},
// /report設定
report: {
isEnabled: true, // 有効/無効
message: "", // 報告者へのメッセージ
},
// 規約等
legal: {
terms: `
`, // 利用規約
privacy: `
`, // プライバシーポリシー
},
// 管理者情報設定
admin: {
name: "あどみん", // BOT管理者名
showMail: false, // メールアドレスを公開するか(false:非公開/文字列:メールアドレス)
panel: { // 管理パネル
isEnabled: true, // 有効/無効
port: 74919, // 配信ポート
},
},
// uwuzuサーバー設定
uwuzu: {
apiToken: "TOKEN_EXAMPLE", // APIトークン
host: "https://uwuzu.example.com", // サーバーホスト
},
};
export default config;
-135
View File
@@ -1,135 +0,0 @@
timeNotice: "{{ time }}になりました。"
weatherProvisional: |
本日の天気
※タイムラインが埋まるため返信に記載しています。
weatherReply: |
【{{ city }}】
天気: {{ weather }}
最高気温: {{ maxTemp }}
最低気温: {{ minTemp }}
降水確率: {{ chanceOfRain }}
earthquakeNotice: |
### ==地震発生==
⏰時刻: {{ occuredTime }}頃
🫨最大震度: {{ maxScale }}
📍震源地: {{ epicenter }}
💪マグニチュード: {{ magnitude }}
🪨深さ: {{ depth }}
{{ domesticTsunami }}
{{ foreignTsunami }}{{ points }}
{{ comment }}
🔬情報源: P2P地震速報 - {{ source }}
tsunamiAreaMsg: |
【{{ name }}】{{ immediate }}
🏷️種別: {{ grade }}
⏳第1波到達予想時刻: {{ arrivalTime }}
🌊第1波の状態: {{ condition }}
🗼予想される津波の高さ: {{ maxHeight }}
tsunamiForecastNotice: |
### ==津波予報**発表**==
⏰発表時刻: {{ announceTime }}
{{ areasMsg }}
🔬情報源: P2P地震速報 - {{ source }}
tsunamiCancelNotice: |
### ==津波予報**解除**==
⏰発表時刻: {{ announceTime }}
🔬情報源: P2P地震速報 - {{ source }}
eewAreaMsg: |
【{{ name }}】
🫨最大予測震度: {{ maxScale }}
⏰到達予想時刻: {{ arrivalTime }}
{{ kind }}
eewNotice: |
### ==***緊急地震速報(警報)***==
{{ isTest }}{{isAssume}}
⏰発表時刻: {{ announceTime }}
⏰地震発生時刻: {{ occuredTime }}
⏰地震発現時刻: {{ arrivalTime }}
📍震源地: {{ epicenter }}
💪マグニチュード: {{ magnitude }}
🪨深さ: {{ depth }}{{ areas }}
🔬情報源: P2P地震速報
eewCancelNotice: |
### ==緊急地震速報(警報)**解除**==
{{ isTest }}
⏰発表時刻: {{ announceTime }}
🔬情報源: P2P地震速報
hnyNotice: |
あけましておめでとうございます。今年は、{{ year }}年です。
commandNotFound: |
コマンドが本文から参照できませんでした。
Botでは、このアカウントに対してのメンション部分を取り除きます。
その後、各行の先頭と末尾のスペースを削除します。
その上で、`/`から始まる最初の行を検索します。
検索結果をコマンドとして認識します。
上記の条件に当てはまる文法で再度お試しください。
unknownCommand: |
`/{{ command }}`は不明なコマンドです。
スペルミスや、既に削除されたコマンドである可能性があります。
`/help`を使用することで、コマンドのヘルプを確認できます。
invalidOption: |
{{ option }}は無効なオプションです。
`/help {{ command }}`を確認してください。
lackOption: |
オプションが不足しています。
`/help {{ command }}`を確認してください。
helpHelp: |
コマンドの概要を返信します。
オプション1にコマンド名(`/`を除く)を入力することで、詳細を返信します。
helpFollow: コマンド送信者をフォローします。
helpUnfollow: コマンド送信者をフォロー解除します。
helpWeather: 天気を返信します。
helpMiq: |
Make it a Quoteを操作します。
`/help miq`で詳細を確認することを推奨します。
fullHelpHelp: |
コマンドの概要を返信します。
オプション1にコマンド名(`/`を除く)を入力することで、詳細を返信します。
fullHelpFollow: |
コマンドを送信したユーザーをフォローします。
既にフォローされているユーザーが使用した場合は、応答されません。
fullHelpUnfollow: |
コマンドを送信したユーザーをフォロー解除します。
既にフォローされていないユーザーが使用した場合は、応答されません。
fullHelpWeather: |
天気を返信します。
毎日7:00の天気を再投稿するわけではなく、
再取得して返信します。
fullHelpMiq: |
Make it a Quoteを操作します。
オプション1が必須です。
`/miq generate`で、コマンドを送信したユーズの返信元のユーズ内容を使用して、Make it a Quoteを生成できます。
オプション2にcolorを指定することで、(`/miq generate color`)カラーモードで生成できます。
`/miq permission`で、コマンド送信者に対してのMake it a Quoteを生成する要求者の制限を変更、確認できます。
確認する場合は、オプション2は不要です。
変更するには以下のいずれかをオプション2として指定してください。(例: `/miq permission me`)
- `me`: 自分自身のみになります。あなたのみが生成でき、あなた以外が生成を要求すると拒否されます。
- `everyone`: 全体公開になります。全てのユーザーが許可なしにあなたのユーズでMake it a Quoteを生成できます。
- `consent`: 許可制になります。生成を要求されるとあなたにメンションが届き許可するかを選択できます。
`/miq allow`で、`/miq permission`で設定されている制限が`consent`の場合にMake it a Quoteの生成を許可できます。
形式が異なる場合には生成されません。
followedNotification: "{{ username }}さんをフォローしました。"
unfollowedNotification: "{{ username }}さんをフォロー解除しました。"
replySourceFailed: 返信元のユーズの取得に失敗しました。
miqPermissionMe: |
生成を要求したユーズの投稿者が生成要求者を自分自身のみに設定しています。
しかし、あなたは投稿者自身ではないため、Make it a Quoteを使用することはできません。
miqPermissionConsent: |
生成を要求したユーズの投稿者が生成要求者を許可制に設定しています。
そのため、Make it a Quoteを使用するには、@{{ userid }}さんがこのユーズに返信で`/miq allow`を使用する必要があります。
miqGenerateFailed: |
Make it a Quoteの生成に失敗しました。
また後でお試しください。
miqSuccess: "{{ message }}"
permissionResponse: あなたのMake it a Quoteの生成要求者の制限は、{{ permission }}です。
permissionChangeSuccess: "{{ username }}さんのMake it a Quoteの生成要求者の制限を{{ permission }}に変更しました。"
injusticeFormat: 不正な形式です。
permisionIsNotConsent: |
あなたに対してのMake it a Quoteの生成要求者が許可制に設定されていません。
そのため、`/miq allow`はご利用いただけません。
replySourceIsNotThis: 返信元のユーザーがこのBotではありません。
replySourceIsNotSourceUser: ソースのユーズと`/miq allow`を使用したユーザーが一致しません。
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+62
View File
@@ -0,0 +1,62 @@
// 起動チェック
import Check from "./checks/main.js";
(async () => {
await Check();
})();
// 定期実行読み込み
import * as cron from "node-cron";
// 機能読み込み
import timeNotice from "./scripts/timeNotice.js";
import { weatherNotice } from "./scripts/weatherNotice.js";
import earthquakeNotice from "./scripts/earthquakeNotice.js";
import EventDays from "./scripts/eventday.js";
import Commands from "./scripts/commands/main.js";
// その他機能
import asciiArt from "./scripts/asciiart.js";
asciiArt();
import successExit from "./scripts/successExit.js";
successExit();
// 地震情報観測開始
earthquakeNotice();
// 時報(1時間/1回)
cron.schedule("0 * * * *", () => {
timeNotice();
});
// コマンド(10分/1回)
cron.schedule("*/10 * * * *", () => {
Commands();
});
// 祝日などお知らせ(毎日0:00)
cron.schedule("0 0 * * *", () => {
EventDays();
});
// 天気お知らせ(毎日7:00)
import { setTimeout } from "timers";
cron.schedule("0 7 * * *", () => {
setTimeout(() => {
weatherNotice();
}, 100);
});
// 管理パネル
import AdminPanel from "./panel/main.js";
import { styleText } from "util";
(async () => {
await AdminPanel();
})();
// 起動表示
console.log("BOTサーバーが起動しました");
import config from "./config.js";
if (config.debug !== undefined) {
console.log(styleText(["bgRed", "cyan", "bold"], "デバッグモードで起動中"));
}
+46 -20
View File
@@ -1,33 +1,59 @@
{ {
"name": "notice-uwuzu", "name": "notice-uwuzu",
"version": "2026.4.0", "version": "v8.1.2@uwuzu1.6.1",
"type": "module", "tag": "v8.1.2",
"main": "dist/index.js", "description": "Notice Bot for uwuzu",
"main": "dist/main.js",
"scripts": { "scripts": {
"start": "node .", "start": "node .",
"build": "tsc && tsc-alias" "build": "tsc",
"main": "tsc && node .",
"dev": "tsx main.ts",
"clean": "tsc && node dist/scripts/clean/main.js"
}, },
"repository": {
"type": "git",
"url": "https://gitea.last2014.com/last2014/noticeUwuzu"
},
"keywords": [
"uwuzu",
"bot",
"cron",
"notice",
"mail",
"weather",
"time",
"earthquake",
"command",
"commands"
],
"author": { "author": {
"name": "Last2014", "name": "Last2014",
"email": "info@last2014.com", "url": "https://last2014.com",
"url": "https://about.last2014.com" "email": "info@last2014.com"
}, },
"packageManager": "pnpm@10.33.0", "contributors": [],
"license": "Apache-2.0",
"type": "module",
"dependencies": { "dependencies": {
"@types/node": "^25.5.2", "@types/date-fns": "^2.5.3",
"@types/dotenv": "^6.1.1",
"@types/express": "^5.0.3",
"@types/node": "^24.0.7",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"better-uwuzu-sdk": "git+https://gitea.last2014.com/last2014/better-uwuzu-sdk.git#1.1.7", "child_process": "^1.0.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"fs": "0.0.1-security", "express": "^5.1.0",
"i18next": "^26.0.3", "fs": "^0.0.1-security",
"miq": "git+https://gitea.last2014.com/last2014/miq.git#1.0.1", "node-cron": "^4.1.1",
"node-cron": "^4.2.1", "nodemailer": "^7.0.4",
"os": "^0.1.2", "timers": "^0.1.1",
"tsc-alias": "^1.8.16", "typescript": "^5.9.2",
"typescript": "^5.9.3", "ws": "^8.18.3"
"util": "^0.12.5", },
"ws": "^8.20.0", "devDependencies": {
"yaml": "^2.8.3", "tsx": "^4.20.3"
"zod": "^4.3.6"
} }
} }
+54
View File
@@ -0,0 +1,54 @@
import express from "express";
import * as os from "os";
import config from "../config.js";
import { NetworkInterfaceDetails } from "types/types";
// バックエンドルーティング
import CommandExecute from "./route/command.js";
import ueusePost from "./route/ueuse.js";
import WeatherUeuse from "./route/weather.js";
import API from "./route/api.js";
import Token from "./route/token.js";
import Debug from "./route/debug.js";
export default async function AdminPanel() {
// 無効
if (!config.admin.panel.isEnabled) {
return;
}
// 管理パネル
const app = express();
const port = config.admin.panel.port;
// ルーティング
app.use(ueusePost);
app.use(CommandExecute);
app.use(WeatherUeuse);
app.use(API);
app.use(Token);
app.use(Debug);
app.use(express.static("panel/public"));
app.listen(port, () => {
console.log(`http://${LocalIP()}:${port} で管理パネルを起動しました`);
});
}
function LocalIP() {
const interfaces = os.networkInterfaces();
for (const name in interfaces) {
const iface: any = interfaces[name];
for (const i of iface) {
const details: NetworkInterfaceDetails = i;
if (details.family === 'IPv4' && details.internal !== true) {
return details.address;
}
}
}
return "localhost";
}
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理パネル</title>
<!-- パッケージ -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://code.iconify.design/iconify-icon/3.0.0/iconify-icon.min.js"></script>
<script>
import "iconify-icon";
</script>
</head>
<body class="dark:bg-gray-950 dark:text-white text-center">
<h1 class="text-4xl font-bold">noticeUwuzu管理パネル</h1>
<button id="commandExec" class="border rounded-[10px] p-[5px] m-[10px] mt-[20px] cursor-pointer">
コマンド実行
</button>
<button id="weatherUeuse" class="border rounded-[10px] p-[5px] m-[10px] mt-[20px] cursor-pointer">
天気お知らせ
</button>
<button id="eventdayUeuse" class="border rounded-[10px] p-[5px] m-[10px] mt-[20px] cursor-pointer">
祝日等お知らせ
</button>
<button id="ueuse" class="border rounded-[10px] p-[5px] m-[10px] mt-[20px] cursor-pointer">
ユーズ投稿
</button>
<button id="api" class="border rounded-[10px] p-[5px] m-[10px] mt-[20px] cursor-pointer">
API使用
</button>
<script src="/script.js"></script>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
document.getElementById("commandExec").addEventListener("click", async () => {
const req = await fetch("/actions/command-execute", {
method: "POST",
});
const res = await req.text();
if (res === "Accepted") {
alert("コマンド実行を受け付けました");
} else {
alert(`コマンド実行の要求にエラーが発生しました:${res}`);
}
});
document.getElementById("weatherUeuse").addEventListener("click", async () => {
const req = await fetch("/actions/weather", {
method: "POST",
});
const res = await req.text();
if (res === "Accepted") {
alert("天気お知らせを受け付けました");
} else {
alert(`天気お知らせの要求にエラーが発生しました:${res}`);
}
});
document.getElementById("eventdayUeuse").addEventListener("click", async () => {
const req = await fetch("/actions/eventday", {
method: "POST",
});
const res = await req.text();
if (res === "Accepted") {
alert("祝日等お知らせを受け付けました");
} else {
alert(`祝日等お知らせの要求にエラーが発生しました:${res}`);
}
});
document.getElementById("ueuse").addEventListener("click", async () => {
const text = prompt("ユーズ内容").toLowerCase();
if (text === "") {
alert("ユーズ内容がありません。");
return;
}
const nsfw = confirm("NSFWにしますか?");
const req = await fetch("/actions/ueuse", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
text, text,
nsfw: nsfw,
}),
});
const res = await req.text();
if (res === "Success") {
alert("ユーズ投稿を受け付けました");
} else {
alert(`ユーズ投稿の要求にエラーが発生しました:${res}`);
}
});
document.getElementById("api").addEventListener("click", async () => {
const token = await (await fetch("/actions/token", {
method: "GET",
})).text();
const endpoint = prompt("エンドポイント", "/serverinfo-api").toLowerCase();
if (endpoint === "") {
alert("エンドポイントが設定されていません。");
return;
}
const body = prompt("body(JSON)", `{"token": "${token}"}`).toLowerCase();
if (body === "") {
alert("bodyが設定されていません。");
return;
}
const req = await fetch("/actions/api", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
endpoint: endpoint,
body: JSON.parse(body),
}),
});
const res = await req.text();
alert(res);
});
+34
View File
@@ -0,0 +1,34 @@
import express from "express";
const API = express.Router();
import config from "../../config.js";
API.use(express.json());
API.use(express.urlencoded({ extended: true }));
API.post("/actions/api", async (req, res, next) => {
const endpoint = req.body.endpoint;
const body = req.body.body;
try {
const apiReq = await fetch(`${config.uwuzu.host}/api${endpoint}`, {
method: "POST",
body: JSON.stringify(body),
});
const apiRes = await apiReq.json();
res.status(200)
.send(apiRes);
} catch(err) {
res.status(500)
.send(`Error: ${err}`);
}
});
API.get("/actions/api", (req, res) => {
res.status(501)
.send("POST Only");
});
export default API;
+25
View File
@@ -0,0 +1,25 @@
import express from "express";
const CommandExecute = express.Router();
import Commands from "../../scripts/commands/main.js";
CommandExecute.post("/actions/command-execute", (req, res) => {
try {
(async () => {
await Commands();
})();
res.status(202)
.send("Accepted");
} catch(err) {
res.status(500)
.send(`Error: ${err}`);
}
});
CommandExecute.get("/actions/command-execute", (req, res) => {
res.status(501)
.send("POST Only");
});
export default CommandExecute;
+24
View File
@@ -0,0 +1,24 @@
import express from "express";
const Debug = express.Router();
import config from "../../config.js";
Debug.post("/actions/debug", (req, res, next) => {
res.status(501)
.send("GET Only");
});
Debug.get("/actions/debug", (req, res) => {
let debug;
if (config.debug === undefined) {
debug = false;
} else {
debug = true;
}
res.status(200)
.send(debug);
});
export default Debug;
+25
View File
@@ -0,0 +1,25 @@
import express from "express";
const EventdayUeuse = express.Router();
import EventDays from "../../scripts/eventday.js";
EventdayUeuse.post("/actions/eventday", (req, res) => {
try {
(async () => {
await EventDays();
})();
res.status(202)
.send("Accepted");
} catch(err) {
res.status(500)
.send(`Error: ${err}`);
}
});
EventdayUeuse.get("/actions/eventday", (req, res) => {
res.status(501)
.send("POST Only");
});
export default EventdayUeuse;
+16
View File
@@ -0,0 +1,16 @@
import express from "express";
const Token = express.Router();
import config from "../../config.js";
Token.post("/actions/token", (req, res, next) => {
res.status(501)
.send("GET Only");
});
Token.get("/actions/token", (req, res) => {
res.status(200)
.send(config.uwuzu.apiToken);
});
export default Token;
+44
View File
@@ -0,0 +1,44 @@
import express from "express";
const ueusePost = express.Router();
import config from "../../config.js";
ueusePost.use(express.json());
ueusePost.use(express.urlencoded({ extended: true }));
ueusePost.post("/actions/ueuse", async (req, res, next) => {
const text = req.body.text;
const nsfw = req.body.nsfw;
try {
const ueuseReq = await fetch(`${config.uwuzu.host}/api/ueuse/create`, {
method: "POST",
body: JSON.stringify({
token: config.uwuzu.apiToken,
text: `
${text}
このユーズはnoticeUwuzuの管理パネルから投稿されました
`,
nsfw: nsfw,
}),
});
const ueuseRes = await ueuseReq.json();
console.log(`ユーズ(管理パネル)${JSON.stringify(ueuseRes)}`);
res.status(200)
.send("Success");
} catch(err) {
res.status(500)
.send(`Error: ${err}`);
}
});
ueusePost.get("/actions/ueuse", (req, res) => {
res.status(501)
.send("POST Only");
});
export default ueusePost;
+25
View File
@@ -0,0 +1,25 @@
import express from "express";
const WeatherUeuse = express.Router();
import { weatherNotice } from "../../scripts/weatherNotice.js";
WeatherUeuse.post("/actions/weather", (req, res) => {
try {
(async () => {
await weatherNotice();
})();
res.status(202)
.send("Accepted");
} catch(err) {
res.status(500)
.send(`Error: ${err}`);
}
});
WeatherUeuse.get("/actions/weather", (req, res) => {
res.status(501)
.send("POST Only");
});
export default WeatherUeuse;
-1293
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
onlyBuiltDependencies:
- "better-uwuzu-sdk"
- canvas
- esbuild
- "miq"
- sharp
+8
View File
@@ -0,0 +1,8 @@
import * as fs from "fs";
const version = JSON.parse(fs.readFileSync("package.json", "utf-8")).version;
export default function asciiArt() {
console.log(fs.readFileSync("asciiart.txt", "utf-8").replace(/(\r?\n)$/, ''));
console.log(`${version}\n`);
}
+16
View File
@@ -0,0 +1,16 @@
import { unlinkSync } from "fs";
export default function logsDelete() {
console.log("ログを削除中します");
try {
unlinkSync("logs/boot.json");
unlinkSync("logs/version.txt");
console.log("ログファイルを削除しました");
console.log("----------------");
} catch (err) {
console.log("ログファイルの削除中にエラーが発生しました:\n", err);
process.exit();
}
}
+7
View File
@@ -0,0 +1,7 @@
import logsDelete from "./logsDel.js";
import packageLockJsonDelete from "./packageLockDel.js";
import npmInstall from "./npmInstall.js";
logsDelete();
packageLockJsonDelete();
npmInstall();
+23
View File
@@ -0,0 +1,23 @@
import { execSync } from "child_process";
export default function npmInstall() {
// npm install実行
console.log("npm installを実行します");
try {
const npmInstall = execSync("npm install");
console.log("----");
console.log("$ npm install");
console.log(npmInstall.toString());
console.log("----");
console.log("npm installを実行しました");
} catch (err) {
console.log("npm installの実行中にエラーが発生しました:\n", err);
process.exit();
}
console.log("初期化が完了しました");
process.exit();
}
+15
View File
@@ -0,0 +1,15 @@
import { unlinkSync } from "fs";
export default function packageLockJsonDelete() {
console.log("package-lock.jsonを削除します");
try {
unlinkSync("package-lock.json");
console.log("package-lock.jsonを削除しました");
console.log("----------------");
} catch (err) {
console.log("package-lock.jsonの削除中にエラーが発生しました:\n", err);
process.exit();
}
}
+25
View File
@@ -0,0 +1,25 @@
import { ueuse } from "types/types";
import config from "../../config.js";
import { Reply } from "./main.js";
export default async function Follow(data: ueuse) {
const followReq = await fetch(`${config.uwuzu.host}/api/users/follow`, {
method: "POST",
body: JSON.stringify({
token: config.uwuzu.apiToken,
userid: data.account.userid,
}),
cache: "no-store",
});
const followRes = await followReq.json();
console.log("フォロー: ", followRes);
const notice = await Reply(`
${data.account.username}さんをフォローしました
`, data.uniqid);
console.log("フォロー通知: ", notice);
}
+79
View File
@@ -0,0 +1,79 @@
import { ueuse } from "types/types";
import { readFileSync } from "fs";
import { Reply } from "./main.js";
const helpsMin = {
"info": "このBOTについての概要を返信するコマンドです。",
"help": "コマンドの概要を返信します。追記に\`/\`抜きのコマンド名を入力することでそのコマンドの詳細(フル)を返信します。",
"follow": "コマンド送信者をフォローします。",
"unfollow": "コマンド送信者をフォロー解除します。",
"weather": "天気を返信します。",
"report": "運営者に不具合などを報告します。",
"legal terms": "利用規約を返信します。",
"legal privacy": "プライバシーポリシーを返信します。",
} as { [key: string]: string };
const helpsFull = {
"info": `
このBOTについての概要を返信するコマンドです。
バージョン、開発者などが確認できます。
`,
"help": `
このコマンドです。コマンドの概要を返信します。
追記に\`/\`抜きのコマンド名を入力することでそのコマンドの詳細(フル)を返信します。
`,
"follow": `
コマンドを送信したユーザーをフォローします。
既にフォローされているユーザーも使用できます。
`,
"unfollow": `
コマンドを送信したユーザーをフォロー解除します。
既にフォローされていないユーザーも使用できます。
`,
"weather": `
天気を返信します。
毎日7:00の天気を再投稿するわけではなく、
再取得して返信します。
`,
"report": `
不具合などを運営者にメールで報告できます。
運営者によって有効化されていないと使用できません。
\`/report\`を使用してそのユーズの追記に内容を入力することで使用できます。
`,
"legal terms": `
利用規約を返信します。
`,
"legal privacy": `
プライバシーポリシーを返信します。
`,
} as { [key: string]: string };
export default async function Help(data: ueuse) {
const packageJson = JSON.parse(readFileSync("package.json", "utf-8"));
if (
data.abi === "none" ||
data.abi === ""
) {
const helpMsg =
Object.entries(helpsMin)
.map(([command, message]) =>
`\`/${command}\`${message}`
).join('\n');
const ueuse = await Reply(`
${helpMsg}
BOTの概要は\`/info\`をご利用ください。
Wikiを見る:${packageJson.repository.url}/wiki
`, data.uniqid);
console.log("ヘルプ:", ueuse);
} else {
const ueuse = await Reply(`
${helpsFull[data.abi]}
機能を見る:${packageJson.repository.url}/wiki
`, data.uniqid);
console.log("ヘルプ:", ueuse);
}
}
+65
View File
@@ -0,0 +1,65 @@
import { ueuse } from "types/types";
import { readFileSync } from "fs";
import { Reply } from "./main.js";
import config from "../../config.js";
export default async function Info(data: ueuse) {
const packageJson = JSON.parse(readFileSync("package.json", "utf-8"));
const releaseUrl = `${packageJson.repository.url}/releases/tag/${packageJson.tag}`;
let editor = "";
if (packageJson.author.name !== "Last2014") {
editor = `\nEdited by ${packageJson.author.name}`;
}
let adminMail;
if (config.admin.showMail === false) {
adminMail = "非公開";
} else {
adminMail = config.admin.showMail;
}
let isReport;
if (config.report.isEnabled) {
isReport = "有効";
} else {
isReport = "無効";
}
const ueuse = await Reply(`
【BOTについて】
このBOTはオープンソースソフトウェアであるnoticeUwuzuを利用して運営されています。
noticeUwuzuはApache License 2.0によって保護されています。
ライセンスに違反して使用した場合は著作権法違反となります。
バージョン:${packageJson.version}
リリース詳細:${releaseUrl}
【運営者情報】
運営者名:${config.admin.name}
メールアドレス:${adminMail}
報告機能(\`/report\`)${isReport}
【関連コマンド】
コマンドのヘルプをお探しですか?
\`/help\`をご利用ください。
運営者へ報告が必要ですか?
\`/report\`をご利用ください。
利用規約をお探しですか?
\`/legal terms\`をご利用ください。
プライバシーポリシーをお探しですか?
\`/legal privacy\`をご利用ください。
【クレジット】
Created by Last2014${editor}
`, data.uniqid);
console.log("概要:", ueuse);
}
+7
View File
@@ -0,0 +1,7 @@
import { ueuse } from "types/types";
import { Reply } from "../main.js";
import config from "../../../config.js";
export default function PrivacyPolicy(data: ueuse) {
Reply(config.legal.privacy, data.uniqid);
}
+7
View File
@@ -0,0 +1,7 @@
import { ueuse } from "types/types";
import { Reply } from "../main.js";
import config from "../../../config.js";
export default function Terms(data: ueuse) {
Reply(config.legal.terms, data.uniqid);
}
+147
View File
@@ -0,0 +1,147 @@
import * as fs from "fs";
import config from "../../config.js";
import type { ueuse } from "types/types";
const initialFile: Array<string> = [];
// コマンド読み込み
import Info from "./info.js";
import Follow from "./follow.js";
import UnFollow from "./unfollow.js";
import Weather from "./weather.js";
import Help from "./help.js";
import Report from "./report.js";
import Terms from "./legal/terms.js"
import PrivacyPolicy from "./legal/privacy.js";
// 初期化
if (!fs.existsSync("data/alreadyCommands.json")) {
fs.writeFileSync(
"data/alreadyCommands.json",
JSON.stringify(initialFile),
"utf-8",
);
}
// 対応済みユーズ一覧
const alreadyCommands: Array<string> = JSON.parse(fs.readFileSync("data/alreadyCommands.json", "utf-8"));
function cutAfterChar(str: string, char: string) {
const index = str.indexOf(char);
if (index === -1) {
return "";
}
return str.substring(index + 1);
}
export async function Reply(text: string, reply: string) {
const req = await fetch(`${config.uwuzu.host}/api/ueuse/create`, {
method: "POST",
body: JSON.stringify({
token: config.uwuzu.apiToken,
text: text,
replyid: reply,
}),
cache: "no-store",
});
const res = await req.json();
return res;
}
function alreadyAdd(data: string) {
alreadyCommands[alreadyCommands.length] = data;
fs.writeFileSync(
"data/alreadyCommands.json",
JSON.stringify(alreadyCommands),
"utf-8",
);
}
export default async function Commands() {
const mentionsReq = await fetch(
`${config.uwuzu.host}/api/ueuse/mentions`, {
method: "POST",
body: JSON.stringify({
token: config.uwuzu.apiToken,
}),
cache: "no-store",
}
);
const mentions: { [key: string]: ueuse } = await mentionsReq.json();
console.log("----------------");
console.log("コマンド処理");
for (const key in mentions) {
if (mentions.hasOwnProperty(key)) {
const data = mentions[key];
// 除外ユーズ
if (data.text === undefined) {
break;
}
if (alreadyCommands.indexOf(data.uniqid) !== -1) {
break;
}
if (
data.text.charAt(0) === "!" ||
data.text.charAt(0) === "" ||
data.abi === "ignore"
) {
break;
}
// コマンド処理
console.log("--------");
const commandName = cutAfterChar(data.text, "/");
alreadyAdd(data.uniqid);
switch (commandName) {
case "":
break;
case "info":
Info(data);
break;
case "help":
Help(data);
break;
case "legal terms":
Terms(data);
break;
case "legal privacy":
PrivacyPolicy(data);
break;
case "report":
Report(data);
break;
case "follow":
Follow(data);
break;
case "unfollow":
UnFollow(data);
break;
case "weather":
Weather(data);
break;
default:
const reply = await Reply(`
不明なコマンドです。
コマンド実行を除外する場合は1文字目に\`!\`を入れてください。
`, data.uniqid);
console.log("未対応コマンド: ", reply);
break;
}
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import { ueuse } from "types/types";
import { Reply } from "./main.js";
import config from "../../config.js";
import sendMail from "../../src/mailer.js";
export default async function Report(data: ueuse) {
if (
data.abi === "none" ||
data.abi === ""
) {
console.log("報告(内容なし)", await Reply(`
追記に内容を記入してください。
(このユーズはもう利用できません。他のユーズで\`/report\`をまたご利用ください。)
`, data.uniqid));
return;
}
if (!config.emergency.isEnabled) {
console.log("報告(重要通知オフ)", await Reply(`
BOTの運営者によって重要通知が無効化されています。
そのため報告機能はご利用いただけません。
`, data.uniqid));
return;
}
if (!config.emergency.mail.isEnabled) {
console.log("報告(メールオフ)", await Reply(`
BOTの運営者によってメール送信機能が無効化されています。
そのため報告機能はご利用いただけません。
`, data.uniqid));
return;
}
if (!config.report.isEnabled) {
console.log("報告(機能オフ)", await Reply(`
BOTの運営者によって報告機能が無効化されています。
そのため報告機能はご利用いただけません。
`, data.uniqid));
return;
}
try {
sendMail({
to: config.emergency.mail.to,
subject: "【報告】BOT利用者からの報告",
text: `
※noticeUwuzu自動送信によるメールです
【報告】
BOT管理者さん、noticeUwuzu自動送信メールです。
@${data.account.userid}@${config.uwuzu.host}から/reportコマンドを利用した報告がありました。
報告元ユーズ:${config.uwuzu.host}/!${data.uniqid}
下記が内容となります。
${data.abi}
`,
});
console.log("報告(完了)", await Reply(`
報告が完了しました。
運営者は報告者、ユーズのURL、内容を確認できます。
--運営者からのメッセージ--
${config.report.message}
`, data.uniqid));
return;
} catch (err) {
console.log("/reportエラー:", err);
console.log("報告(エラー)", await Reply(`
報告に失敗しました。
`, data.uniqid));
return;
}
}
+25
View File
@@ -0,0 +1,25 @@
import { ueuse } from "types/types";
import config from "../../config.js";
import { Reply } from "./main.js";
export default async function UnFollow(data: ueuse) {
const unfollowReq = await fetch(`${config.uwuzu.host}/api/users/unfollow`, {
method: "POST",
body: JSON.stringify({
token: config.uwuzu.apiToken,
userid: data.account.userid,
}),
cache: "no-store",
});
const unfollowRes = await unfollowReq.json();
console.log("フォロー解除: ", unfollowRes);
const notice = await Reply(`
${data.account.username}さんをフォロー解除しました
`, data.uniqid);
console.log("フォロー解除通知: ", notice);
}
+6
View File
@@ -0,0 +1,6 @@
import { weatherReply } from "../weatherNotice.js";
import { ueuse } from "types/types";
export default function Weather(data: ueuse) {
weatherReply(data.uniqid);
}
+506
View File
@@ -0,0 +1,506 @@
import WebSocket from "ws";
import sendMail from "../src/mailer.js";
import config from "../config.js";
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 (err) {
console.error(`メッセージのパースでエラーが発生: ${err}`);
}
});
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("----------------");
const supportCode: Array<number> = [
551,
552,
556,
];
if (supportCode.indexOf(message.code) !== -1) {
event(message);
} else {
console.log(`未対応の情報を受信しました(コード: ${message.code})`);
console.log("受信メッセージ:", message);
}
}
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();
};
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("受信メッセージ:", earthquakeInfo);
// ----処理----
// 緊急地震速報の場合
if (earthquakeInfo.code === 556) {
console.log("緊急地震速報を受信しました");
// 地震詳細
let descriptionEarthquake: string = "";
if (
earthquakeInfo.earthquake.description !== "" ||
earthquakeInfo.earthquake.description !== undefined
) {
descriptionEarthquake = `この地震について:${earthquakeInfo.earthquake.description}`;
}
// 発令詳細
let description: string = "";
if (
earthquakeInfo.comments.freeFormComment !== "" ||
earthquakeInfo.comments.freeFormComment !== undefined
) {
description = `この発令について:${earthquakeInfo.comments.freeFormComment}`;
}
// テスト・訓練
let test: string = "";
if (earthquakeInfo.test !== undefined) {
test = "この情報にテスト・訓練かの情報はありません";
} else if (earthquakeInfo.test) {
test = "これはテスト、あるいは訓練です";
} else if (earthquakeInfo.test === false) {
test = "これはテスト・訓練ではありません";
}
// 対象地域
let areas: string = "";
if (earthquakeInfo.areas !== undefined) {
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 === undefined
) {
magnitude += "マグニチュードの情報はありません";
} else {
magnitude += `M${String(earthquakeInfo.earthquake.hypocenter.magnitude)}`;
}
ueuse(`
==地震情報==
【緊急地震速報】
${cancelled}
時刻:${earthquakeInfo.time}
${descriptionEarthquake}
${description}
${test}
${areas}
`);
}
// 地震情報
else if (earthquakeInfo.code === 551) {
console.log("地震発生情報を受信しました");
if (
earthquakeInfo.earthquake.maxScale !== undefined &&
earthquakeInfo.earthquake.maxScale < config.earthquake.maxScaleMin
) {
console.log("最低震度に満たしていないため投稿されませんでした");
return;
}
// 国内津波
let domesticTsunami;
const TsunamiMessages = {
"None": "この地震による国内の津波の心配はありません",
"Unknown": "この地震による国内の津波情報はありません",
"Checking": "この地震による国内の津波情報を調査中です",
"NonEffective": "この地震による国内の津波影響は若干の海面変動が予想されますが被害の心配はありません",
"Watch": "この地震により国内で津波注意報が発令しています",
"Warning": "この地震による国内の津波予報があります",
} as { [key: string]: string };
if (earthquakeInfo.earthquake.domesticTsunami === undefined) {
domesticTsunami = "この地震による国内の津波情報はありません";
} else {
domesticTsunami = TsunamiMessages[earthquakeInfo.earthquake.domesticTsunami];
}
// 最大震度
let maxScale: string = "最大深度:";
const maxScales = {
10: "震度1",
20: "震度2",
30: "震度3",
40: "震度4",
45: "震度5弱",
50: "震度5強",
55: "震度6弱",
60: "震度6強",
70: "震度7",
} as { [key: number]: string };
if (
earthquakeInfo.earthquake.maxScale == -1 ||
earthquakeInfo.earthquake.maxScale === undefined
) {
maxScale = "最大震度:不明";
} else {
maxScale = `最大震度:${maxScales[earthquakeInfo.earthquake.maxScale]}`;
}
// 警告
if (
earthquakeInfo.earthquake.maxScale !== undefined &&
earthquakeInfo.earthquake.maxScale >= 60 &&
config.emergency.isEnabled
) {
console.log("----------------");
console.log("震度6強以上の地震を受信しました");
console.log("サーバーがダウンする可能性があります");
// メール送信
if (config.emergency.isEnabled) {
sendMail({
to: config.emergency.mail.to,
subject: "【警告】震度6強以上の地震を受信しました",
text: `
※noticeUwuzu自動送信によるメールです
【警告】
BOT管理者さん、noticeUwuzu自動送信メールです。
震度6強以上の地震を受信したため警告メールが送信されました。
物理、システム的にサーバーがダウンする可能性があります。
ご自身の身をお守りください。
`
});
console.log("管理者へ警告メールを送信しました");
}
console.log("----------------");
}
// 対象地域
let areas;
if (earthquakeInfo.points !== undefined) {
const areaNames: Array<string> = Array.from(
new Set(
earthquakeInfo.points.map((point: any) => point.addr).filter(Boolean),
),
);
areas = `対象地域:${areaNames.join("・")}`;
} else {
areas = "対象地域:不明";
}
// 詳細
let description;
if (
earthquakeInfo.comments.freeFormComment !== "" &&
earthquakeInfo.comments.freeFormComment !== undefined
) {
description = `この地震について:${earthquakeInfo.comments.freeFormComment}`;
} else {
description = "";
}
// 深さ
let depth;
if (
earthquakeInfo.earthquake.hypocenter.depth !== null ||
earthquakeInfo.earthquake.hypocenter.depth !== undefined
) {
if (earthquakeInfo.earthquake.hypocenter.depth === 0) {
depth = "深さ:ごく浅い";
} else if (earthquakeInfo.earthquake.hypocenter.depth === -1) {
depth = "深さ:不明";
} else {
depth = `深さ:${String(earthquakeInfo.earthquake.hypocenter.depth)}km`;
}
} else {
depth = "深さ:不明";
}
// マグニチュード
let magnitude;
if(
earthquakeInfo.earthquake.hypocenter.magnitude !== null ||
earthquakeInfo.earthquake.hypocenter.magnitude !== undefined
) {
if (earthquakeInfo.earthquake.hypocenter.magnitude === -1) {
magnitude = "マグニチュード:不明";
} else {
magnitude = `マグニチュード:M${String(earthquakeInfo.earthquake.hypocenter.magnitude)}`;
}
} else {
magnitude = "マグニチュード:不明";
}
ueuse(`
==地震情報==
【地震発生】
時刻:${earthquakeInfo.time}
${description}
${magnitude}
${depth}
${maxScale}
${areas}
国内の津波:${domesticTsunami}
`);
} else if (earthquakeInfo.code === 552) {
console.log("津波予報情報を受信しました");
// 予報取り消し
if (earthquakeInfo.cancelled) {
ueuse(`
==地震情報==
【津波予報】
※津波予報が取り消されました※
時刻:${earthquakeInfo.time}
`);
return;
}
let result: string = `
==地震情報==
【津波予報】
時刻:${earthquakeInfo.time}
\n
`;
for (let i = 0; i < earthquakeInfo.areas.length; i++) {
const data = earthquakeInfo.areas[i];
// 種類
const gradeMessages = {
"MajorWarning": "大津波警報",
"Warning": "津波警報",
"Watch": "津波注意報",
"Unknown": "不明",
} as { [key: string]: string };
let grade;
if (data.grade === undefined) {
grade = "予報種類:不明";
} else {
grade = `予報種類:${gradeMessages[data.grade]}`;
}
// 直後襲来
let immediate;
if (data.immediate === undefined) {
immediate = "津波の襲来が直後かの情報がありません";
} else if (data.immediate) {
immediate = "### 津波が直ちに襲来します";
} else if (!data.immediate) {
immediate = "津波は直ちには襲来しません";
} else {
immediate = "津波の襲来が直後かの情報がありません";
}
// 第1波
let firstHeight;
if (data.firstHeight === undefined) {
firstHeight = "第1波の情報がありません";
} else if (
data.firstHeight.arrivalTime === undefined &&
data.firstHeight.condition === undefined
) {
firstHeight = "第1波の情報がありません";
} else {
let arrivalTime;
if (data.arrivalTime === undefined) {
arrivalTime = "不明";
} else {
arrivalTime = data.arrivalTime;
}
let condition;
if (data.condition === undefined) {
condition = "不明";
} else {
condition = data.condition;
}
firstHeight = `
第1波到達予想時刻:${arrivalTime}
第1波の状態:${condition}
`
}
// 予想高さ
let maxHeight;
if (data.maxHeight.description === undefined) {
maxHeight = "津波の高さ(予想):不明";
} else {
maxHeight = `津波の高さ(予想)${data.maxHeight.description}`;
}
result = `
${data.name}
${grade}
${immediate}
${firstHeight}
${maxHeight}\n\n
`;
}
ueuse(result);
}
}
async function ueuse(text: string) {
const res = await fetch(`${config.uwuzu.host}/api/ueuse/create`, {
method: "POST",
body: JSON.stringify({
token: config.uwuzu.apiToken,
text: text,
}),
cache: "no-store",
});
const resData = await res.json();
console.log(`地震情報投稿:${JSON.stringify(resData)}`);
}
export default function earthquakeNotice(): void {
console.log("地震情報サーバーに接続します");
const client = new P2PEarthquakeClient();
client.start();
}
+30
View File
@@ -0,0 +1,30 @@
import { format } from "date-fns";
import eventdays from "./eventdayData.js";
import config from "../config.js";
export default async function EventDays() {
const now = format(new Date(), "MM/dd");
for (let i = 0; i < Object.keys(eventdays).length; i++) {
const day = Object.keys(eventdays)[i];
const value = Object.values(eventdays)[i];
const name = value.name;
const message = value.message;
if (day === now) {
const req = await fetch(`${config.uwuzu.host}/api/ueuse/create`, {
method: "POST",
body: JSON.stringify({
token: config.uwuzu.apiToken,
text:
`今日は${name}です
${message}`,
}),
});
const res = await req.json();
console.log("祝日等ユーズ:", res);
}
}
}
+70
View File
@@ -0,0 +1,70 @@
interface eventdaysValue {
name: string;
message: string;
}
const eventdays = {
"01/01": {
name: "元日",
message: "はい年越した瞬間地球にいなかった~",
},
"02/11": {
name: "建国記念日",
message: "建国記念日とかいう強制休暇",
},
"04/29": {
name: "昭和の日",
message: "平成の日は???",
},
"05/03": {
name: "憲法記念日",
message: "憲法決めた日とか祝日じゃなくていいだろ",
},
"05/04": {
name: "みどりの日",
message:
`なんだよみどりの日って
単色全部作れよ`,
},
"05/05": {
name: "こどもの日",
message: "こどもの日あるならおとなの日もあっていいだろ",
},
"07/07": {
name: "七夕",
message:
`祭りでも行っとけ
どーせ開発者のLast2014は家でサーバーいじってるから`,
},
"08/11": {
name: "山の日",
message:
`空の日と山の日も作れよ
水の循環に重要な3つ`,
},
"11/03": {
name: "文化の日",
message: "ネットミームできるたびに休みになればいいのになぁ...",
},
"11/23": {
name: "勤労感謝の日",
message: "学生は神!!",
},
"12/24": {
name: "クリスマスイブ",
message:
`リア充爆破します
by 開発者`,
},
"12/25": {
name: "クリスマス",
message: `リア充爆破します(2回目)
by 開発者`,
},
"12/31": {
name: "大晦日",
message: "大掃除!!大掃除!!",
},
} as { [key: string]: eventdaysValue };
export default eventdays;
+60
View File
@@ -0,0 +1,60 @@
import * as fs from "fs";
import { isAfter } from "date-fns";
import config from "../config.js";
import sendMail from "../src/mailer.js";
export default function successExit() {
// 初期化
if (!fs.existsSync("logs/boot.json")) {
fs.writeFileSync("logs/boot.json", JSON.stringify({
start: new Date(),
stop: "",
}), "utf-8");
}
const iolog = JSON.parse(fs.readFileSync("logs/boot.json", "utf-8"));
if (config.emergency.isEnabled) {
// 前回の終了確認
const start = iolog.start;
const stop = iolog.stop;
if (isAfter(start, stop)) {
console.log("前回の終了が適切でない可能性があります");
if (config.emergency.mail.isEnabled) {
sendMail({
to: config.emergency.mail.to,
subject: "【警告】前回終了が不適切な可能性",
text: `
※noticeUwuzu自動送信によるメールです。
【警告】
BOT管理者さん、noticeUwuzu自動送信メールです。
BOTの前回終了で不適切なデータを検出しました。
これは適切な終了時にはデータを残しデータがない場合に送信されます。
電源を強制的に遮断するなどの行為による可能性があります。
その場合は今後やめ、OSからのシャットダウンを使用してください。
BOTのプログラムが破損していないかご確認ください。
`
});
}
console.log("----------------");
}
}
// 起動時に起動時刻を保存
iolog.start = new Date();
fs.writeFileSync("logs/boot.json", JSON.stringify(iolog), "utf-8");
// 終了時に終了時刻を保存
process.on("exit", () => {
const iolog = JSON.parse(fs.readFileSync("logs/boot.json", "utf-8"));
iolog.stop = new Date();
fs.writeFileSync("logs/boot.json", JSON.stringify(iolog), "utf-8");
});
}
successExit();
+47
View File
@@ -0,0 +1,47 @@
import { format } from "date-fns";
import type * as types from "types/types";
import config from "../config.js";
export default async function timeNotice() {
// 停止時間
// 時刻取得
const start = config.time.stopTimes.start;
const stop = config.time.stopTimes.stop;
// 現在の時間を取得
const nowHour = new Date().getHours();
// 停止時刻内かどうかの判定
let inRange: boolean = false;
if (start < stop) {
inRange = nowHour >= start && nowHour < stop;
} else {
inRange = nowHour >= start || nowHour < stop;
}
if (inRange) {
console.log("----------------");
console.log("時報休止期間のため投稿されませんでした");
return;
} else {
// 投稿
const resUeuse = await fetch(
`${config.uwuzu.host}/api/ueuse/create`,
{
method: "POST",
body: JSON.stringify({
token: config.uwuzu.apiToken,
text: `${format(new Date(), "HH:mm")}になりました`,
}),
cache: "no-store",
},
);
const ueuseData: types.ueuseCreateApi = await resUeuse.json();
console.log("----------------");
console.log(`時報投稿:${JSON.stringify(ueuseData)}`);
}
}
+126
View File
@@ -0,0 +1,126 @@
import { cityList } from "../src/weatherId.js";
import type * as types from "types/types";
import config from "../config.js";
export async function weatherNotice() {
console.log("----------------");
// 仮投稿
const resUeuse = await fetch(
`${config.uwuzu.host}/api/ueuse/create`,
{
method: "POST",
body: JSON.stringify({
token: config.uwuzu.apiToken,
text: `
本日の天気
※タイムラインが埋まるため返信に記載しています
`,
}),
cache: "no-store",
},
);
const ueuseData: types.ueuseCreateApi = await resUeuse.json();
console.log(`天気仮投稿:${JSON.stringify(ueuseData)}`);
weatherReply(ueuseData.uniqid);
}
export async function weatherReply(uniqid: string) {
// インデックス
const splitCount = config.weather.splitCount;
const total = cityList.length;
const chunkSizes = Array(splitCount).fill(0).map((_, i) =>
Math.floor((total + i) / splitCount)
);
// 分割インデックス
let start = 0;
const ranges = chunkSizes.map(size => {
const range = [start, start + size];
start += size;
return range;
});
// 配列作成
const weatherResults = Array(splitCount).fill("");
// 天気取得
for (let chunkIndex = 0; chunkIndex < splitCount; chunkIndex++) {
const [chunkStart, chunkEnd] = ranges[chunkIndex];
for (let i = chunkStart; i < chunkEnd; i++) {
const res = await fetch(
`https://weather.tsukumijima.net/api/forecast/city/${cityList[i]}`,
);
const data = await res.json();
const today = data.forecasts[0];
// 天気
const weather = today.telop ?? "取得できませんでした";
// 最高気温
let maxTemp: string;
if (today.temperature.max.celsius !== null) {
maxTemp = `${today.temperature.max.celsius}`;
} else {
maxTemp = "取得できませんでした";
}
// 最低気温
let minTemp: string;
if (today.temperature.min.celsius !== null) {
minTemp = `${today.temperature.min.celsius}`;
} else {
minTemp = "取得できませんでした";
}
// 降水確率
let chanceOfRain: string;
if (
today.chanceOfRain.T06_12 !== null ||
today.chanceOfRain.T06_12 !== "--%"
) {
chanceOfRain = today.chanceOfRain.T06_12;
} else {
chanceOfRain = "取得できませんでした";
}
weatherResults[chunkIndex] += `
${data.location.city}
天気:${weather}
最高気温:${maxTemp}
最低気温:${minTemp}
降水確率:${chanceOfRain}
`;
}
}
// 分割投稿
for (let i = 0; i < splitCount; i++) {
const resReply = await fetch(
`${config.uwuzu.host}/api/ueuse/create`,
{
method: "POST",
body: JSON.stringify({
token: config.uwuzu.apiToken,
text: weatherResults[i],
replyid: uniqid,
}),
cache: "no-store",
},
);
const replyData: types.ueuseCreateApi = await resReply.json();
console.log(`天気返信:${JSON.stringify(replyData)}`);
}
}
-21
View File
@@ -1,21 +0,0 @@
import client, { createUeuse } from "@/lib/client";
import ueuseModule from "better-uwuzu-sdk/types/1.6.8/types/modules/ueuse";
import i18next from "i18next";
export default async function followCommand(ueuse: ueuseModule) {
const follow = await client.request("users/follow", {
userid: ueuse.account.userid,
});
if (!follow.success) {
console.warn("フォローに失敗:", follow.error_code);
return;
}
console.log("フォロー:", follow.userid);
await createUeuse({
text: i18next.t("followedNotification", { username: ueuse.account.username }),
replyid: ueuse.uniqid,
}, "フォロー通知");
}
-47
View File
@@ -1,47 +0,0 @@
import { createUeuse } from "@/lib/client";
import ueuseModule from "better-uwuzu-sdk/types/1.6.8/types/modules/ueuse";
import i18next from "i18next";
import { EOL } from "node:os";
const helps = [
"help",
"follow",
"unfollow",
"weather",
"miq",
];
export default async function helpCommand(ueuse: ueuseModule, args: string[]) {
if (args[1] !== undefined) {
if (!(helps.includes(args[1]))) {
await createUeuse({
text: i18next.t("invalidOption", { option: args[1], command: "help" }),
replyid: ueuse.uniqid,
}, "無効なオプションである旨");
return;
}
await createUeuse({
text: i18next.t(`fullHelp${args[1].charAt(0).toUpperCase()}${args[1].slice(1)}`),
replyid: ueuse.uniqid,
}, "コマンド詳細");
return;
}
let summarys = "";
for (let i = 0; i < helps.length; i++) {
const help = helps[i];
if (!help)
continue;
summarys += `${i18next.t(`help${help.charAt(0).toUpperCase()}${help.slice(1)}`)}${EOL}`;
}
await createUeuse({
text: summarys.trim(),
replyid: ueuse.uniqid,
}, "コマンド概要");
}
-148
View File
@@ -1,148 +0,0 @@
import client, { createUeuse } from "@/lib/client";
import Memory from "@/lib/memory";
import ueuseModule from "better-uwuzu-sdk/types/1.6.8/types/modules/ueuse";
import i18next from "i18next";
import weatherCommand from "@/feature/command/weather";
import helpCommand from "@/feature/command/help";
import followCommand from "@/feature/command/follow";
import unfollowCommand from "@/feature/command/unfollow";
import miqCommand from "@/feature/command/miq";
import initI18n from "@/lib/i18n";
import config from "@/lib/config";
await initI18n();
console.log("コマンドの処理を行います");
try {
let ueuses: ueuseModule[] = [];
{
const response = await client.request("me/notification/", {
page: 1,
limit: 20,
});
if (response.success) {
const notifications = response.data.filter(notification => notification.category === "reply" && typeof notification.valueid === "string");
const mem = Memory.memory;
const lastReadReply = mem.lastReadReply;
for (const [index, notification] of notifications.entries()) {
if (notification.category !== "reply" || typeof notification.valueid !== "string")
continue;
if (lastReadReply === notification.valueid)
break;
const ueuseResponse = await client.request("ueuse/get", {
uniqid: notification.valueid,
});
if (!ueuseResponse.success || !ueuseResponse.data[0]) {
console.warn("返信通知からユーズを参照できないため、スキップします");
continue;
}
if (index === 0) {
const mem = Memory.memory;
mem.lastReadReply = ueuseResponse.data[0].uniqid;
Memory.memory = mem;
}
ueuses.push(ueuseResponse.data[0]);
}
} else {
console.warn("返信通知の取得に失敗しましたが、続行します");
}
}
{
const response = await client.request("ueuse/mentions", {
page: 1,
limit: 20,
});
if (response.success) {
const mentions = response.data;
const mem = Memory.memory;
const lastReadMention = mem.lastReadMention;
mem.lastReadMention = mentions[0]?.uniqid ?? lastReadMention;
Memory.memory = mem;
for (const mention of mentions) {
if (lastReadMention === mention.uniqid) {
break;
}
ueuses.push(mention);
}
} else {
console.warn("メンションの取得に失敗しましたが、続行します");
}
}
ueuses = [...new Set(ueuses)];
for (let i = 0; i < ueuses.length; i += config.command.maxParallels) {
const chunk = ueuses.slice(i, i + config.command.maxParallels);
await Promise.all(chunk.map(async (ueuse) => {
const mem = Memory.memory;
let text = ueuse.text;
text = text.replace(`@${mem.userid}`, "");
text = text.trim();
const rows = text.split(/\r\n|\r|\n/).map(row => row.trim());
const commandRow = rows.filter(row => row.startsWith("/"))[0];
if (!commandRow || commandRow === "") {
console.warn("コマンドが本文から参照できません");
await createUeuse({
text: i18next.t("commandNotFound"),
replyid: ueuse.uniqid,
}, "コマンドが見つからない旨");
return;
}
const args = commandRow.replace("/", "").split(" ");
switch (args[0]) {
case "help":
await helpCommand(ueuse, args);
break;
case "weather":
await weatherCommand(ueuse);
break;
case "follow":
await followCommand(ueuse);
break;
case "unfollow":
await unfollowCommand(ueuse);
break;
case "miq":
await miqCommand(ueuse, args);
break;
default:
console.warn("不明なコマンドが入力されました:", args[0]);
await createUeuse({
text: i18next.t("unknownCommand", { command: args[0] }),
replyid: ueuse.uniqid,
}, "コマンドが不明である旨");
break;
}
}));
}
process.exit(0);
} catch (err: any) {
console.error("message" in err
? err.message
: err);
process.exit(1);
}
-302
View File
@@ -1,302 +0,0 @@
import client, { createUeuse } from "@/lib/client";
import Memory from "@/lib/memory";
import ueuseModule from "better-uwuzu-sdk/types/1.6.8/types/modules/ueuse";
import i18next from "i18next";
import MiQ from "miq";
import { EOL } from "node:os";
export default async function miqCommand(ueuse: ueuseModule, args: string[]) {
if (!args[1]) {
await createUeuse({
text: i18next.t("lackOption", { command: "miq" }),
replyid: ueuse.uniqid,
}, "オプションが不足している旨");
return;
}
let mem = Memory.memory;
switch (args[1]) {
case "generate":
const itUeuse = await client.request("ueuse/get", {
uniqid: ueuse.replyid,
});
if (!itUeuse.success || !itUeuse.data[0]) {
console.warn("返信元のユーズの取得に失敗:", "error_code" in itUeuse
? itUeuse.error_code
: "データなし");
await createUeuse({
text: i18next.t("replySourceFailed"),
replyid: ueuse.uniqid,
}, "ソースが見つからない旨");
return;
}
mem = Memory.memory;
const permission = mem["permissions"][itUeuse.data[0].account.userid] ?? "consent";
switch (permission) {
case "me":
if (itUeuse.data[0].account.userid !== ueuse.account.userid) {
await createUeuse({
text: i18next.t("miqPermissionMe"),
replyid: ueuse.uniqid,
}, "権限が自分自身のみである旨");
return;
}
case "consent":
if (itUeuse.data[0].account.userid !== ueuse.account.userid) {
await createUeuse({
text: i18next.t("miqPermissionConsent", { userid: itUeuse.data[0].account.userid }),
replyid: ueuse.uniqid,
}, "権限が許可制である旨");
return;
}
}
const result = await MiQ({
type: "Base64Data",
color: (args[2] ?? "") === "color",
text: itUeuse.data[0].text,
iconURL: itUeuse.data[0].account.user_icon,
userid: itUeuse.data[0].account.userid,
username: itUeuse.data[0].account.username,
});
if (!(typeof result === "string")) {
await createUeuse({
text: i18next.t("miqGenerateFailed"),
replyid: ueuse.uniqid,
}, "Make it a Quoteの生成に失敗した旨");
return;
}
await createUeuse({
text: i18next.t("miqSuccess", { message: (args[2] ?? "") === "color"
? "カラーモードで生成しました。"
: "モノクロモードで生成しました。"
}),
media: {
photo: [result],
},
replyid: ueuse.uniqid,
}, "Make it a Quote");
break;
case "permission":
if (args[2] === undefined) {
mem = Memory.memory;
const permission = mem["permissions"][ueuse.account.userid] ?? "consent";
await createUeuse({
text: i18next.t("permissionResponse", { permission }),
replyid: ueuse.uniqid,
}, "権限");
return;
}
const availablePermission = ["me", "everyone", "consent"];
if (!(availablePermission.includes(args[2]))) {
await createUeuse({
text: i18next.t("invalidOption", { option: args[2], command: "miq" }),
replyid: ueuse.uniqid,
}, "無効なオプションである旨");
return;
}
{
mem = Memory.memory;
mem["permissions"][ueuse.account.userid] = args[2];
Memory.memory = mem;
await createUeuse({
text: i18next.t("permissionChangeSuccess", { username: ueuse.account.username, permission: args[2] }),
replyid: ueuse.uniqid,
}, "権限の変更に成功した旨");
}
break;
case "allow":
if (ueuse.replyid === "") {
await createUeuse({
text: i18next.t("injusticeFormat"),
replyid: ueuse.uniqid,
}, "形式が異なる旨");
return;
}
{
mem = Memory.memory;
const permission = mem["permissions"][ueuse.account.userid] ?? "consent";
if (permission !== "consent") {
await createUeuse({
text: i18next.t("permisionIsNotConsent"),
replyid: ueuse.uniqid,
}, "権限が許可制ではない旨");
return;
}
}
const confirmUeuse = await client.request("ueuse/get", {
uniqid: ueuse.replyid,
});
if (!confirmUeuse.success || !confirmUeuse.data[0]) {
console.warn("返信元のユーズの取得に失敗:", "error_code" in confirmUeuse
? confirmUeuse.error_code
: "データなし");
await createUeuse({
text: i18next.t("replySourceFailed"),
replyid: ueuse.uniqid,
}, "ソースが見つからない旨");
return;
}
if (confirmUeuse.data[0].replyid === "") {
await createUeuse({
text: i18next.t("injusticeFormat"),
replyid: ueuse.uniqid,
}, "形式が異なる旨");
return;
}
mem = Memory.memory;
if (confirmUeuse.data[0].account.userid !== mem["userid"]) {
console.warn("返信元のユーズがBotではない:", "error_code" in confirmUeuse
? confirmUeuse.error_code
: "データなし");
await createUeuse({
text: i18next.t("replySourceIsNotThis"),
replyid: ueuse.uniqid,
}, "返信元がこのBotではない旨");
return;
}
const requestUeuse = await client.request("ueuse/get", {
uniqid: confirmUeuse.data[0].replyid,
});
if (!requestUeuse.success || !requestUeuse.data[0]) {
console.warn("返信元のユーズの取得に失敗:", "error_code" in requestUeuse
? requestUeuse.error_code
: "データなし");
await createUeuse({
text: i18next.t("replySourceFailed"),
replyid: ueuse.uniqid,
}, "ソースが見つからない旨");
return;
}
if (requestUeuse.data[0].replyid === "") {
await createUeuse({
text: i18next.t("injusticeFormat"),
replyid: ueuse.uniqid,
}, "形式が異なる旨");
return;
}
const sourceUeuse = await client.request("ueuse/get", {
uniqid: requestUeuse.data[0].replyid,
});
if (!sourceUeuse.success || !sourceUeuse.data[0]) {
console.warn("返信元のユーズの取得に失敗:", "error_code" in sourceUeuse
? sourceUeuse.error_code
: "データなし");
await createUeuse({
text: i18next.t("replySourceFailed"),
replyid: ueuse.uniqid,
}, "ソースが見つからない旨");
return;
}
if (sourceUeuse.data[0].account.userid !== ueuse.account.userid) {
await createUeuse({
text: i18next.t("replySourceIsNotSourceUser"),
replyid: ueuse.uniqid,
}, "ソースのユーズと/miq allowのユーザーが一致しない旨");
return;
}
{
mem = Memory.memory;
let text = requestUeuse.data[0].text;
text = text.replace(`@${mem.userid}`, "");
text = text.trim();
const rows = text.split(/\r\n|\r|\n/).map(row => row.trim());
const commandRow = rows.filter(row => row.startsWith("/"))[0];
if (!commandRow || commandRow === "") {
console.warn("コマンドが本文から参照できません");
await createUeuse({
text: i18next.t("commandNotFound"),
replyid: requestUeuse.data[0].uniqid,
}, "コマンドが見つからない旨");
await createUeuse({
text: i18next.t("injusticeFormat"),
replyid: ueuse.uniqid,
}, "形式が異なる旨");
break;
}
const requestUeuseArgs = commandRow.replace("/", "").split(" ");
const result = await MiQ({
type: "Base64Data",
color: (requestUeuseArgs[2] ?? "") === "color",
text: sourceUeuse.data[0].text,
iconURL: sourceUeuse.data[0].account.user_icon,
userid: sourceUeuse.data[0].account.userid,
username: sourceUeuse.data[0].account.username,
});
if (!(typeof result === "string")) {
await createUeuse({
text: i18next.t("miqGenerateFailed"),
replyid: ueuse.uniqid,
}, "Make it a Quoteの生成に失敗した旨");
return;
}
await createUeuse({
text: i18next.t("miqSuccess", { message: ((requestUeuseArgs[2] ?? "") === "color"
? "カラーモードで生成しました。"
: "モノクロモードで生成しました。")
+ EOL + `@${requestUeuse.data[0].account.userid}`,
}),
media: {
photo: [result],
},
replyid: ueuse.uniqid,
}, "Make it a Quote");
}
break;
}
}
-21
View File
@@ -1,21 +0,0 @@
import client, { createUeuse } from "@/lib/client";
import ueuseModule from "better-uwuzu-sdk/types/1.6.8/types/modules/ueuse";
import i18next from "i18next";
export default async function unfollowCommand(ueuse: ueuseModule) {
const unfollow = await client.request("users/unfollow", {
userid: ueuse.account.userid,
});
if (!unfollow.success) {
console.warn("フォロー解除に失敗:", unfollow.error_code);
return;
}
console.log("フォロー解除:", unfollow.userid);
await createUeuse({
text: i18next.t("unfollowedNotification", { username: ueuse.account.username }),
replyid: ueuse.uniqid,
}, "フォロー解除通知");
}
-6
View File
@@ -1,6 +0,0 @@
import ueuseModule from "better-uwuzu-sdk/types/1.6.8/types/modules/ueuse";
import { weatherReply } from "@/feature/weatherNotice";
export default async function weatherCommand(ueuse: ueuseModule) {
await weatherReply(ueuse.uniqid);
}
-324
View File
@@ -1,324 +0,0 @@
import { createUeuse } from "@/lib/client";
import config from "@/lib/config";
import initI18n from "@/lib/i18n";
import Memory from "@/lib/memory";
import { format } from "date-fns";
import i18next from "i18next";
import { readFileSync } from "node:fs";
import { EOL } from "node:os";
import { WebSocket } from "ws";
await initI18n();
if (config.earthquake?.useHistoryData) {
console.log("過去の地震情報を配信します");
const history = JSON.parse(readFileSync(`${import.meta.dirname}/../../260420.json`, "utf-8"));
history.reverse();
let i = 0;
setInterval(() => {
processMessage(history[i]);
i++;
}, 10 * 1000);
} else {
const connect = () => {
const WEBSOCKET_URL = config.debug
? "wss://api-realtime-sandbox.p2pquake.net/v2/ws"
: "wss://api.p2pquake.net/v2/ws";
console.log("P2P地震情報のWebSocketに接続します");
const socket = new WebSocket(WEBSOCKET_URL);
socket.addEventListener("open", () => {
console.log("P2P地震情報のWebSocketに接続しました");
});
socket.addEventListener("close", (err) => {
const interval = config.earthquake.reconnectInterval;
console.log(`WebSocketが切断されました。${interval / 1000}秒後に再接続します:`, err.reason);
setTimeout(() => {
connect();
}, interval);
});
socket.addEventListener("error", (err) => {
console.error("WebSocketでエラーが発生しました:", err);
socket.close();
});
socket.addEventListener("message", async (event) => {
let message;
try {
message = typeof event.data === "string"
? JSON.parse(event.data)
: event.data;
} catch (err) {
console.error("地震情報メッセージのパースでエラーが発生:", err);
return;
}
const id = message.id ?? message._id;
const mem = Memory.memory;
if (mem.processedInfo.includes(id) && !config.debug) {
console.log("重複した地震情報:", message.id);
return;
}
processMessage(message);
if (!config.debug) {
mem.processedInfo.concat([id]);
Memory.memory = mem;
}
});
}
connect();
}
const processMessage = async (message: any) => {
try {
const scaleMessages: Record<string, string> = {
"-1": "不明",
"0": "震度0",
"10": "震度1",
"20": "震度2",
"30": "震度3",
"40": "震度4",
"45": "震度5弱",
"46": "**推定**震度5弱以上***(正確には不明)***",
"50": "震度5強",
"55": "震度6弱",
"60": "震度6強",
"70": "震度7",
"99": "程度以上",
}
switch (message.code) {
case 555:
console.log("ピアの地域分布情報を受信しました");
break;
case 551:
{
console.log("地震発生情報を受信しました");
if (
(message.earthquake.maxScale === -1 ||
message.earthquake.maxScale === undefined) &&
!config.earthquake.canUnknownMaxScale
) {
console.log("最大震度が不明であり、最大震度が不明な場合の投稿が許可されていないため、スキップします");
break;
}
if (
message.earthquake.maxScale !== -1 &&
message.earthquake.maxScale < config.earthquake.requireMaxScale
) {
console.log(`最大震度(${scaleMessages[message.earthquake.maxScale]})が投稿に必要な値(${scaleMessages[config.earthquake.requireMaxScale]})に満たないため、スキップします`);
break;
}
const domesticTsunamiMessages: Record<string, string> = {
"None": "😌この地震による**国内**の津波の心配はありません。",
"Unknown": "😕この地震による**国内**の***津波情報は***不明です。",
"Checking": "🧐この地震による**国内**の津波情報を**調査中です。**",
"NonEffective": "😌この地震による**国内**の**海面変動が予想されますが**、被害の心配はありません。",
"Watch": "⚠️この地震により**国内**で津波注意報が発令しています。",
"Warning": "🚨この地震による**国内**の津波予報があります。",
}
const foreignTsunamiMessages: Record<string, string> = {
"None": "😌この地震による**国外**の津波の心配はありません。",
"Unknown": "😕この地震による**国外**の***津波情報は***不明です。",
"Checking": "🧐この地震による**国外**の津波情報を**調査中です。**",
"NonEffectiveNearby": "😌この地震によって**国外**にて震源の近傍で**小さな津波の可能性はありますが**、被害の心配はありません。",
"WarningNearby": "⚠️この地震によって**国外**にて震源の近傍で**津波の可能性**があります。",
"WarningPacific": "⚠️この地震によって**太平洋**にて**津波の可能性**があります。",
"WarningPacificWide": "🚨この地震によって**太平洋の広域**にて**津波の可能性**があります。",
"WarningIndian": "⚠️この地震によって**インド洋**にて**津波の可能性**があります。",
"WarningIndianWide": "🚨この地震によって**インド洋の広域**にて**津波の可能性**があります。",
"Potential": "🚨この地震によって**一般的に**この規模では津波の可能性があると考えられています。",
}
const grouped: Record<string, { scale: number; prefs: Record<string, string[]> }> = {};
for (const point of message.points) {
const { addr, scale, pref } = point;
const label = scaleMessages[String(scale)] ?? "不明";
if (!grouped[label]) {
grouped[label] = { scale, prefs: {} };
}
if (!grouped[label].prefs[pref]) {
grouped[label].prefs[pref] = [];
}
grouped[label].prefs[pref].push(addr);
}
const pointsMsg = Object.entries(grouped)
.sort((a, b) => b[1].scale - a[1].scale)
.map(([label, { prefs }]) => {
const prefLines = Object.entries(prefs)
.map(([pref, addrs]) => `**${pref}:** ${addrs.join("・")}`)
.join(EOL);
return `${label}${EOL}${prefLines}`;
})
.join(EOL.repeat(2))
.trim();
await createUeuse({
text: i18next.t("earthquakeNotice", {
occuredTime: format(new Date(message.earthquake.time), "yyyy年M月d日 H:mm"),
maxScale: scaleMessages[String(message.earthquake.maxScale)],
epicenter: message.earthquake.hypocenter.name === ""
? "不明"
: message.earthquake.hypocenter.name,
magnitude: message.earthquake.hypocenter.magnitude === -1
? "不明"
: `M${message.earthquake.hypocenter.magnitude.toFixed(1)}`,
depth: message.earthquake.hypocenter.depth === 0
? "ごく浅い"
: (message.earthquake.hypocenter.depth === -1
? "不明"
: `${message.earthquake.hypocenter.depth}km`),
domesticTsunami: domesticTsunamiMessages[(message.earthquake.domesticTsunami ?? "Unknown")],
foreignTsunami: foreignTsunamiMessages[(message.earthquake.foreignTsunami ?? "Unknown")],
points: pointsMsg === ""
? ""
: EOL.repeat(2) + pointsMsg,
source: message.issue.source ?? "不明",
comment: message.comments.freeFormComment === ""
? ""
: EOL + message.comments.freeFormComment + EOL,
}),
}, "地震発生情報");
}
break;
case 552:
{
console.log("津波予報情報を受信しました");
if (message.cancelled) {
await createUeuse({
text: i18next.t("tsunamiCancelNotice", {
announceTime: format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss"),
source: message.issue.source ?? "不明",
}),
}, "津波予報解除情報");
break;
}
const gradeMessages: Record<string, string> = {
"Unknown": "不明",
"Watch": "津波注意報",
"Warning": "津波警報",
"MajorWarning": "大津波警報",
}
let areasMsg = "";
for (const area of message.areas) {
areasMsg += i18next.t("tsunamiAreaMsg", {
name: area.name,
immediate: area.immediate
? EOL + "🚨***直ちに津波が来襲すると予想されています。***"
: "",
grade: gradeMessages[area.grade],
arrivalTime: format(new Date(area.firstHeight.arrivalTime), "yyyy年M月d日 H:mm"),
condition: area.firstHeight.condition
? `${area.firstHeight.condition}されています`
: "不明",
maxHeight: area.maxHeight.value === 0.2
? "0.2m未満"
: (area.maxHeight.value
? `${area.maxHeight.value}m`
: area.maxHeight.description),
}) + EOL.repeat(2);
}
await createUeuse({
text: i18next.t("tsunamiForecastNotice", {
announceTime: format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss"),
areasMsg: areasMsg.trim(),
source: message.issue.source ?? "不明",
}),
}, "津波予報情報");
}
break;
case 556:
{
console.log("緊急地震速報(警報)を受信しました");
if (message.cancelled) {
await createUeuse({
text: i18next.t("eewCancelNotice", {
isTest: message.test
? "⚒️これは**テストです。**"
: "🚨これは**テストではありません。**",
announceTime: format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss"),
}),
}, "緊急地震速報(警報)解除情報");
}
const kindMessages: Record<string, string> = {
"10": "⏳主要動は、**未到達と予測**されています。",
"11": "🫨主要動が、**既に到達していると予測**されています。",
"19": "🧐PLUM法によると、主要動の***到達予想は***ありません。",
}
let areasMsg = "";
for (const area of message.areas) {
areasMsg += i18next.t("eewAreaMsg", {
name: area.name,
maxScale: scaleMessages[String(Math.floor(area.scaleFrom))] +
(area.scaleTo === 99
? "程度以上"
: area.scaleFrom !== area.scaleTo
? `から${scaleMessages[String(Math.floor(area.scaleTo))]}`
: ""),
kind: kindMessages[area.kindCode] ?? "😕主要動の***到達予想は***ありません。",
arrivalTime: typeof area.arrivalTime === "string" && area.arrivalTime !== ""
? format(new Date(area.arrivalTime), "yyyy年M月d日 H:mm:ss")
: "不明",
}) + EOL.repeat(2);
}
await createUeuse({
text: i18next.t("eewNotice", {
isTest: message.test
? "⚒️これは**テストです。**"
: "🚨これは**テストではありません。**",
announceTime: format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss"),
occuredTime: format(new Date(message.earthquake.originTime), "yyyy年M月d日 H:mm:ss"),
arrivalTime: format(new Date(message.earthquake.arrivalTime), "yyyy年M月d日 H:mm:ss"),
isAssume: message.earthquake.condition === "仮定震源要素"
? `${EOL}❓これは、仮定震源要素です。そのため、震源に関する情報が保証できません。`
: "",
epicenter: message.earthquake.hypocenter.name ?? "不明",
depth: message.earthquake.hypocenter.depth === undefined ||
message.earthquake.hypocenter.depth === -1
? "不明"
: `${Math.floor(message.earthquake.hypocenter.depth)}km`,
magnitude: message.earthquake.hypocenter.magnitude === undefined ||
message.earthquake.hypocenter.magnitude === -1
? "不明"
: `M${message.earthquake.hypocenter.magnitude.toFixed(1)}`,
areas: areasMsg !== ""
? EOL.repeat(2) + areasMsg.trim()
: "",
}),
}, "緊急地震速報(警報)情報");
}
break;
default:
console.log("未対応の情報:", message);
break;
}
} catch (err) {
console.warn("メッセージの処理に失敗しました:", err);
}
}
-25
View File
@@ -1,25 +0,0 @@
import { createUeuse } from "@/lib/client";
import initI18n from "@/lib/i18n";
import { format } from "date-fns";
import i18next from "i18next";
import { parentPort } from "node:worker_threads";
await initI18n();
parentPort?.on("message", async () => {
console.log("新年迎春の投稿を行います");
try {
await createUeuse({
text: i18next.t("hnyNotice", { year: String(new Date().getFullYear()) }),
}, "新年迎春");
console.log("新年迎春投稿時刻:", format(new Date(), "yyyy/M/d H:mm:ss:SSS"));
process.exit(0);
} catch (err: any) {
console.error("message" in err
? err.message
: err);
process.exit(1);
}
});
-20
View File
@@ -1,20 +0,0 @@
import { createUeuse } from "@/lib/client";
import initI18n from "@/lib/i18n";
import { format } from "date-fns";
import i18next from "i18next";
await initI18n();
console.log("時報の投稿を行います");
try {
await createUeuse({
text: i18next.t("timeNotice", { time: format(new Date(), "H:mm") }),
}, "時報");
process.exit(0);
} catch (err: any) {
console.error("message" in err
? err.message
: err);
process.exit(1);
}
-196
View File
@@ -1,196 +0,0 @@
import client from "@/lib/client";
import config from "@/lib/config";
import initI18n from "@/lib/i18n";
import Memory from "@/lib/memory";
import i18next from "i18next";
import { readFileSync } from "node:fs";
import { EOL } from "node:os";
import { isMainThread, workerData } from "node:worker_threads";
const cityList = [
"016010",
"020010",
"030010",
"040010",
"050010",
"060010",
"070010",
"080010",
"090010",
"100010",
"110010",
"120010",
"130010",
"140010",
"150010",
"160010",
"170010",
"180010",
"190010",
"200010",
"210010",
"220010",
"230010",
"240010",
"250010",
"260010",
"270000",
"280010",
"290010",
"300010",
"310010",
"320010",
"330010",
"340010",
"350010",
"360010",
"370000",
"380010",
"390010",
"400010",
"410010",
"420010",
"430010",
"440010",
"450010",
"460010",
"471010",
];
if (!isMainThread && workerData === "scheduledWeatherNotice") {
await initI18n();
console.log("天気予報の投稿を行います");
try {
let provisionalUeuse;
let success = false;
for (let attempt = 1; attempt <= config.ueuse.maxRetries; attempt++) {
provisionalUeuse = await client.request("ueuse/create", {
text: i18next.t("weatherProvisional"),
});
if (provisionalUeuse.success) {
success = true;
break;
}
console.warn(`天気仮投稿に失敗しました (試行 ${attempt}/${config.ueuse.maxRetries}):`, provisionalUeuse.error_code);
if (attempt < config.ueuse.maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
if (!success || !provisionalUeuse?.success) {
console.error("天気仮投稿の全試行に失敗したため、終了します。");
process.exit(1);
}
console.log("天気仮投稿:", provisionalUeuse.uniqid);
weatherReply(provisionalUeuse.uniqid);
process.exit(0);
} catch (err: any) {
console.error("message" in err
? err.message
: err);
process.exit(1);
}
}
export async function weatherReply(uniqid: string) {
// インデックス
const aboutFullLength = 3100;
const mem = Memory.memory;
const splitCount = Math.round(aboutFullLength / mem.max_length);
const total = cityList.length;
const chunkSizes = Array(splitCount).fill(0).map((_, i) =>
Math.floor((total + i) / splitCount)
);
// 分割インデックス
let start = 0;
const ranges: [number, number][] = chunkSizes.map(size => {
const range: [number, number] = [start, start + size];
start += size;
return range;
});
// 配列作成
const weatherResults = Array(splitCount).fill("");
// package.json取得
const packageJson = JSON.parse(readFileSync(`${import.meta.dirname}/../../package.json`, "utf-8"));
// 天気取得
for (let chunkIndex = 0; chunkIndex < splitCount; chunkIndex++) {
const range = ranges[chunkIndex];
if (!range) continue;
const [chunkStart, chunkEnd] = range;
for (let i = chunkStart; i < chunkEnd; i++) {
const res = await fetch(`https://weather.tsukumijima.net/api/forecast/city/${cityList[i]}`, {
headers: {
"User-Agent": `noticeUwuzu/${packageJson.version}`,
},
});
const data = await res.json();
const today = data.forecasts[0];
// 天気
const weather = today.telop ?? "取得できませんでした";
const maxTemp = today.temperature.max.celsius
? `${today.temperature.max.celsius}`
: "取得できませんでした";
const minTemp = today.temperature.min.celsius
? `${today.temperature.min.celsius}`
: "取得できませんでした";
const chanceOfRain = (
today.chanceOfRain.T06_12 !== null &&
today.chanceOfRain.T06_12 !== "--%"
)
? today.chanceOfRain.T06_12
: "取得できませんでした";
weatherResults[chunkIndex] += `${i18next.t("weatherReply", {
city: data.location.city,
weather,
maxTemp,
minTemp,
chanceOfRain,
})}${EOL.repeat(2)}`;
}
}
// 分割投稿
for (let i = 0; i < splitCount; i++) {
let replyUeuse;
let success = false;
for (let attempt = 1; attempt <= config.ueuse.maxRetries; attempt++) {
replyUeuse = await client.request("ueuse/create", {
text: weatherResults[i].trim(),
replyid: uniqid,
});
if (replyUeuse.success) {
success = true;
break;
}
console.warn(`天気返信に失敗しました (試行 ${attempt}/${config.ueuse.maxRetries}):`, replyUeuse.error_code);
if (attempt < config.ueuse.maxRetries) {
await new Promise(resolve => setTimeout(resolve, config.ueuse.retryInterval));
}
}
if (!success || !replyUeuse?.success) {
console.error("天気返信の全試行に失敗したため、終了します。");
process.exit(1);
}
console.log("天気返信:", replyUeuse.uniqid);
}
}
-59
View File
@@ -1,59 +0,0 @@
import { schedule } from "node-cron";
import { readFileSync } from "node:fs";
import config from "@/lib/config";
import { initData } from "@/lib/memory";
import { styleText } from "node:util";
import { Worker } from "node:worker_threads";
try {
console.log(readFileSync(`${import.meta.dirname}/../asciiart.txt`, "utf-8"));
console.log(JSON.parse(readFileSync(`${import.meta.dirname}/../package.json`, "utf-8")).version);
if (config.debug) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
console.log(styleText(
["bgRed", "cyan", "bold"],
"デバッグモードが有効です",
));
}
console.log();
await initData();
new Worker(`${import.meta.dirname}/feature/earthquakeNotice.js`);
console.log("Botが起動しました");
} catch (err: any) {
console.error("message" in err
? err.message
: err);
process.exit(1);
}
try {
schedule("0 * * * *", async () => {
new Worker(`${import.meta.dirname}/feature/timeNotice.js`);
});
schedule("0 7 * * *", async () => {
new Worker(`${import.meta.dirname}/feature/weatherNotice.js`);
});
schedule(`*/${config.command.interval} * * * *`, async () => {
new Worker(`${import.meta.dirname}/feature/command/index.js`);
});
let hnyWorker: Worker | undefined = undefined;
schedule("57 59 23 31 12 *", () => {
hnyWorker = new Worker(`${import.meta.dirname}/feature/hnyNotice.js`);
});
schedule("0 0 0 1 1 *", () => {
hnyWorker?.postMessage("");
});
} catch (err: any) {
console.error("message" in err
? err.message
: err);
}
-102
View File
@@ -1,102 +0,0 @@
import uwuzu from "better-uwuzu-sdk";
import config from "@/lib/config";
import Parser from "better-uwuzu-sdk/1.6.8/parser";
import ApiMap from "better-uwuzu-sdk/types/1.6.8/map";
import Memory from "@/lib/memory";
import { EOL } from "node:os";
const client = new uwuzu<ApiMap>({
origin: config.uwuzu.origin,
parser: Parser,
});
client.token = config.uwuzu.token;
export default client;
export const createUeuse = async (data: ApiMap["ueuse/create"]["body"], title: string) => {
const mem = Memory.memory;
const excessedMessage = "👉返信に続きがあります。";
let lines = data.text.split(EOL);
let firstUniqid = "";
let count = 0;
while (lines.length > 0) {
count++;
let currentText = "";
const currentMaxLength = (data.replyid !== undefined || firstUniqid !== "")
? mem.max_length
: config.ueuse.maxLengthWithPublic === 0
? mem.max_length
: config.ueuse.maxLengthWithPublic;
const limit = currentMaxLength - (excessedMessage.length + EOL.length * 2);
while (lines.length > 0) {
const nextLine = lines[0];
if (nextLine === undefined)
break;
if (nextLine.length > limit && currentText === "") {
const targetLine = lines.shift() || "";
currentText = targetLine.slice(0, limit);
lines.unshift(targetLine.slice(limit));
break;
}
const potentialText = currentText ? currentText + EOL + nextLine : nextLine;
if (potentialText.length <= limit) {
currentText = potentialText;
lines.shift();
} else {
break;
}
}
currentText = currentText.trimEnd();
if (lines.length > 0) {
currentText += EOL.repeat(2) + excessedMessage;
}
let postedUniqid = "";
let success = false;
for (let attempt = 1; attempt <= config.ueuse.maxRetries; attempt++) {
const response = await client.request("ueuse/create", {
...data,
text: currentText,
replyid: data.replyid === undefined && firstUniqid !== ""
? firstUniqid
: data.replyid,
});
if (response.success) {
success = true;
postedUniqid = response.uniqid;
break;
}
console.warn(`${title}の投稿に失敗しました (試行 ${attempt}/${config.ueuse.maxRetries}):`, response.error_code);
if (attempt < config.ueuse.maxRetries) {
await new Promise(resolve => setTimeout(resolve, config.ueuse.retryInterval));
}
}
if (!success) {
console.error(`${title}の全試行が失敗したため、処理を中断します。`);
break;
}
if (firstUniqid === "")
firstUniqid = postedUniqid;
console.log(`${title}を投稿(${count}):`, postedUniqid);
while (lines.length > 0 && lines[0]?.trim() === "") {
lines.shift();
}
}
}
-56
View File
@@ -1,56 +0,0 @@
import z from "zod";
import { readFileSync } from "node:fs";
import { parse as yamlParse } from "yaml";
import { EOL } from "node:os";
const schema = z.object({
command: z.object({
interval: z.number().int().positive(),
maxParallels: z.number().int().positive(),
}),
earthquake: z.object({
requireMaxScale: z.union([
z.literal(10),
z.literal(20),
z.literal(30),
z.literal(40),
z.literal(45),
z.literal(50),
z.literal(55),
z.literal(60),
z.literal(70),
]),
canUnknownMaxScale: z.boolean(),
useHistoryData: z.boolean(),
reconnectInterval: z.number().positive(),
}),
uwuzu: z.object({
token: z.string().length(64),
origin: z.string().refine(data => {
try {
return new URL(data).origin === data;
} catch {
return false;
}
}),
}),
ueuse: z.object({
maxRetries: z.number().int().positive(),
retryInterval: z.number().positive(),
maxLengthWithPublic: z.number().int().nonnegative(),
}),
debug: z.boolean().optional(),
});
const configFile = readFileSync(`${import.meta.dirname}/../../config/config.yaml`, "utf-8");
const configObj = yamlParse(configFile);
const result = schema.safeParse(configObj);
if (!result.success) {
console.error("Config: configが無効です。");
console.error(` ${result.error.issues.map(issue => issue.message).join(EOL).replaceAll(EOL, `${EOL} `)}`);
process.exit(1);
}
const config = result.data;
export default config;
-28
View File
@@ -1,28 +0,0 @@
import i18next from "i18next";
import config from "@/lib/config";
import { parse as yamlParse } from "yaml";
import { readFileSync } from "node:fs";
const translation = Object.fromEntries(Object.entries(
yamlParse(readFileSync(`${import.meta.dirname}/../../locales/ja.yaml`, "utf-8"))
).map(([key, value]) => [
key,
typeof value === "string"
? value.trim()
: value,
]));
export default async function initI18n() {
await i18next.init({
lng: "ja",
debug: config.debug,
resources: {
ja: {
translation,
},
},
interpolation: {
escapeValue: false,
},
});
};
-58
View File
@@ -1,58 +0,0 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import client from "@/lib/client";
const path = `${import.meta.dirname}/../../memory.json`;
class MemoryClass {
private cachedMemory: any;
constructor() {
if (!existsSync(path)) {
writeFileSync(path, JSON.stringify({
processedInfo: [],
permissions: {},
lastReadMention: "",
lastReadReply: "",
userid: "",
max_length: 0,
}));
}
this.cachedMemory = JSON.parse(readFileSync(path, "utf-8"));
}
get memory() {
return this.cachedMemory;
}
set memory(data: any) {
this.cachedMemory = data;
writeFileSync(path, JSON.stringify(this.cachedMemory), "utf-8");
}
}
const Memory = new MemoryClass();
export const initData = async () => {
await Promise.all([
(async () => {
const response = await client.request("me/");
if (!response.success)
throw new Error("meの取得に失敗しました");
const mem = Memory.memory;
mem.userid = response.userid;
Memory.memory = mem;
})(),
(async () => {
const response = await client.request("serverinfo-api");
const mem = Memory.memory;
mem.max_length = response.server_info.max_ueuse_length;
Memory.memory = mem;
})(),
]);
}
export default Memory;
+64
View File
@@ -0,0 +1,64 @@
import config from "../config.js";
import * as nodemailer from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
export interface EmailMessage {
to: string | string[];
subject: string;
text?: string;
html?: string;
}
async function createTransporter() {
if (
config.emergency.isEnabled &&
config.emergency.mail.isEnabled
) {
const transporter = nodemailer.createTransport({
host: config.emergency.mail.host,
port: config.emergency.mail.port,
secure: config.emergency.mail.secure,
auth: {
user: config.emergency.mail.user,
pass: config.emergency.mail.password,
},
} as SMTPTransport.Options);
// 接続テスト
try {
await transporter.verify();
console.log("SMTPサーバーに接続できました");
} catch (error) {
console.error("SMTP接続テストに失敗:", error);
throw error;
}
return transporter;
}
}
export default async function sendMail(message: EmailMessage): Promise<void> {
if (
config.emergency.isEnabled &&
config.emergency.mail.isEnabled
) {
try {
const transporter: any = await createTransporter();
await transporter.sendMail({
from: config.emergency.mail.user,
to: Array.isArray(message.to) ? message.to.join(",") : message.to,
subject: message.subject,
text: message.text,
html: message.html,
});
console.log("メール送信成功");
} catch (error) {
console.error("メール送信に失敗しました:", error);
throw error;
}
} else {
return;
}
}
+49
View File
@@ -0,0 +1,49 @@
export const cityList: Array<String> = [
"016010",
"020010",
"030010",
"040010",
"050010",
"060010",
"070010",
"080010",
"090010",
"100010",
"110010",
"120010",
"130010",
"140010",
"150010",
"160010",
"170010",
"180010",
"190010",
"200010",
"210010",
"220010",
"230010",
"240010",
"250010",
"260010",
"270000",
"280010",
"290010",
"300010",
"310010",
"320010",
"330010",
"340010",
"350010",
"360010",
"370000",
"380010",
"390010",
"400010",
"410010",
"420010",
"430010",
"440010",
"450010",
"460010",
"471010",
];
+8 -7
View File
@@ -2,21 +2,22 @@
"compilerOptions": { "compilerOptions": {
"target": "ES2024", "target": "ES2024",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "node",
"outDir": "./dist", "outDir": "./dist",
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"baseUrl": "./src", "baseUrl": "./",
"typeRoots": [
"./node_modules/@types",
"./types"
],
"paths": { "paths": {
"@/*": ["./*"], "ws": ["./node_modules/ws/index.js"],
"@types/ws": ["./node_modules/@types/ws/index.d.ts"]
}, },
"removeComments": true, "removeComments": true,
}, },
"tsc-alias": {
"resolveFullPaths": true,
},
} }
+86
View File
@@ -0,0 +1,86 @@
interface earthquakeTypes {
reconnectTimes: number;
websocketUrl: string;
areasCsvUrl: string;
maxScaleMin: number;
}
interface weatherTypes {
splitCount: number;
}
interface stopsTypes {
start: number;
stop: number;
}
interface timeTypes {
stopTimes: stopsTypes;
}
interface emergencyMailFullTypes {
isEnabled: true;
host: string;
port: number;
user: string;
password: string;
secure: boolean;
to: string | string[];
}
interface emergencyMailMinTypes {
isEnabled: false;
mail: undefined;
}
interface emergencyFullTypes {
isEnabled: true;
mail: emergencyMailFullTypes | emergemcyMailMinTypes;
}
interface emergencyMinTypes {
isEnabled: false;
}
interface reportTypes {
isEnabled: boolean;
message: string;
}
interface legalTypes {
terms: string;
privacy: string;
}
interface PanelFullTypes {
isEnabled: true;
port: number;
}
interface PanelMinTypes {
isEnabled: false;
}
interface adminTypes {
name: string;
showMail: string | false;
panel: PanelFullTypes | PanelMinTypes;
}
interface uwuzuTypes {
apiToken: string;
host: string;
}
export interface configTypes {
time: timeTypes,
earthquake: earthquakeTypes;
weather: weatherTypes;
emergency: emergencyFullTypes | emergencyMinTypes;
report: reportTypes;
legal: legalTypes;
admin: adminTypes;
uwuzu: uwuzuTypes;
debug?: true;
}
+64
View File
@@ -0,0 +1,64 @@
export interface Role {
name: string;
color: string;
effect: string;
id: string;
}
export interface meApi {
username: string;
userid: string;
profile: string;
user_icon: string;
user_header: string;
registered_date: string;
followee: Array<string>;
followee_cnt: number;
follower: Array<string>;
follower_cnt: number;
ueuse_cnt: number;
isBot: Boolean;
isAdmin: Boolean;
role: Role[];
language: String;
}
export interface ueuse {
uniqid: string;
relpyid: string;
reuseid: string;
text: string;
account: {
username: string;
userid: string;
user_icon: string;
user_header: string;
is_bot: boolean;
};
photo1: string;
photo2: string;
photo3: string;
photo4: string;
video1: string;
favorite: Array<string>;
favorite_cnt: string;
datetime: string;
abi: string;
abidatetime: string;
nsfw: boolean;
}
export interface ueuseCreateApi {
uniqid: string;
userid: string;
}
export interface followApi {
userid: string;
}
export interface NetworkInterfaceDetails {
family: string;
internal: boolean;
address: string;
}
+68
View File
@@ -0,0 +1,68 @@
declare module 'ws' {
import { EventEmitter } from 'events';
import { IncomingMessage } from 'http';
import { Socket } from 'net';
export type Data = string | Buffer | ArrayBuffer | Buffer[];
export interface WebSocketEventMap {
close: CloseEvent;
error: ErrorEvent;
message: MessageEvent;
open: Event;
}
export default class WebSocket extends EventEmitter {
static readonly CONNECTING: 0;
static readonly OPEN: 1;
static readonly CLOSING: 2;
static readonly CLOSED: 3;
readonly CONNECTING: 0;
readonly OPEN: 1;
readonly CLOSING: 2;
readonly CLOSED: 3;
readonly readyState: 0 | 1 | 2 | 3;
readonly url: string;
readonly protocol: string;
constructor(address: string | URL, protocols?: string | string[]);
close(code?: number, reason?: string): void;
send(data: Data): void;
ping(data?: Data): void;
pong(data?: Data): void;
terminate(): void;
on(event: 'close', listener: (code: number, reason: Buffer) => void): this;
on(event: 'error', listener: (error: Error) => void): this;
on(event: 'message', listener: (data: Data) => void): this;
on(event: 'open', listener: () => void): this;
on(event: string | symbol, listener: (...args: any[]) => void): this;
addEventListener(type: 'close', listener: (event: CloseEvent) => void): void;
addEventListener(type: 'error', listener: (event: ErrorEvent) => void): void;
addEventListener(type: 'message', listener: (event: MessageEvent) => void): void;
addEventListener(type: 'open', listener: (event: Event) => void): void;
}
export interface CloseEvent {
code: number;
reason: string;
wasClean: boolean;
}
export interface ErrorEvent {
error: Error;
message: string;
type: string;
}
export namespace WebSocket {
export const CONNECTING: 0;
export const OPEN: 1;
export const CLOSING: 2;
export const CLOSED: 3;
}
}