Compare commits

..

75 Commits

Author SHA1 Message Date
last2014 b461304d65 Merge pull request '2026.5.8' (#25) from develop into main
Reviewed-on: #25
2026-05-21 13:46:18 +00:00
last2014 bf8e998d14 Merge branch 'main' into develop 2026-05-21 13:46:12 +00:00
last2014 3c7fa8fe82 Fix: 地震情報の情報源と各震度の間に改行がない問題 / Fix: 最大震度が不明な場合にも行が追加される問題 / Fix: pnpmが動作しない問題 / 2026.5.8 2026-05-21 22:45:48 +09:00
last2014 aff5d4e54d Merge pull request '2026.5.7' (#24) from develop into main
Reviewed-on: #24
2026-05-21 13:36:30 +00:00
last2014 fc5256c681 Merge branch 'main' into develop 2026-05-21 13:36:24 +00:00
last2014 11f29463a6 Chg: 地震情報で不明な情報をそもそも投稿しないように / 2026.5.7 2026-05-21 22:35:56 +09:00
last2014 6fc5ab9d0d Chg: pnpm 11.1.3 / Del: helpコマンドとmiqコマンドを除く全てのi18n / Feat: 朝四時に何してるんだい? 2026-05-21 22:22:45 +09:00
last2014 7774447b2b Merge pull request '2026.5.6' (#23) from develop into main
Reviewed-on: #23
2026-05-17 10:38:15 +00:00
last2014 b2ff3e481a Merge branch 'main' into develop 2026-05-17 10:38:08 +00:00
last2014 5ed2f3a41e Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2026-05-17 19:37:44 +09:00
last2014 7aff4cdf7d Fix: 2026.5.5で修正されたと報告した「20件前のユーズを最後に応答したコマンドのユーズとして記録していた問題」を再発させていた問題 / 2026.5.6 2026-05-17 19:37:38 +09:00
last2014 70227278c0 Merge pull request '2026.5.5' (#22) from develop into main
Reviewed-on: #22
2026-05-17 10:35:52 +00:00
last2014 896bb9aaf7 Merge branch 'main' into develop 2026-05-17 10:35:45 +00:00
last2014 386555ecf3 Fix: 毎日18:00の天気予報の仮投稿の表記が本日になっていた問題 / Chg: 毎日7:00の天気予報の日付表記 / Fix: 20件前のユーズを最後に応答したコマンドのユーズとして記録していた問題 / 2026.5.5 2026-05-17 19:35:13 +09:00
last2014 734b573a3b Merge pull request '2026.5.4' (#21) from develop into main
Reviewed-on: #21
2026-05-17 05:43:39 +00:00
last2014 d974115606 Merge branch 'main' into develop 2026-05-17 05:43:33 +00:00
last2014 c1edf6c13b Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2026-05-17 14:42:50 +09:00
last2014 95ed876a47 Chg: メモリをキャッシュしないように / 2026.5.4 2026-05-17 14:42:30 +09:00
last2014 9c09675205 Merge pull request '2026.5.3' (#20) from develop into main
Reviewed-on: #20
2026-05-17 04:56:17 +00:00
last2014 9b6e790d4b Merge branch 'main' into develop 2026-05-17 04:56:10 +00:00
last2014 7c4b5f7397 2026.5.3 2026-05-17 13:55:47 +09:00
last2014 3db3a75f64 Fix: distに震度分布画像のアセットと地域マップがコピーされるように / Del: 正確にスケジュールが動作する機能 / Fix: 返信とメンションの返答が記録されない問題 / Fix: 震度分布画像が投稿されない問題 / Chg: 震度分布画像のメッセージを変更 / Fix: 条件に合わなくても震度分布画像を生成する問題 / Fix: 震度分布画像に隙間が空く問題 2026-05-17 13:54:22 +09:00
last2014 2951a248f5 Merge pull request '2026.5.2' (#19) from develop into main
Reviewed-on: #19
2026-05-16 10:30:55 +00:00
last2014 eb027d7daf Merge branch 'main' into develop 2026-05-16 10:30:48 +00:00
last2014 4dfdb6fc4a Fix: /weatherが利用できない問題 / 2026.5.2 2026-05-16 19:30:09 +09:00
last2014 b92116f3a9 Merge pull request '2026.5.1' (#18) from develop into main
Reviewed-on: #18
2026-05-16 10:25:48 +00:00
last2014 6b732b3110 Merge branch 'main' into develop 2026-05-16 10:25:40 +00:00
last2014 a986068372 2026.5.1 2026-05-16 19:24:54 +09:00
last2014 3a446440d3 Chg: 全てのスケジュールが正確に動作するように 2026-05-16 19:20:16 +09:00
last2014 33435f4c46 Chg: 毎日18:00の天気予報は明日の天気予報を投稿するように 2026-05-16 18:53:25 +09:00
last2014 e7f0c3aed8 Chg: 毎日18:00にも天気予報が投稿されるように 2026-05-16 18:47:00 +09:00
last2014 6e9ea20d33 Feat: 地震の震度分布画像生成 / Chg: 震度分布画像のズームレベルを8から9へ / Chg: 震度分布画像の生成条件を震源と位置が存在するへ変更 / Feat: 震度分布画に全タイルが埋まる機能 / Feat: 震度分布画像の震源がタイル単位で中央になる機能 / Feat: 震度分布画像で欠けているタイルが描画される機能 / Chg: 震度分布画像のアセットを並列に取得 / Feat: 震度分布画像のアセットの位置が中央基準になる機能 / Chg: 震度分布画像の震源のアセットに縁取りを追加 / Chg: 震度分布画像の震度ではないアセットを拡大 / Chg: ユーズの分割の案内文を太字に / Del: 最大震度が不明な場合に投稿するかどうかのconfigを削除 2026-05-16 18:45:13 +09:00
last2014 83441a8eaf Fix: 地震情報の画像生成での並び替えで、震度の位置が震源の位置として扱われる問題 2026-05-11 13:42:40 +09:00
last2014 1c979939bb Fix: 毎日7:00に天気予報の返信をできない問題 2026-05-11 07:02:21 +09:00
last2014 391694ca45 Merge pull request '2026.5.0' (#17) from develop into main
Reviewed-on: #17
2026-05-10 21:58:33 +00:00
last2014 ca552be9e9 Merge branch 'main' into develop 2026-05-10 21:58:26 +00:00
last2014 e099522b7e 2026.5.0 2026-05-11 06:57:43 +09:00
last2014 16b1b9d2ab Merge pull request '2026.4.3' (#16) from develop into main
Reviewed-on: #16
2026-05-03 10:19:41 +00:00
last2014 f0246d7ce9 Merge branch 'main' into develop 2026-05-03 10:19:33 +00:00
last2014 165500e71d 2026.4.3 / Chg: コマンド実行の際にメンションと通知を時系列にソートしないように 2026-05-03 19:18:58 +09:00
last2014 4f2be0e0ed Chg: forループ内でlastRead*を記録するように / Chg: lastRead*を時刻ベースに 2026-05-03 19:12:27 +09:00
last2014 7f2ef21557 Chg: コマンド実行の際にメンションと通知を時系列にソートするように 2026-05-03 18:49:01 +09:00
last2014 b33b998ae4 Merge pull request '2026.4.2' (#15) from develop into main
Reviewed-on: #15
2026-05-03 07:36:22 +00:00
last2014 e17da1686d Merge branch 'main' into develop 2026-05-03 07:36:16 +00:00
last2014 ab06f4ceec 2026.4.2 / Fix: id除外が機能しない問題 2026-05-03 16:35:59 +09:00
last2014 47fe174ac6 Merge pull request '2026.4.1' (#14) from develop into main
Reviewed-on: #14
2026-05-03 07:11:02 +00:00
last2014 747bf00e7b Merge branch 'main' into develop 2026-05-03 07:10:56 +00:00
last2014 3baa55bd29 Merge branch 'develop' of https://gitea.last2014.com/last2014/noticeUwuzu into develop 2026-05-03 16:10:06 +09:00
last2014 90e9d614f9 2026.4.1 / Chg: notificationsもfor内でlastReadを更新しないように 2026-05-03 16:09:04 +09:00
last2014 41dc68340f Merge pull request '2026.4.0' (#13) from develop into main
Reviewed-on: #13
2026-05-03 06:14:47 +00:00
last2014 0dcbbf993c Merge branch 'main' into develop 2026-05-03 06:14:42 +00:00
last2014 fbef68ce79 2026.4.0 2026-05-03 15:14:01 +09:00
last2014 953c49583c Chg: 最大震度が投稿に必要な値に満たない場合のコンソール表示を変更 / Feat: 返信ではないユーズの自主的な文字数制限 2026-05-03 15:10:37 +09:00
last2014 86f6f72067 Merge pull request '2026.4.0-beta.0' (#12) from develop into main
Reviewed-on: #12
2026-05-03 05:28:47 +00:00
last2014 eb4d1450c3 Merge branch 'main' into develop 2026-05-03 05:28:41 +00:00
last2014 251fc0f403 2026.4.0-beta.0 2026-05-03 14:28:01 +09:00
last2014 95aa0bdb45 Feat: 地震発生情報の地域を都道府県でグループ化する機能 2026-05-03 14:23:49 +09:00
last2014 6fd92973f8 Chg: 地震発生情報の時刻に頃を追加 / Feat: ユーズが分割の何個目のユーズかをコンソールに表示 2026-05-03 14:09:35 +09:00
last2014 2b21401587 Chg: 文字数制限回避を1行ずつに 2026-05-03 14:01:45 +09:00
last2014 d429503b78 Feat: ユーズの再試行 / Feat: ユーズの文字数制限回避 / Feat: ユーズ送信関数 / Chg: weatherNotice.tsのマジックナンバーに命名 2026-05-03 13:50:11 +09:00
last2014 74c1552472 Feat: コマンドの並列処理数を制限できる機能 2026-05-03 11:47:50 +09:00
last2014 0012fea2de Feat: コマンドを並列処理 2026-05-03 11:41:31 +09:00
last2014 d61fc56a38 Feat: 地震情報のWebSocket再接続 2026-05-02 20:32:15 +09:00
last2014 c0df3d7344 Feat: 天気予報の分割数を自動的に計算する機能 / Fix: 震度0の地震情報に対応していない問題 / Feat: 最大震度の要求を設定できる機能 / Feat: 最大震度が不明な場合に投稿するかどうかを設定できる機能 2026-05-02 20:07:37 +09:00
last2014 8045fdacec Fix: コマンド処理済みのユーズのid除外が正しく動作しない問題 / Fix: miqコマンドのコンソール出力に値が存在しない問題 2026-05-02 18:56:07 +09:00
last2014 f727ca1bf1 Merge pull request '2026.4.0-alpha.2' (#11) from develop into main
Reviewed-on: #11
2026-05-01 13:12:21 +00:00
last2014 d59857593a 2026.4.0-alpha.2 2026-05-01 22:10:50 +09:00
last2014 23a06fc88f Chg: 時報の時刻フォーマットをHH:mmからH:mmに変更 / Feat: 緊急地震速報(警報)解除 2026-05-01 22:06:04 +09:00
last2014 3b66c47aa3 Fix: for文でスキップではなくループの終了を行っていた問題 / Feat: #10 2026-04-30 20:10:59 +09:00
last2014 90b308afa0 Chg: 新年迎春の投稿する際の遅延を削減 / Feat: 新年迎春を投稿した時刻をコンソールに表示 / Fix: 地震情報のid除外が本番環境で動作しない問題 2026-04-29 19:13:08 +09:00
last2014 109d32a3ed Feat: 新年迎春 2026-04-29 14:45:29 +09:00
last2014 3bd235b0cb Feat: #7 2026-04-29 14:17:54 +09:00
last2014 67dc014cce 2026.4.0-alpha.1 2026-04-28 16:47:17 +09:00
last2014 ebc770ab21 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-04-28 16:44:41 +09:00
last2014 ecbfc828d6 2026.4.0-alpha.0 2026-04-27 19:55:50 +09:00
54 changed files with 28611 additions and 829 deletions
+5 -7
View File
@@ -1,7 +1,5 @@
/dist/
/.env*
/node_modules/
/package-lock.json
/config.ts
log*
/node_modules
/dist
/memory.json
/config/**
!/config/example.yaml
+24841
View File
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
# 2026.5.8
- Fix: 地震情報の情報源と各震度の間に改行がない問題
- Fix: 最大震度が不明な場合にも行が追加される問題
- Fix: pnpmが動作しない問題
# 2026.5.7
- Feat: 朝四時に何してるんだい?
- Chg: pnpm 11.1.3
- Chg: 地震情報で不明な情報をそもそも投稿しないように
- Del: helpコマンドとmiqコマンドを除く全てのi18nを廃止
# 2026.5.6
- Fix: 2026.5.5で修正されたと報告した「20件前のユーズを最後に応答したコマンドのユーズとして記録していた問題」を再発させていた問題
# 2026.5.5
- Chg: 毎日7:00の天気予報の日付表記
- Fix: 20件前のユーズを最後に応答したコマンドのユーズとして記録していた問題
- Fix: 毎日18:00の天気予報の仮投稿の表記が本日になっていた問題
# 2026.5.4
- Chg: メモリをキャッシュしないように
# 2026.5.3
- Chg: 震度分布画像のメッセージを変更
- Fix: distに震度分布画像のアセットと地域マップがコピーされるように
- Fix: 返信とメンションの返答が記録されない問題
- Fix: 震度分布画像が投稿されない問題
- Fix: 条件に合わなくても震度分布画像を生成する問題
- Fix: 震度分布画像に隙間が空く問題
- Del: 正確にスケジュールが動作する機能
# 2026.5.2
- Fix: `/weather`が利用できない問題
# 2026.5.1
- Feat: 地震の震度分布画像生成
- Feat: 震度分布画に全タイルが埋まる機能
- Feat: 震度分布画像の震源がタイル単位で中央になる機能
- Feat: 震度分布画像で欠けているタイルが描画される機能
- Feat: 震度分布画像のアセットの位置が中央基準になる機能
- Chg: 全てのスケジュールが正確に動作するように
- Chg: 毎日18:00に明日の天気予報を投稿するように
- Chg: 震度分布画像のズームレベルを8から9へ
- Chg: 震度分布画像の生成条件を震源と位置が存在するへ変更
- Chg: 震度分布画像のアセットを並列に取得
- Chg: 震度分布画像の震源のアセットに縁取りを追加
- Chg: 震度分布画像の震度ではないアセットを拡大
- Chg: ユーズの分割の案内文を太字に
- Fix: 地震情報の画像生成での並び替えで、震度の位置が震源の位置として扱われる問題
- Fix: 毎日7:00に天気予報の返信をできない問題
- Del: 最大震度が不明な場合に投稿するかどうかのconfigを削除
# 2026.5.0
- Feat(dev): 地震情報の画像生成機能は未完成のため、ユーズには適用されません
- Chg: 地震情報でのタイトルを変更
- Fix: 毎日7:00に天気予報が投稿されない問題
# 2026.4.3
- Chg: forループ内でlastReadを記録するように
- Chg: lastReadを時刻ベースに
# 2026.4.2
- Fix: id除外が機能しない問題
# 2026.4.1
- Chg: notificationsもfor内でlastReadを更新しないように
# 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
@@ -1,13 +0,0 @@
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
http://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.
+33 -29
View File
@@ -1,31 +1,35 @@
# uwuzuお知らせBOT
# noticeUwuzu
[uwuzu](https://www.uwuzu.com)向けのお知らせBotです。
uwuzu v1.6.7以上で利用可能です。
# uwuzuお知らせBOTについて
## 機能
- 時報
毎時0分に「h:00になりました。」と投稿します。
4:00は某画像で投稿します。
- 天気予報
[天気予報 APIlivedoor 天気互換)](https://weather.tsukumijima.net/)を利用して、毎日7:00及び18:00に天気予報を投稿します。
18: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/)に変更します。
そのため、最新の情報は配信されません。
- 地震情報のid除外を無効化
地震情報のid除外を記録も確認も行わなくなります。
- i18nextのdebugを有効化
[i18nextでのdebug](https://www.i18next.com/overview/configuration-options#logging)が有効化されます。
uwuzuで動作するお知らせBOTです。
# 設定
examples/config.tsをプロジェクトルートへ移動し各設定を更新してください。
## 設定項目
earthquake.reconnectTimes:地震情報のWebSocketが切断されたときに自動再接続する時間(ミリ秒)
earthquake.websocketUrl:地震情報のWebSocket接続先URL
earthquake.areasCsvUrl:地域情報のデータベース(CSV)ファイルのURL
weather.splitCount:天気お知らせの返信の分割数(4分割を推奨)
apiTokenBOTアカウントのAPIキー
uwuzuServer:使用するuwuzuサーバーのホスト名(uwuzu.netなど)
# サーバー起動
```
npm install
npm run build
npm run start
```
※Node.js・npmがインストールされている必要があります。
# ライセンス
Apache 2.0 License
## 260420.jsonについて
このデータは2026年4月20日に三陸で発生した地震の後21件を記録したものです。
P2P地震情報APIのhistoryで取得しました。
検証などにご利用ください。
+5
View File
@@ -0,0 +1,5 @@
# # ###### ##### ### ###### ####### # # # # # # ###### # #
## # # # # # # # # # # # # # # # # #
# ## # # # # # # ####### # # # # # # # # ## # #
# ## # # # # # # # # # # # # # # #
# # ###### # ### ###### ####### ###### # # ###### ###### ######
+41
View File
@@ -0,0 +1,41 @@
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
# デバッグ用途のみで使用してください。
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
-17
View File
@@ -1,17 +0,0 @@
import type { configTypes } from "types/config";
const config: configTypes = {
earthquake: {
reconnectTimes: 5000,
websocketUrl: "wss://api.p2pquake.net/v2/ws",
areasCsvUrl: "https://raw.githubusercontent.com/p2pquake/epsp-specifications/master/epsp-area.csv",
},
weather: {
splitCount: 4,
},
apiToken: "TOKEN_EXAMPLE",
uwuzuServer: "uwuzu.example.com",
};
export default config;
+73
View File
@@ -0,0 +1,73 @@
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`を使用したユーザーが一致しません。
-29
View File
@@ -1,29 +0,0 @@
// 定期実行読み込み
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 followBack from "./scripts/followBack.js";
// 地震情報観測開始
earthquakeNotice();
// 時報・フォローバック(毎時)
cron.schedule("0 * * * *", () => {
timeNotice();
followBack();
});
// 天気お知らせ(毎日7:01)
cron.schedule("1 7 * * *", () => {
setTimeout(() => {
weatherNotice();
}, 100);
});
// コンソールで表示
console.log("サーバーが起動しました");
+24 -29
View File
@@ -1,40 +1,35 @@
{
"name": "noticeuwuzu",
"version": "v3.5.1@uwuzu1.5.4",
"description": "uwuzu Notice Bot",
"main": "dist/main.js",
"name": "notice-uwuzu",
"version": "2026.5.8",
"type": "module",
"main": "dist/index.js",
"scripts": {
"start": "node .",
"build": "tsc",
"dev": "tsx main.ts"
"build": "tsc && tsc-alias && cpy \"{**/*.json,**/*.png}\" ../dist --cwd=src && cpy \"260420.json\" dist"
},
"keywords": [
"uwuzu",
"bot",
"cron",
"time",
"timenotice",
"notice"
],
"author": {
"name": "Last2014",
"url": "https://last2014.com",
"email": "info@last2014.com"
"email": "info@last2014.com",
"url": "https://about.last2014.com"
},
"license": "ISC",
"type": "module",
"packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d",
"dependencies": {
"@types/date-fns": "^2.5.3",
"@types/dotenv": "^6.1.1",
"@types/node-cron": "^3.0.11",
"@types/node": "^25.5.2",
"@types/ws": "^8.18.1",
"better-uwuzu-sdk": "git+https://gitea.last2014.com/last2014/better-uwuzu-sdk.git#1.1.7",
"cpy-cli": "^7.0.0",
"date-fns": "^4.1.0",
"node-cron": "^4.1.1",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"ws": "^8.18.3"
},
"devDependencies": {
"@types/node": "^24.0.7",
"@types/ws": "^8.18.1"
"fs": "0.0.1-security",
"i18next": "^26.0.3",
"miq": "git+https://gitea.last2014.com/last2014/miq.git#1.0.1",
"node-cron": "^4.2.1",
"os": "^0.1.2",
"sharp": "^0.34.5",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3",
"util": "^0.12.5",
"ws": "^8.20.0",
"yaml": "^2.8.3",
"zod": "^4.3.6"
}
}
+1420
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
canvas: true
sharp: true
-342
View File
@@ -1,342 +0,0 @@
import WebSocket from "ws";
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("P2P地震情報に接続中");
try {
this.ws = new WebSocket(config.earthquake.websocketUrl);
this.ws.on("open", () => {
console.log("P2P地震情報に接続しました");
this.isConnecting = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
});
this.ws.on("message", (data: WebSocket) => {
try {
const message = JSON.parse(data.toString());
this.handleMessage(message);
} catch (error) {
console.error("メッセージのパースに失敗:", error);
}
});
this.ws.on("close", (code: number, reason: Buffer) => {
console.log(`接続が閉じられました: ${code} - ${reason.toString()}`);
this.isConnecting = false;
this.scheduleReconnect();
});
this.ws.on("error", (error: Error) => {
console.error("WebSocketエラー:", error);
this.isConnecting = false;
this.scheduleReconnect();
});
} catch (error) {
console.error("接続エラー:", error);
this.isConnecting = false;
this.scheduleReconnect();
}
}
private handleMessage(message: any): void {
switch (message.code) {
case 551: // 地震情報
console.log("地震情報を受信しました");
this.executeEventFunc(message);
break;
case 554: // 緊急地震速報
console.log("緊急地震速報を受信しました");
this.executeEventFunc(message);
break;
case 555: // 震度情報
console.log("震度情報を受信しました");
this.executeEventFunc(message);
break;
default:
console.log(`その他の情報 (コード: ${message.code})`);
break;
}
}
private executeEventFunc(earthquakeInfo: any): void {
event(earthquakeInfo);
}
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
console.log(`${this.reconnectInterval / 1000}秒後に再接続を試みます`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, this.reconnectInterval);
}
private setupCleanup(): void {
const cleanup = () => {
this.stop();
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
}
public stop(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}
// 地名オブジェクトマッピング
async function areaMap(): Promise<Record<number, string>> {
const res = await fetch(config.earthquake.areasCsvUrl);
const text = await res.text();
const lines = text.split("\n");
const map: Record<number, string> = {};
for (const line of lines) {
const cols = line.split(",");
if (cols.length >= 3 && /^\d+$/.test(cols[0])) {
const id = Number(cols[0]);
const name = cols[2].trim();
map[id] = name;
}
}
return map;
}
// 情報受信
async function event(earthquakeInfo: any): Promise<void> {
console.log(JSON.stringify(earthquakeInfo));
// ----処理----
// 緊急地震速報の場合
if (earthquakeInfo.code === 554) {
// 地震詳細
let descriptionEarthquake: string = "";
if (earthquakeInfo.earthquake.description !== "") {
descriptionEarthquake = `この地震について:${earthquakeInfo.earthquake.description}`;
}
// 発令詳細
let description: string = "";
if (earthquakeInfo.comments.freeFormComment !== "") {
description = `この発令について:${earthquakeInfo.comments.freeFormComment}`;
}
// テスト・訓練
let test: string = "";
if (earthquakeInfo.test) {
test = "これはテスト、あるいは訓練です";
} else if (earthquakeInfo.test === false) {
test = "これはテスト・訓練ではありません";
} else {
test = "この情報にテスト・訓練かの情報はありません";
}
// 対象地域
let areas: string = "";
if (earthquakeInfo.areas !== null) {
const areaNames: Array<string> = Array.from(
new Set(
earthquakeInfo.areas.map((i: any) => {
earthquakeInfo.areas[i].name.filter(Boolean);
}),
),
);
areas = `対象地域:${areaNames.join("・")}`;
}
// 速報取り消し
let cancelled: string = "";
if (earthquakeInfo.cancelled) {
cancelled = "※以下の緊急地震速報が取り消されました※";
}
// マグニチュード
let magnitude: string = "マグニチュード:";
if (
earthquakeInfo.earthquake.hypocenter.magnitude !== -1 ||
earthquakeInfo.earthquake.hypocenter.magnitude === null
) {
magnitude += "マグニチュードの情報はありません";
} else {
magnitude += `M${String(earthquakeInfo.earthquake.hypocenter.magnitude)}`;
}
ueuse(`
==地震情報==
【緊急地震速報】
${cancelled}
時刻:${earthquakeInfo.time}
${descriptionEarthquake}
${description}
${test}
${areas}
`);
}
// 地震情報
else if (earthquakeInfo.code === 551) {
// 国内津波
let domesticTsunami;
if (earthquakeInfo.earthquake.domesticTsunami === "None") {
domesticTsunami = "この地震による国内の津波の心配はありません";
} else if (earthquakeInfo.earthquake.domesticTsunami === "Unknown") {
domesticTsunami = "この地震による国内の津波情報はありません";
} else if (earthquakeInfo.earthquake.domesticTsunami === "Checking") {
domesticTsunami = "この地震による国内の津波情報を調査中です";
} else if (earthquakeInfo.earthquake.domesticTsunami === "NonEffective") {
domesticTsunami =
"この地震による国内の津波影響は若干の海面変動が予想されますが被害の心配はありません";
} else if (earthquakeInfo.earthquake.domesticTsunami === "Watch") {
domesticTsunami = "この地震により国内で津波注意報が発令しています";
} else if (earthquakeInfo.earthquake.domesticTsunami === "Warning") {
domesticTsunami = "この地震による国内の津波予報があります";
} else {
domesticTsunami = "この地震による国内の津波情報はありません";
}
// 最大震度
let maxScale: string = "最大深度:";
if (
earthquakeInfo.earthquake.maxScale === -1 ||
earthquakeInfo.earthquake.maxScale === null
) {
maxScale = "最大震度情報なし";
} else if (earthquakeInfo.earthquake.maxScale === 10) {
maxScale += "震度1";
} else if (earthquakeInfo.earthquake.maxScale === 20) {
maxScale += "震度2";
} else if (earthquakeInfo.earthquake.maxScale === 30) {
maxScale += "震度3";
} else if (earthquakeInfo.earthquake.maxScale === 40) {
maxScale += "震度4";
} else if (earthquakeInfo.earthquake.maxScale === 45) {
maxScale += "震度5弱";
} else if (earthquakeInfo.earthquake.maxScale === 50) {
maxScale += "震度5強";
} else if (earthquakeInfo.earthquake.maxScale === 55) {
maxScale += "震度6弱";
} else if (earthquakeInfo.earthquake.maxScale === 60) {
maxScale += "震度6強";
} else if (earthquakeInfo.earthquake.maxScale === 70) {
maxScale += "震度7";
}
// 対象地域
let areas: string = "";
if (earthquakeInfo.points !== null) {
const areaNames: Array<string> = Array.from(
new Set(earthquakeInfo.points.map((i: any) => i.addr).filter(Boolean)),
);
areas = `対象地域:${areaNames.join("・")}`;
}
// 詳細
let description: string = "";
if (earthquakeInfo.comments.freeFormComment !== "") {
description = `この地震について:${earthquakeInfo.comments.freeFormComment}`;
}
ueuse(`
==地震情報==
【地震発生】
時刻:${earthquakeInfo.time}
${description}
${maxScale}
${areas}
国内の津波:${domesticTsunami}
`);
}
// 地域情報更新の場合
else if (earthquakeInfo.code === 555) {
// 対象地域マッピング
const areaMaps: any = await areaMap();
const areaNames: Array<string> = Array.from(
new Set(
earthquakeInfo.areas.map((i: any) => {
areaMaps[i.id].filter(Boolean);
}),
),
);
const areas = areaNames.join("・");
ueuse(`
==地震情報==
【地域情報更新】
時刻:${earthquakeInfo.time}
対象地域:${areas}
`);
}
}
async function ueuse(text: string) {
const res = await fetch(`https://${config.uwuzuServer}/api/ueuse/create`, {
method: "POST",
body: JSON.stringify({
token: config.apiToken,
text: text,
}),
});
const resData = await res.json();
console.log(JSON.stringify(resData));
}
export default function earthquakeNotice(): void {
console.log("地震情報サーバーに接続します");
const client = new P2PEarthquakeClient();
client.start();
}
-40
View File
@@ -1,40 +0,0 @@
import type * as types from "types/types";
import config from "../config.js";
export default async function followBack() {
// フォロワーを取得
const resMe = await fetch(
`https://${config.uwuzuServer}/api/me?token=${config.apiToken}`,
{
method: "GET",
// uwuzu.netで/api/meのPOSTが死んでいるため簡易的にGET
},
);
const meData: types.meApi = await resMe.json();
console.log(meData);
const followers: Array<string> = meData.follower;
// フォロー
for (let i = 0; i < followers.length; i++) {
const followerItem = followers[i];
const resFollow = await fetch(
`https://${config.uwuzuServer}/api/users/follow`,
{
method: "POST",
body: JSON.stringify({
token: config.apiToken,
userid: followerItem,
}),
},
);
const followData: types.followApi = await resFollow.json();
console.log(JSON.stringify(followData));
}
}
-26
View File
@@ -1,26 +0,0 @@
import { format } from "date-fns";
import type * as types from "types/types";
import config from "../config.js";
export default async function timeNotice() {
// 現在時刻を取得
const now = format(new Date(), "HH:mm");
// 投稿
const resUeuse = await fetch(
`https://${config.uwuzuServer}/api/ueuse/create`,
{
method: "POST",
body: JSON.stringify({
token: config.apiToken,
text: `${now}になりました`,
}),
},
);
const ueuseData: types.ueuseCreateApi = await resUeuse.json();
console.log(JSON.stringify(ueuseData));
}
-119
View File
@@ -1,119 +0,0 @@
import { cityList } from "../src/weatherId.js";
import type * as types from "types/types.js";
import config from "../config.js";
export default async function weatherNotice() {
// 仮投稿
const resUeuse = await fetch(
`https://${config.uwuzuServer}/api/ueuse/create`,
{
method: "POST",
body: JSON.stringify({
token: config.apiToken,
text: `
本日の天気
※タイムラインが埋まるため返信に記載しています
`,
}),
},
);
const ueuseData: types.ueuseCreateApi = await resUeuse.json();
console.log(JSON.stringify(ueuseData));
// インデックス
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(
`https://${config.uwuzuServer}/api/ueuse/create`,
{
method: "POST",
body: JSON.stringify({
token: config.apiToken,
text: weatherResults[i],
replyid: ueuseData.uniqid
}),
},
);
const replyData: types.ueuseCreateApi = await resReply.json();
console.log(JSON.stringify(replyData));
}
}
+21
View File
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,47 @@
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,
}, "コマンド概要");
}
+154
View File
@@ -0,0 +1,154 @@
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 mem = Memory.memory;
let newLastReadReply = mem.lastReadReply;
let newLastReadMention = mem.lastReadMention;
{
const response = await client.request("me/notification/", {
page: 1,
limit: 20,
});
if (response.success) {
const notifications = response.data.filter(n => n.category === "reply" && typeof n.valueid === "string");
for (const [index, notification] of notifications.entries()) {
const ueuseResponse = await client.request("ueuse/get", { uniqid: notification.valueid });
if (!ueuseResponse.success || !ueuseResponse.data[0]) {
console.warn("返信通知からユーズを参照できないため、スキップします");
continue;
}
const ueuseData = ueuseResponse.data[0];
const time = new Date(ueuseData.datetime).getTime();
if (mem.lastReadReply >= time)
break;
if (index === 0)
newLastReadReply = time;
ueuses.push(ueuseData);
}
} else {
console.warn("返信通知の取得に失敗しましたが、続行します");
}
}
{
const response = await client.request("ueuse/mentions", {
page: 1,
limit: 20,
});
if (response.success) {
const mentions = response.data;
for (const [index, mention] of mentions.entries()) {
const time = new Date(mention.datetime).getTime();
if (mem.lastReadMention >= time)
break;
if (index === 0)
newLastReadMention = time;
ueuses.push(mention);
}
} else {
console.warn("メンションの取得に失敗しましたが、続行します");
}
}
mem.lastReadReply = newLastReadReply;
mem.lastReadMention = newLastReadMention;
Memory.memory = mem;
const seenIds = new Set();
ueuses = ueuses.filter(ueuse => {
if (seenIds.has(ueuse.uniqid))
return false;
seenIds.add(ueuse.uniqid);
return true;
});
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
@@ -0,0 +1,302 @@
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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,6 @@
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, false);
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 B

@@ -0,0 +1,533 @@
import { readFileSync, writeFileSync } from "node:fs";
import sharp from "sharp";
/**
* 経度からX方向の座標情報を計算します
*
* 指定された経度とズームレベルに基づき、以下の4つの値を返します:
* - 世界座標系でのX(0〜1の範囲、グリニッジ子午線が0)
* - ズームレベルを考慮したピクセル単位のグローバルX座標
* - タイルグリッドにおけるX方向のタイル番号(tileNumberX
* - タイル内(256×256ピクセル)でのX方向ピクセル位置(tileInnerX
* @param lat - 経度
* @param z - ズームレベル(0以上の整数)
* @returns {{
* worldCoordX: number, // 世界座標(01, グリニッジ子午線が原点(0))
* globalPixelX: number, // ズームレベルを考慮したピクセル単位のY座標
* tileNumberX: number, // 該当するタイル番号(0オリジン)
* tileInnerX: number, // タイル内のピクセルY座標(0〜255)
* }}
* @copyright murasuke on Qiita - https://qiita.com/murasuke/items/ad81b7b726a3463fa3fe#%E7%B5%8C%E5%BA%A6
*/
function calcLngToTileX(lng: number, z: number) {
// 経度(-180〜180)を世界座標系のX(0〜1)に変換
const worldCoordX = (lng + 180) / 360;
// ズームレベル0(256×256px)の座標の位置を計算
let globalPixelX = worldCoordX * 256;
// ズームレベル(2^z)を考慮した座標を計算
globalPixelX = globalPixelX * Math.pow(2, z);
// 1つの画像タイルが256pxなので、256で割って左端からのタイルの枚数(位置)を求める
const tileNumberX = Math.floor(globalPixelX / 256);
// 該当タイル内のピクセル位置を算出する(タイル幅合計を引いた余り)
const tileInnerX = Math.floor(globalPixelX - tileNumberX * 256);
// 計算した値を返す
return {
worldCoordX, // 世界座標(0~1, グリニッジ子午線が原点(0))
globalPixelX, // ズームレベルを考慮したピクセル単位のY座標
tileNumberX, // 該当するタイル番号(0オリジン)
tileInnerX, // タイル内のピクセルY座標(0〜255)
};
}
/**
* 緯度からWebメルカトル投影によるY方向の座標情報を計算します。
*
* 指定された緯度とズームレベルに基づき、以下の4つの値を返します:
* - 世界座標系でのY(0〜1の範囲、北極が0)
* - ズームレベルを考慮したピクセル単位のグローバルY座標
* - タイルグリッドにおけるY方向のタイル番号(tileNumberY
* - タイル内(256×256ピクセル)でのY方向ピクセル位置(tileInnerY
*
* メルカトル図法で緯度から位置を算出する式 (https://qiita.com/Seo-4d696b75/items/aa6adfbfba404fcd65aa)
* R ln(tan(π/4 + ϕ/2))
* R: 半径
* ϕ: 緯度(ラジアン)
* @param lat - 緯度(-85〜+85度の範囲が推奨)
* @param z - ズームレベル(0以上の整数)
* @returns {{
* worldCoordY: number, // 世界座標系Y0〜1
* globalPixelY: number, // ズームレベルを考慮したピクセル単位のY座標
* tileNumberY: number, // Y方向のタイル番号(0オリジン)
* tileInnerY: number // タイル内のピクセルY座標(0〜255)
* }}
* @copyright murasuke on Qiita - https://qiita.com/murasuke/items/ad81b7b726a3463fa3fe#%E7%B7%AF%E5%BA%A6
*/
function calcLatToTileY(lat: number, z: number) {
// 緯度からメルカトル図法の座標に変換して、世界座標系のY(0~1)を計算
let worldCoordY = latToWorldY(lat);
// ズームレベル0(256×256px)の座標の位置を計算
let globalPixelY = worldCoordY * 256;
// ズームレベル(2^z)を考慮した座標を計算
globalPixelY = globalPixelY * Math.pow(2, z);
// 1つの画像タイルが256pxなので、256で割って上端からのタイルの枚数(位置)を求める
const tileNumberY = Math.floor(globalPixelY / 256);
// 該当タイル内のピクセル位置を算出する(タイル幅合計を引いた余り)
const tileInnerY = Math.floor(globalPixelY - tileNumberY * 256);
// 計算した値を返す
return {
worldCoordY, // 世界座標(0~1, 北極側が原点(0))
globalPixelY, // ズームレベルを考慮したピクセル単位のY座標
tileNumberY, // 該当するタイル番号(0オリジン)
tileInnerY, // タイル内のピクセルY座標(0〜255)
};
}
/**
* メルカトル図法で投影した位置を元に世界座標系でのY(0〜1、北極が0)を計算する
*
* メルカトル図法の式(半径1の球体を想定)
* ϕ = (π / 180) * 緯度
* y = ln(tan(π/4 + ϕ/2))
* ϕ:緯度(ラジアン)
* y:投影した位置
*
* * 赤道を中心(0)にして、約-85〜+85度の範囲で-π~πの値になる
* * 高緯度では値が急増し無限大になる
* * y = ln((tan(ϕ) + 1) / cos(ϕ)) という式もある(同じ値になる)
*
* 地図で位置を計算するため、北極側が原点(0)で南極側が1となるように正規化した値を返す
*
* @param lat 緯度(-8585)
* @returns {number} 世界座標系でのY0〜1、北極が0)
* @copyright murasuke on Qiita - https://qiita.com/murasuke/items/ad81b7b726a3463fa3fe#%E7%B7%AF%E5%BA%A6
*/
function latToWorldY(lat: number) {
// 経度をラジアンに変換
const latRad = (Math.PI / 180) * lat;
// 緯度からメルカトル図法の座標に変換する
const mercatorY = Math.log(Math.tan(Math.PI / 4 + latRad / 2));
// -1~1 の範囲になるように調整(-85度~85度の範囲)
let worldCoordY = mercatorY / Math.PI;
// 原点(0)を赤道から、北極へ変換(0~1)
worldCoordY = 1 - (worldCoordY + 1) / 2;
return worldCoordY;
}
function getEdge<T extends any[]>(arr: T, property: keyof T[number]) {
if (arr.length === 0) {
return {
most: undefined,
least: undefined,
}
}
const counts = new Map<any, number>();
for (const item of arr) {
const val = item[property];
counts.set(val, (counts.get(val) || 0) + 1);
}
const uniqueValues = Array.from(counts.keys());
const minVal = uniqueValues.reduce((a, b) => (a < b ? a : b));
const maxVal = uniqueValues.reduce((a, b) => (a > b ? a : b));
return {
most: maxVal,
least: minVal,
}
}
export default async function generateImage(message: any) {
if (
message.earthquake.hypocenter === undefined ||
message.earthquake.hypocenter?.name === "" ||
!(Array.isArray(message.points)) ||
message.points.length === 0
)
return "input_lack";
const ZOOM_LEVEL = 9;
// タイル・地点取得
const tiles: {
tileX: number;
tileY: number;
}[] = [];
const positions: ({
tileIndex: number;
innerX: number;
innerY: number;
} & ({
type: "epicenter";
} | {
type: "point";
scale: number;
}))[] = [];
if (
message.earthquake.hypocenter?.latitude !== undefined &&
message.earthquake.hypocenter?.latitude !== -200 &&
message.earthquake.hypocenter?.longitude !== undefined &&
message.earthquake.hypocenter?.longitude !== -200
) {
// 震源地
const lng = message.earthquake.hypocenter.longitude;
const lat = message.earthquake.hypocenter.latitude;
const xData = calcLngToTileX(lng, ZOOM_LEVEL);
const yData = calcLatToTileY(lat, ZOOM_LEVEL);
tiles.push({
tileX: xData.tileNumberX,
tileY: yData.tileNumberY,
});
positions.push({
tileIndex: 0,
innerX: xData.tileInnerX,
innerY: yData.tileInnerY,
type: "epicenter",
});
}
if (
Array.isArray(message.points) &&
message.points.length > 0
) {
// 観測点・区域
const areas = JSON.parse(readFileSync(`${import.meta.dirname}/areas.json`, "utf-8"));
const points = JSON.parse(readFileSync(`${import.meta.dirname}/points.json`, "utf-8"));
message.points.forEach((point: any) => {
let areaData: {
longitude: number;
latitude: number;
}
if (point.isArea) {
areaData = areas[point.addr];
} else {
areaData = points[point.addr];
}
const lng = areaData.longitude;
const lat = areaData.latitude;
const xData = calcLngToTileX(lng, ZOOM_LEVEL);
const yData = calcLatToTileY(lat, ZOOM_LEVEL);
const tilePosition = {
tileX: xData.tileNumberX,
tileY: yData.tileNumberY,
}
let tileIndex: number;
const sameTile = tiles
.map((value, index) => ({ value, index }))
.filter(tile => JSON.stringify(tile.value) === JSON.stringify(tilePosition))[0];
if (sameTile) {
tileIndex = sameTile.index;
} else {
tiles.push(tilePosition);
tileIndex = tiles.length - 1;
}
if (tiles.filter(tile => JSON.stringify(tile) === JSON.stringify(tilePosition)).length === 0) {
tiles.push(tilePosition);
}
positions.push({
tileIndex,
innerX: xData.tileInnerX,
innerY: yData.tileInnerY,
type: "point",
scale: point.scale,
});
});
}
// 画像生成
const WIDTH = 1920;
const HEIGHT = 1080;
let result = sharp({
create: {
width: WIDTH,
height: HEIGHT,
channels: 4,
background: "#ffffff",
}
});
let compounds: (sharp.OverlayOptions & ({
type: "tile" | "epicenter" | "other";
} | {
type: "scale";
scale: number;
}))[] = [];
// タイルの幅、最大最小
let tileXCount = getEdge(tiles, "tileX");
let tileYCount = getEdge(tiles, "tileY");
let xSize = tileXCount.most - tileXCount.least + 1;
let ySize = tileYCount.most - tileYCount.least + 1;
// タイルサイズ仮計算
let tileSize: number;
xSize = tileXCount.most - tileXCount.least + 1;
ySize = tileYCount.most - tileYCount.least + 1;
if (xSize > ySize) {
tileSize = Math.round(WIDTH / xSize);
} else {
tileSize = Math.round(HEIGHT / ySize);
}
// 震源を中心とする
const epicenterPosition = positions.filter(position => position.type === "epicenter")[0];
if (!epicenterPosition)
return "input_lack";
const epicenterTile = tiles[epicenterPosition.tileIndex];
if (!epicenterTile)
return "input_lack";
const leftPx = (epicenterTile.tileX - tileXCount.least) * tileSize + epicenterPosition.innerX;
const rightPx = xSize * tileSize - leftPx;
const topPx = (epicenterTile.tileY - tileYCount.least) * tileSize + epicenterPosition.innerY;
const bottomPx = ySize * tileSize - topPx;
if (leftPx > rightPx) {
const leftTiles = epicenterTile.tileX - tileXCount.least;
tileXCount = {
...tileXCount,
most: epicenterTile.tileX + leftTiles,
}
} else {
const rightTiles = tileXCount.most - epicenterTile.tileX;
tileXCount = {
...tileXCount,
least: epicenterTile.tileX - rightTiles,
}
}
if (topPx > bottomPx) {
const topTiles = epicenterTile.tileY - tileYCount.least;
tileYCount = {
...tileYCount,
most: epicenterTile.tileY + topTiles,
}
} else {
const bottomTiles = tileYCount.most - epicenterTile.tileY;
tileYCount = {
...tileYCount,
least: epicenterTile.tileY - bottomTiles,
}
}
// タイルサイズ再計算
xSize = tileXCount.most - tileXCount.least + 1;
ySize = tileYCount.most - tileYCount.least + 1;
if (xSize > ySize) {
tileSize = Math.round(WIDTH / xSize);
} else {
tileSize = Math.round(HEIGHT / ySize);
}
// 全画面
if (WIDTH - xSize * tileSize > 5) {
const requireTilesX = Math.ceil(WIDTH / tileSize);
const halfTilesX = Math.ceil(requireTilesX / 2);
tileXCount = {
least: tileXCount.least - halfTilesX,
most: tileXCount.most + halfTilesX,
}
}
if (HEIGHT - ySize * tileSize > 5) {
const requireTilesY = Math.ceil(HEIGHT / tileSize);
const halfTilesY = Math.ceil(requireTilesY / 2);
tileYCount = {
least: tileYCount.least - halfTilesY,
most: tileYCount.most + halfTilesY,
}
}
// タイル幅再計算
xSize = tileXCount.most - tileXCount.least + 1;
ySize = tileYCount.most - tileYCount.least + 1;
// 欠けているタイルを取得
for (let xIndex = 0; xIndex < xSize; xIndex++) {
const itX = xIndex + tileXCount.least;
for (let yIndex = 0; yIndex < ySize; yIndex++) {
const itY = yIndex + tileYCount.least;
const itTile = tiles.filter(tile => tile.tileX === itX && tile.tileY === itY)[0];
if (itTile)
continue;
tiles.push({
tileX: itX,
tileY: itY,
});
}
}
// アセット
const assets: Record<string, {
buffer: Buffer<ArrayBuffer>;
size: number;
}> = {}
const assetsList = [
"scales/10",
"scales/20",
"scales/30",
"scales/40",
"scales/45",
"scales/46",
"scales/50",
"scales/55",
"scales/60",
"scales/70",
"epicenter",
];
await Promise.all(assetsList.map(async name => {
const key = name.split("/").at(-1);
if (!key)
return;
const asset = readFileSync(`${import.meta.dirname}/assets/${name}.png`);
const assetMeta = await sharp(asset).metadata();
const assetSize = assetMeta.width;
assets[key] = {
buffer: asset,
size: assetSize,
}
}));
// 各画像処理
await Promise.all(tiles.map(async (tile, index) => {
const TYPE = "blank";
const BASE_URL = "https://cyberjapandata.gsi.go.jp/xyz";
const request = await fetch(`${BASE_URL}/${TYPE}/${ZOOM_LEVEL}/${tile.tileX}/${tile.tileY}.png`);
if (!request.ok)
return;
const tileImg = await sharp(await request.arrayBuffer()).resize({
width: tileSize,
height: tileSize,
}).png().toBuffer();
const top = tileSize * (tile.tileY - tileYCount.least);
const left = tileSize * (tile.tileX - tileXCount.least);
compounds.push({
type: "tile",
input: tileImg,
top,
left,
});
const itTilePositions = positions.filter(position => position.tileIndex === index);
itTilePositions.forEach(position => {
const requireAssetName = position.type === "epicenter"
? "epicenter"
: String(position.scale);
const asset = assets[requireAssetName];
if (!asset)
return;
const tileResizedSize = tileSize / 256;
let about: any;
if (position.type === "epicenter") {
about = {
type: "epicenter",
}
} else {
about = {
type: "scale",
scale: position.scale,
}
}
compounds.push({
...about,
input: asset.buffer,
top: Math.round(top + position.innerY * tileResizedSize - asset.size / 2),
left: Math.round(left + position.innerX * tileResizedSize - asset.size / 2),
});
});
}));
// compounds並び替え
const TYPE_PRIORITY: Record<string, number> = {
tile: 1,
scale: 2,
epicenter: 3,
}
compounds = [...compounds].sort((a, b) => {
if (TYPE_PRIORITY[a.type] !== TYPE_PRIORITY[b.type]) {
return (TYPE_PRIORITY[a.type] ?? 99) - (TYPE_PRIORITY[b.type] ?? 99);
}
if (a.type === "scale" && b.type === "scale") {
return a.scale - b.scale;
}
return 0;
});
// 案内部分
const informationPath = `${import.meta.dirname}/assets/information.png`;
const informationBuffer = readFileSync(informationPath);
const informationMeta = await sharp(informationPath).metadata();
const informationMargin = 20;
compounds.push({
type: "other",
input: informationBuffer,
top: HEIGHT - informationMeta.height - informationMargin,
left: WIDTH - informationMeta.width - informationMargin,
});
// クレジット部分
const creditPath = `${import.meta.dirname}/assets/credit.png`;
const creditBuffer = readFileSync(creditPath);
const creditMeta = await sharp(creditPath).metadata();
compounds.push({
type: "other",
input: creditBuffer,
top: HEIGHT - creditMeta.height,
left: 0,
});
result.composite(compounds);
const buffer = await result.png().toBuffer();
return buffer;
}
File diff suppressed because one or more lines are too long
+360
View File
@@ -0,0 +1,360 @@
import { createUeuse } from "@/lib/client";
import config from "@/lib/config";
import Memory from "@/lib/memory";
import { format } from "date-fns";
import { readFileSync } from "node:fs";
import { EOL } from "node:os";
import { WebSocket } from "ws";
import generateImage from "@/feature/earthquake/generateImage";
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) {
const mem = Memory.memory;
mem.processedInfo = 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 < config.earthquake.requireMaxScale
) {
console.log(`最大震度(${scaleMessages[message.earthquake.maxScale]})が投稿に必要な値(${scaleMessages[config.earthquake.requireMaxScale]})に満たないため、スキップします`);
break;
}
const typeMessage: Record<string, string> = {
"ScalePrompt": "震度速報",
"Destination": "震源情報",
}
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();
const earthquakeNoticeText = [];
earthquakeNoticeText.push(`### ${typeMessage[message.issue.type] ?? "地震情報"}`);
earthquakeNoticeText.push(`⏰時刻: ${format(new Date(message.earthquake.time), "yyyy年M月d日 H:mm")}`);
earthquakeNoticeText.push(...(message.earthquake.maxScale === -1
? []
: [`🫨最大震度: ${scaleMessages[String(message.earthquake.maxScale)]}`]));
earthquakeNoticeText.push(...(message.earthquake.hypocenter.name === ""
? []
: [`📍震源地: ${message.earthquake.hypocenter.name}`]));
earthquakeNoticeText.push(...(message.earthquake.hypocenter.magnitude === -1
? []
: [`💪マグニチュード: M${message.earthquake.hypocenter.magnitude.toFixed(1)}`]));
earthquakeNoticeText.push(...(message.earthquake.hypocenter.depth === -1
? []
: [`🪨深さ: ${message.earthquake.hypocenter.depth === 0
? "ごく浅い"
: `${message.earthquake.hypocenter.depth}km`}`]));
earthquakeNoticeText.push(...(message.earthquake.domesticTsunami ?? "Unknown" === "Unknown"
? []
: [domesticTsunamiMessages[(message.earthquake.domesticTsunami)]]));
earthquakeNoticeText.push(...(message.earthquake.foreignTsunami ?? "Unknown" === "Unknown"
? []
: [foreignTsunamiMessages[(message.earthquake.foreignTsunami)]]));
earthquakeNoticeText.push(...(pointsMsg === ""
? []
: [EOL + pointsMsg]));
earthquakeNoticeText.push(...(message.comments.freeFormComment === ""
? []
: [message.comments.freeFormComment]));
earthquakeNoticeText.push(EOL + `🔬情報源: P2P地震速報 - ${message.issue.source ?? "不明"}`);
const earthquakeUeuses = await createUeuse({
text: earthquakeNoticeText.join(EOL),
}, "地震発生情報");
try {
const image = await generateImage(message);
if (typeof image === "string") {
throw "情報が不足しているため、地震の画像生成ができませんでした";
} else {
const infoUeuseURL = earthquakeUeuses[0]?.uniqid
? `${config.uwuzu.origin}/!${earthquakeUeuses[0].uniqid}`
: "不明";
const earthquakeImageText = [];
earthquakeImageText.push("地震の震度分布画像を生成しました。");
earthquakeImageText.push(`地震情報ユーズ: ${infoUeuseURL}`);
await createUeuse({
text: earthquakeImageText.join(EOL),
media: {
photo: [
image.toString("base64"),
],
},
}, "震度分布画像");
}
} catch (err) {
console.warn(err);
}
}
break;
case 552:
{
console.log("津波予報情報を受信しました");
if (message.cancelled) {
const tsunamiCancelledNoticeText = [];
tsunamiCancelledNoticeText.push("### 津波予報**解除**");
tsunamiCancelledNoticeText.push(`⏰発表時刻: ${format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss")}`);
tsunamiCancelledNoticeText.push(`🔬情報源: P2P地震速報 - ${message.issue.source ?? "不明"}`);
await createUeuse({
text: tsunamiCancelledNoticeText.join(EOL),
}, "津波予報解除情報");
break;
}
const gradeMessages: Record<string, string> = {
"Unknown": "不明",
"Watch": "津波注意報",
"Warning": "津波警報",
"MajorWarning": "大津波警報",
}
let areasMsg = "";
for (const area of message.areas) {
const tsunamiAreaText = [];
tsunamiAreaText.push(`${area.name}`);
tsunamiAreaText.push(...(area.immediate
? ["🚨***直ちに津波が来襲すると予想されています。***"]
: []));
tsunamiAreaText.push(`🏷️種別: ${gradeMessages[area.grade]}`);
tsunamiAreaText.push(`⏳第1波到達予想時刻: ${format(new Date(area.firstHeight.arrivalTime), "yyyy年M月d日 H:mm")}`);
tsunamiAreaText.push(`🌊第1波の状態: ${area.firstHeight.condition
? `${area.firstHeight.condition}されています`
: "不明"}`);
tsunamiAreaText.push(`🗼予想される津波の高さ: ${area.maxHeight.value === 0.2
? "0.2m未満"
: (area.maxHeight.value
? `${area.maxHeight.value}m`
: area.maxHeight.description)}`);
areasMsg += tsunamiAreaText.join(EOL) + EOL.repeat(2);
}
const tsunamiNoticeText = [];
tsunamiNoticeText.push("### 津波予報**発表**");
tsunamiNoticeText.push(`⏰発表時刻: ${format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss")}`);
tsunamiNoticeText.push(EOL + areasMsg.trim() + EOL);
tsunamiNoticeText.push(`🔬情報源: P2P地震速報 - message.issue.source ?? "不明"`);
await createUeuse({
text: tsunamiNoticeText.join(EOL),
}, "津波予報情報");
}
break;
case 556:
{
console.log("緊急地震速報(警報)を受信しました");
if (message.cancelled) {
const eewCancelledNotice = [];
eewCancelledNotice.push("### 緊急地震速報(警報)**解除**");
eewCancelledNotice.push(message.test
? "⚒️これは**テストです。**"
: "🚨これは**テストではありません。**");
eewCancelledNotice.push(`⏰発表時刻: ${format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss")}`);
eewCancelledNotice.push(EOL + "🔬情報源: P2P地震速報");
await createUeuse({
text: eewCancelledNotice.join(EOL),
}, "緊急地震速報(警報)解除情報");
}
const kindMessages: Record<string, string> = {
"10": "⏳主要動は、**未到達と予測**されています。",
"11": "🫨主要動が、**既に到達していると予測**されています。",
"19": "🧐PLUM法によると、主要動の***到達予想は***ありません。",
}
let areasMsg = "";
for (const area of message.areas) {
const eewAreaText = [];
eewAreaText.push(`${area.name}`);
eewAreaText.push(`🫨最大予測震度: ${scaleMessages[String(Math.floor(area.scaleFrom))] +
(area.scaleTo === 99
? "程度以上"
: area.scaleFrom !== area.scaleTo
? `から${scaleMessages[String(Math.floor(area.scaleTo))]}`
: "")}`);
eewAreaText.push(`⏰到達予想時刻: ${typeof area.arrivalTime === "string" && area.arrivalTime !== ""
? format(new Date(area.arrivalTime), "yyyy年M月d日 H:mm:ss")
: "不明"}`);
eewAreaText.push(kindMessages[area.kindCode] ?? "😕主要動の***到達予想は***ありません。");
areasMsg += eewAreaText.join(EOL) + EOL.repeat(2);
}
const eewNoticeText = [];
eewNoticeText.push("### ***緊急地震速報(警報)***");
eewNoticeText.push(message.test
? "⚒️これは**テストです。**"
: "🚨これは**テストではありません。**");
eewNoticeText.push(...(message.earthquake.condition === "仮定震源要素"
? ["❓これは、仮定震源要素です。そのため、震源に関する情報が保証できません。"]
: []));
eewNoticeText.push(`⏰発表時刻: ${format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss")}`);
eewNoticeText.push(`⏰地震発生時刻: ${format(new Date(message.earthquake.originTime), "yyyy年M月d日 H:mm:ss")}`);
eewNoticeText.push(`⏰地震発現時刻: ${format(new Date(message.earthquake.arrivalTime), "yyyy年M月d日 H:mm:ss")}`);
eewNoticeText.push(`📍震源地: ${message.earthquake.hypocenter.name ?? "不明"}`);
eewNoticeText.push(`💪マグニチュード: ${message.earthquake.hypocenter.magnitude === undefined ||
message.earthquake.hypocenter.magnitude === -1
? "不明"
: `M${message.earthquake.hypocenter.magnitude.toFixed(1)}`}`);
eewNoticeText.push(`🪨深さ: ${message.earthquake.hypocenter.depth === undefined ||
message.earthquake.hypocenter.depth === -1
? "不明"
: `${Math.floor(message.earthquake.hypocenter.depth)}km`}`);
eewNoticeText.push(...(areasMsg !== ""
? [EOL + areasMsg.trim()]
: []));
eewNoticeText.push(EOL + "🔬情報源: P2P地震速報");
await createUeuse({
text: eewNoticeText.join(EOL),
}, "緊急地震速報(警報)情報");
}
break;
default:
console.log("未対応の情報:", message);
break;
}
} catch (err) {
console.warn("メッセージの処理に失敗しました:", err);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { createUeuse } from "@/lib/client";
import { format } from "date-fns";
console.log("新年迎春の投稿を行います");
try {
await createUeuse({
text: `あけましておめでとうございます。今年は、${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);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+28
View File
@@ -0,0 +1,28 @@
import { createUeuse } from "@/lib/client";
import { format } from "date-fns";
import { readFileSync } from "node:fs";
console.log("時報の投稿を行います");
try {
if (new Date().getHours() !== 4) {
await createUeuse({
text: `${format(new Date(), "H:mm")}になりました。`,
}, "時報");
} else {
const asayoziImg = readFileSync(`${import.meta.dirname}/asayozi.png`);
await createUeuse({
text: "朝四時に何してるんだい?",
media: {
photo: [asayoziImg.toString("base64")],
},
}, "時報(午前4時)");
}
process.exit(0);
} catch (err: any) {
console.error("message" in err
? err.message
: err);
process.exit(1);
}
+198
View File
@@ -0,0 +1,198 @@
import client from "@/lib/client";
import config from "@/lib/config";
import Memory from "@/lib/memory";
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 &&
typeof workerData === "string" &&
workerData.startsWith("scheduledWeatherNotice")
) {
console.log("天気予報の投稿を行います");
try {
let provisionalUeuse;
let success = false;
for (let attempt = 1; attempt <= config.ueuse.maxRetries; attempt++) {
const provisionalText = [];
provisionalText.push(`${workerData.endsWith("Tomorrow")
? "明日"
: "今日"}の天気`);
provisionalText.push("※タイムラインが埋まるため返信に記載しています。");
provisionalUeuse = await client.request("ueuse/create", {
text: provisionalText.join(EOL),
});
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);
await weatherReply(provisionalUeuse.uniqid, workerData.endsWith("Tomorrow"));
process.exit(0);
} catch (err: any) {
console.error("message" in err
? err.message
: err);
process.exit(1);
}
}
export async function weatherReply(uniqid: string, isTomorrow: boolean) {
// インデックス
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 itDay = isTomorrow
? data.forecasts[1]
: data.forecasts[0];
// 天気
const areaText = [];
areaText.push(`${data.location.city}`);
areaText.push(`⛅天気: ${itDay.telop ?? "取得できませんでした"}`);
areaText.push(`🔆最高気温: ${itDay.temperature.max.celsius
? `${itDay.temperature.max.celsius}`
: "取得できませんでした"}`);
areaText.push(`🔅最低気温: ${itDay.temperature.min.celsius
? `${itDay.temperature.min.celsius}`
: "取得できませんでした"}`);
areaText.push(`☔降水確率: ${
itDay.chanceOfRain.T06_12 !== null &&
itDay.chanceOfRain.T06_12 !== "--%"
? itDay.chanceOfRain.T06_12
: "取得できませんでした"}`);
weatherResults[chunkIndex] += areaText.join(EOL) + 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);
}
}
+67
View File
@@ -0,0 +1,67 @@
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/earthquake/index.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/time/index.js`);
});
schedule("0 7 * * *", async () => {
new Worker(`${import.meta.dirname}/feature/weatherNotice.js`, {
workerData: "scheduledWeatherNotice",
});
});
schedule("0 18 * * *", async () => {
new Worker(`${import.meta.dirname}/feature/weatherNotice.js`, {
workerData: "scheduledWeatherNoticeTomorrow",
});
});
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);
}
+108
View File
@@ -0,0 +1,108 @@
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;
type ExtractSuccess<T> = T extends { success: true } ? T : never;
const results: ExtractSuccess<ApiMap["ueuse/create"]["response"]>[] = [];
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;
results.push(response);
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();
}
}
return results;
}
+55
View File
@@ -0,0 +1,55 @@
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),
]),
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
@@ -0,0 +1,28 @@
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,
},
});
};
+55
View File
@@ -0,0 +1,55 @@
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: 0,
lastReadReply: 0,
userid: "",
max_length: 0,
}));
}
}
get memory() {
return JSON.parse(readFileSync(path, "utf-8"));
}
set memory(data: any) {
writeFileSync(path, JSON.stringify(data), "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;
-49
View File
@@ -1,49 +0,0 @@
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",
];
+11 -11
View File
@@ -1,22 +1,22 @@
{
"compilerOptions": {
"target": "es2022",
"module": "ES2022",
"target": "ES2024",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"strict": true,
"skipLibCheck": true,
"baseUrl": "./",
"typeRoots": [
"./node_modules/@types",
"./types"
],
"baseUrl": "./src",
"paths": {
"ws": ["./node_modules/ws/index.js"],
"@types/ws": ["./node_modules/@types/ws/index.d.ts"]
}
}
"@/*": ["./*"],
},
"removeComments": true,
},
"tsc-alias": {
"resolveFullPaths": true,
},
}
-17
View File
@@ -1,17 +0,0 @@
interface earthquakeTypes {
reconnectTimes: number;
websocketUrl: string;
areasCsvUrl: string;
}
interface weatherTypes {
splitCount: number;
}
export interface configTypes {
earthquake: earthquakeTypes;
weather: weatherTypes;
apiToken: string;
uwuzuServer: string;
}
-33
View File
@@ -1,33 +0,0 @@
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;
followee_cnt: number;
follower: Array;
follower_cnt: number;
ueuse_cnt: number;
isBot: Boolean;
isAdmin: Boolean;
role: Role[];
language: String;
}
export interface ueuseCreateApi {
uniqid: string;
userid: string;
}
export interface followApi {
userid: string;
}
-68
View File
@@ -1,68 +0,0 @@
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;
}
}