Compare commits

...

1184 Commits

Author SHA1 Message Date
syuilo c35a6c01ff wip 2026-07-30 20:39:15 +09:00
syuilo f75c12fbd3 wip 2026-07-30 18:08:23 +09:00
syuilo 755a630468 Update engine.ts 2026-07-30 13:24:42 +09:00
syuilo c2bb96e180 wip 2026-07-30 13:24:31 +09:00
syuilo 64d721d24a wip 2026-07-30 12:58:06 +09:00
syuilo a716d5511a wip 2026-07-30 10:42:27 +09:00
syuilo cb62a1d4fe wip 2026-07-30 10:11:29 +09:00
syuilo 6ec6a5d523 Merge branch 'develop' into room 2026-07-30 09:38:45 +09:00
syuilo 3bbe40baec Update Firework.ts 2026-07-29 20:09:08 +09:00
syuilo 2d7e79f924 wip 2026-07-29 20:01:43 +09:00
syuilo b3c981d14a wip 2026-07-29 17:18:28 +09:00
かっこかり bcc3bc4fd7 fix(frontend): MkLightboxの動画の縦横比を事前に確定させるように (#17822)
* fix(frontend): MkLightboxの動画の縦横比を事前に確定させるように

* fix

* fix
2026-07-29 14:47:58 +09:00
syuilo 1b878998a0 wip 2026-07-29 14:20:58 +09:00
syuilo dc773a2709 wip 2026-07-29 12:48:57 +09:00
syuilo 9feab0b673 wip 2026-07-29 11:39:54 +09:00
おさむのひと 6f579b6854 chore: docker_example.ymlのsentryForBackend更新が漏れていたのを追記 (#17817) 2026-07-28 21:25:08 +09:00
syuilo c1d60fa5f5 up 2026-07-28 20:39:09 +09:00
syuilo d93aede642 wip 2026-07-28 19:45:15 +09:00
github-actions[bot] d600525bb2 Bump version to 2026.7.0-rc.0 2026-07-28 10:21:11 +00:00
おさむのひと 6dac6f7928 fix: OTel除去 (#17812) 2026-07-28 19:15:44 +09:00
かっこかり 318af2e1ff fix(frontend): follow-up of #17811 (#17814) 2026-07-28 17:35:33 +09:00
かっこかり a44e419d5a fix(frontend): follow-up of 2509a28 (#17815) 2026-07-28 17:35:18 +09:00
syuilo 3479b957e4 wip 2026-07-28 16:39:33 +09:00
syuilo 4682180c8b wip 2026-07-28 14:56:51 +09:00
かっこかり 2509a28813 Merge commit from fork 2026-07-28 11:11:56 +09:00
おさむのひと a12ee2e5d7 Merge commit from fork 2026-07-28 11:09:37 +09:00
syuilo 6dffa2f59a wip 2026-07-28 11:08:25 +09:00
syuilo 86762db14e wip 2026-07-28 10:47:40 +09:00
syuilo f53b50ee54 Update config.json 2026-07-27 21:55:29 +09:00
syuilo 58cb722bdf wip 2026-07-27 21:54:15 +09:00
syuilo ee2d16b237 Update lobby.ts 2026-07-27 20:44:13 +09:00
syuilo 319ddc674c wip 2026-07-27 20:07:24 +09:00
かっこかり 703aa2360b enhance(frontend): UploaderItemsの詳細メニューからもプレビューを起動できるように (#17811)
* enhance(frontend): UploaderItemsの詳細メニューからもプレビューを起動できるように

* clean up
2026-07-27 18:34:31 +09:00
かっこかり cb58ff32a4 fix(gh): check-misskey-js-autogenが失敗する問題を修正 (#17809)
* fix(gh): check-misskey-js-autogenが失敗する問題を修正

* fix comment

* fix
2026-07-27 16:48:36 +09:00
syuilo 9d4d04b6d2 Update lobby.ts 2026-07-27 16:18:46 +09:00
syuilo b9ca53785c wip 2026-07-27 16:00:11 +09:00
syuilo 83f3daccde Merge branch 'develop' into room 2026-07-27 12:58:26 +09:00
かっこかり f6979333d4 docs: Update CHANGELOG.md to include about js-yaml changes (#17805) 2026-07-27 00:14:34 +09:00
かっこかり e897255e71 fix 2026-07-25 21:43:41 +09:00
syuilo daf2a85539 Update CHANGELOG.md 2026-07-25 21:37:20 +09:00
chocolate-pie 1a59ec20e3 Merge commit from fork
* fix: Fix improper authorization in `admin/reset-password`

* Apply suggestions from code review

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>

* fix: improper authorization in `admin/unset-mfa`

* fix

---------

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
2026-07-25 21:34:46 +09:00
4ster1sk 3e31e59bcd fix(backend): フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように (#17802)
* test(e2e): フォロー中のチャンネルに再度フォローAPIを叩いた場合のテストを追加

* fix(backend): フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように

* docs(changelog): update changelog
2026-07-25 21:09:12 +09:00
anatawa12 982d4905c0 use UPSERT instead of select for hashtag statistics update (#17795)
* feat: set defaults for hashtag table

* chore: use upsert to update hashtag table

* fix: query runner not cleaned up

* fix: query runner not cleaned up correctly

* chore: replace await using => try-finally
2026-07-25 19:48:20 +09:00
renovate[bot] 7ecce0901c fix(deps): update dependency js-yaml to v5.2.2 [security] (#17797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 16:07:49 +09:00
syuilo 6fac907867 Update babylonRuntime.ts 2026-07-03 15:48:54 +09:00
syuilo 887f219119 up 2026-07-03 15:42:05 +09:00
syuilo b1a1717fdc Merge branch 'develop' into room 2026-07-03 15:40:38 +09:00
syuilo 1903607659 thumbs 2026-06-17 16:55:13 +09:00
syuilo 7d750668c0 thumbs 2026-06-17 16:51:17 +09:00
syuilo db6708bfbd Merge branch 'develop' into room 2026-06-17 16:42:41 +09:00
syuilo bb06278e97 wip 2026-06-15 20:13:43 +09:00
syuilo 3fd8becdc5 typo 2026-06-15 18:51:26 +09:00
syuilo de94c15c8f typo 2026-06-15 18:50:05 +09:00
syuilo 68719db32f typo 2026-06-15 18:49:26 +09:00
syuilo 1e4539a168 typo 2026-06-15 18:48:21 +09:00
syuilo ef6c5dfc95 typo 2026-06-15 18:47:33 +09:00
syuilo 4fc9e38e0d typo 2026-06-15 18:46:54 +09:00
syuilo 60f60b6bac Merge branch 'develop' into room 2026-06-15 18:42:48 +09:00
syuilo a7a7b8bc69 Update babylonRuntime.ts 2026-06-15 17:42:36 +09:00
syuilo 253dd052f8 refactoe 2026-06-15 16:22:57 +09:00
syuilo c1eb67a4ce typo 2026-06-15 16:18:20 +09:00
syuilo a26d78d573 stickyPlaneId 2026-06-15 16:16:33 +09:00
syuilo 9e33507f01 wip 2026-06-14 20:10:00 +09:00
syuilo bde03b8259 wip 2026-06-14 17:21:05 +09:00
syuilo 5690d28d4e Update engine.ts 2026-06-14 17:04:17 +09:00
syuilo 64a4283c35 Update engine.ts 2026-06-14 15:31:05 +09:00
syuilo 9de48030fc Update engine.ts 2026-06-14 15:28:38 +09:00
syuilo 1dbf9f9f85 wip 2026-06-14 13:35:21 +09:00
syuilo 37c3145859 shadow 2026-06-13 20:34:04 +09:00
syuilo 600dae3d68 wip 2026-06-13 15:59:23 +09:00
syuilo ab9d6947ae 🎨 2026-06-13 12:31:35 +09:00
syuilo 56da1e5084 🎨 2026-06-13 11:35:24 +09:00
syuilo 63d314750f Update room.core.vue 2026-06-12 18:43:02 +09:00
syuilo 1e10b67476 herbarium 2026-06-12 17:42:11 +09:00
syuilo cf2139d515 wip 2026-06-12 13:39:41 +09:00
syuilo 3d2941bd8f wip 2026-06-12 12:30:39 +09:00
syuilo c06eef6b58 username label 2026-06-11 21:27:32 +09:00
syuilo fa7a8b1282 up 2026-06-11 20:13:42 +09:00
syuilo 319826fe76 Merge branch 'develop' into room 2026-06-11 20:12:32 +09:00
syuilo 54cfa4a13e Update README.md 2026-06-11 18:35:54 +09:00
syuilo e507d41640 Update lavaLamp.ts 2026-06-11 18:24:58 +09:00
syuilo d9cac0fa56 🎨 2026-06-11 14:47:22 +09:00
syuilo 2e51cde16e 🎨 2026-06-11 11:58:43 +09:00
syuilo 98ac383b44 🎨 2026-06-11 11:25:32 +09:00
syuilo e1290584f2 kakejiku 2026-06-10 20:51:41 +09:00
syuilo 923d95d078 locale 2026-06-10 18:59:13 +09:00
syuilo 47a5f0cdd9 image rotation 2026-06-10 18:50:20 +09:00
syuilo 961d611cd4 bolt 2026-06-10 15:50:47 +09:00
syuilo 2bc10ccc34 lavaLamp 2026-06-10 13:58:20 +09:00
syuilo 38dae938c0 Update haniwa.ts 2026-06-09 20:15:02 +09:00
syuilo 1f2b635920 clean up 2026-06-09 19:51:03 +09:00
syuilo 39c6baf6f2 room light 2026-06-09 18:50:46 +09:00
syuilo 79541bcbe3 downlight 2026-06-09 15:29:26 +09:00
syuilo ba66b5d609 wip 2026-06-09 14:09:10 +09:00
syuilo a2c96c29c7 wip 2026-06-09 12:06:50 +09:00
syuilo 60db0a6bbd wip 2026-06-09 11:40:35 +09:00
syuilo 07f17820b3 Update simple.ts 2026-06-08 21:50:06 +09:00
syuilo 66cd678cd3 wip 2026-06-08 21:35:29 +09:00
syuilo 1f604db203 wip 2026-06-08 15:45:27 +09:00
syuilo 06def2995b throttle 2026-06-08 11:45:59 +09:00
syuilo c9107e5d2b Update MkWorldMonoOptionsForm.vue 2026-06-08 11:41:16 +09:00
syuilo cbac208262 Update README.md 2026-06-08 10:37:44 +09:00
syuilo b7c7323b97 Update README.md 2026-06-08 10:28:59 +09:00
syuilo 602d034e94 cardboard-box 2026-06-08 10:24:23 +09:00
syuilo 22f78ab175 haniwa 2026-06-07 20:46:01 +09:00
syuilo d59afb5a29 ceilingFan 2026-06-07 18:31:01 +09:00
syuilo cc8e6a2e92 update ceiling-fan-light 2026-06-07 16:59:09 +09:00
syuilo 004ef368c2 update ceiling-fan-light 2026-06-07 16:47:04 +09:00
syuilo ac06c1700a Update babylonRuntime.ts
add RegisterAnimation and RegisterAnimatable
2026-06-07 11:24:43 +09:00
syuilo 2d0fb674d5 wip 2026-06-06 17:29:08 +09:00
syuilo 61b8b0aac3 .js 2026-06-06 17:17:15 +09:00
syuilo bd6e443cec Update haniwa.ts 2026-06-06 17:00:20 +09:00
syuilo 897199a807 .js 2026-06-06 16:45:49 +09:00
syuilo 1f64579516 wip 2026-06-06 16:34:16 +09:00
syuilo 84203630a1 wip 2026-06-05 20:48:20 +09:00
syuilo 561e0c3813 Update room.add-furniture-dialog.vue 2026-06-04 22:50:17 +09:00
syuilo 169d655717 Update room.add-furniture-dialog.vue 2026-06-04 22:27:30 +09:00
syuilo ade652dd3e wallClock 2026-06-04 20:22:09 +09:00
syuilo 138353c84d up 2026-06-04 16:53:02 +09:00
syuilo 4b30bfeb3c wip 2026-06-04 16:22:31 +09:00
syuilo aae2039df2 Update default.blend 2026-06-04 02:28:39 +09:00
syuilo 850e156687 🎨 2026-06-04 02:28:30 +09:00
syuilo 844196cfea wip 2026-06-03 22:07:04 +09:00
syuilo 1925840f3f Merge branch 'develop' into room 2026-06-03 21:54:33 +09:00
syuilo d5df006463 Update babylonRuntime.ts 2026-06-03 21:46:22 +09:00
syuilo 2593652510 Update babylonRuntime.ts 2026-06-03 21:38:52 +09:00
syuilo 2e22b34c40 Create haniwa.blend 2026-06-03 21:01:51 +09:00
syuilo 8d3c3284b5 wip 2026-06-03 19:15:08 +09:00
syuilo 7d7a125299 wip 2026-06-03 18:46:18 +09:00
syuilo aac51a171c wip 2026-06-03 17:12:08 +09:00
syuilo 908e0022e9 wip 2026-06-03 15:14:25 +09:00
syuilo b56a2a8bf7 wip 2026-06-03 14:50:32 +09:00
syuilo 61de0fb279 wip 2026-06-03 14:17:23 +09:00
syuilo b43ff3d820 wip 2026-06-03 14:06:41 +09:00
syuilo 23b2f4c7a0 Update env.ts 2026-06-03 13:53:56 +09:00
syuilo 827895b548 wip 2026-06-03 13:00:53 +09:00
syuilo 8b0dd1ea68 wip 2026-06-03 12:03:54 +09:00
syuilo ec3517ba58 wip 2026-06-03 09:55:12 +09:00
syuilo c24515044e Update babylonRuntime.ts 2026-06-03 08:48:33 +09:00
syuilo f55adc19d3 registerBabylonRuntime 2026-06-03 08:42:07 +09:00
syuilo 32af098cd9 pure 2026-06-02 21:41:11 +09:00
syuilo cfea88dce3 Update defineFurnitureUi.ts 2026-06-02 20:31:27 +09:00
syuilo 3a97e26f80 Update engine.ts 2026-06-02 18:50:58 +09:00
syuilo 7bcb1b1732 wip 2026-06-02 18:00:39 +09:00
syuilo 8684cf14cc wip 2026-06-02 17:43:20 +09:00
syuilo 686a847697 wip 2026-06-02 17:06:10 +09:00
syuilo 0294ef74e4 wip 2026-06-02 14:53:23 +09:00
syuilo 42af31fbbd Update room.core.vue 2026-06-02 14:47:20 +09:00
syuilo b9ea42531a wip 2026-06-02 14:39:15 +09:00
syuilo 39c4618a35 Merge branch 'develop' into room 2026-06-02 14:12:58 +09:00
syuilo b025ce0ae6 wip 2026-06-02 10:50:24 +09:00
syuilo 07e660df01 Update MkWorldAvatarEditDialog.vue 2026-06-01 20:28:21 +09:00
syuilo e07eed4a4b wip 2026-06-01 18:33:50 +09:00
syuilo 19ad95cfa6 Update room.core.vue 2026-06-01 13:13:56 +09:00
syuilo 8f5f595073 Update room.core.vue 2026-06-01 13:13:32 +09:00
syuilo 6a536d5c92 refactor 2026-06-01 13:12:39 +09:00
syuilo c1e6e6a871 wip 2026-06-01 13:05:35 +09:00
syuilo 039bce590f wip 2026-06-01 12:44:46 +09:00
syuilo 24dac68475 Update MkWorldAvatarManager.vue 2026-06-01 11:21:23 +09:00
syuilo 8217a3a42c wip 2026-06-01 11:08:50 +09:00
syuilo dbcb856638 wip 2026-06-01 11:07:05 +09:00
syuilo dbbb197816 wip 2026-06-01 10:58:13 +09:00
syuilo 40263b0af5 wip 2026-06-01 09:42:07 +09:00
syuilo b978df8990 wip 2026-06-01 09:26:07 +09:00
syuilo 3a0da02b50 wip 2026-06-01 09:17:24 +09:00
syuilo af7d1a763a wip 2026-06-01 09:15:58 +09:00
syuilo a7e16c09dd up 2026-05-31 20:07:47 +09:00
syuilo 351872f47b Update engine.ts 2026-05-31 18:57:23 +09:00
syuilo e3c6c3025a wip 2026-05-31 18:52:54 +09:00
syuilo 273bc59cdb Update room.core.vue 2026-05-31 18:49:24 +09:00
syuilo 7a3e27d653 Update PlayerContainer.ts 2026-05-31 18:39:57 +09:00
syuilo 6a8aacfb95 wip 2026-05-31 17:07:39 +09:00
syuilo 99659a89b4 Update room.core.vue 2026-05-31 16:56:40 +09:00
syuilo 4591b940c3 Update room.core.vue 2026-05-31 16:52:23 +09:00
syuilo f01e9aa2ac wip 2026-05-31 16:50:33 +09:00
syuilo bba1605801 Update WorldRoomMultiplayService.ts 2026-05-31 16:48:06 +09:00
syuilo 6d6dbb2584 Update PlayerContainer.ts 2026-05-31 16:47:07 +09:00
syuilo 51a08bbf34 Update room.core.vue 2026-05-31 16:17:27 +09:00
syuilo 7fa8bcc9fd wip 2026-05-31 16:09:58 +09:00
syuilo 9d5818f7b5 wip 2026-05-31 15:24:55 +09:00
syuilo bb028fda00 wip 2026-05-31 14:56:40 +09:00
syuilo 56ae76e994 Update OverlayPanel.vue 2026-05-31 14:39:20 +09:00
syuilo 69bc12201e wip 2026-05-31 14:35:15 +09:00
syuilo 313e730fbb Update room.core.vue 2026-05-31 14:24:24 +09:00
syuilo c14986b43d Update room.core.vue 2026-05-31 14:22:59 +09:00
syuilo 69c75fc464 Update room.core.vue 2026-05-31 13:39:47 +09:00
syuilo 1b13c945ff wip 2026-05-31 13:37:40 +09:00
syuilo 6e763d4daa Update OverlayPanel.vue 2026-05-31 13:28:12 +09:00
syuilo d1e5b04a10 Update OverlayPanel.vue 2026-05-31 13:27:32 +09:00
syuilo 7fec78b70e wip 2026-05-31 13:22:54 +09:00
syuilo c962cd33b3 wip 2026-05-31 12:47:24 +09:00
syuilo 814bf357ca wip 2026-05-31 11:30:32 +09:00
syuilo 222d914544 Update room.core.vue 2026-05-31 11:29:22 +09:00
syuilo b81b9e1da7 wip 2026-05-31 11:16:56 +09:00
syuilo 52cb23da63 wip 2026-05-31 10:09:25 +09:00
syuilo 6b4c2c9141 Update room.core.vue 2026-05-31 10:02:09 +09:00
syuilo 7af9069962 wip 2026-05-31 09:58:26 +09:00
syuilo 8f9be8c735 wip 2026-05-31 09:40:20 +09:00
syuilo cdbf5c843f wip 2026-05-31 09:26:19 +09:00
syuilo 27fff648b6 Update README.md 2026-05-30 21:17:27 +09:00
syuilo 5cdc5f53b4 Update MkWorldMonoOptionsForm.vue 2026-05-30 20:45:26 +09:00
syuilo c67e231ff5 wip 2026-05-30 19:18:43 +09:00
syuilo f564b7a29b object -> furniture 2026-05-30 19:14:45 +09:00
syuilo 1eea31aacd object -> furniture 2026-05-30 19:00:43 +09:00
syuilo 5da458ca54 Update engine.ts 2026-05-30 18:32:39 +09:00
syuilo c343ccbdff wip 2026-05-30 18:25:16 +09:00
syuilo f8a981a1fc wip 2026-05-30 18:20:04 +09:00
syuilo 72df3d29ce wip 2026-05-29 10:57:17 +09:00
syuilo 75454b8075 wip 2026-05-29 10:08:08 +09:00
syuilo 559846fd7c wip 2026-05-29 09:56:22 +09:00
syuilo 8c1fa69800 Update MkWorldAvatarEditDialog.vue 2026-05-29 09:44:11 +09:00
syuilo 810d437a2c Update MkWorldAvatarEditDialog.vue 2026-05-29 09:40:33 +09:00
syuilo 3fca2cb664 wip 2026-05-29 08:01:39 +09:00
syuilo e95552b264 wip 2026-05-29 07:42:48 +09:00
syuilo 781f6135fd wip 2026-05-29 07:39:52 +09:00
syuilo e90830c738 Update MkWorldAvatarEditDialog.vue 2026-05-28 21:02:26 +09:00
syuilo ab32ab6746 wip 2026-05-28 20:52:10 +09:00
syuilo 86c4f3072f wip 2026-05-28 18:27:44 +09:00
syuilo 8bb8b2fc97 wip 2026-05-28 17:50:04 +09:00
syuilo fad53328be wip 2026-05-28 17:42:54 +09:00
syuilo 0a6bfce548 wip 2026-05-28 17:32:45 +09:00
syuilo 5998b01ffc wip 2026-05-28 17:24:22 +09:00
syuilo 9a91170839 wip 2026-05-28 15:03:06 +09:00
syuilo a0aa64cd9f wip 2026-05-28 13:12:35 +09:00
syuilo 95864071a0 Update PlayerContainer.ts 2026-05-28 10:31:01 +09:00
syuilo ff9d997cec Update edit-world-avatar-dialog.vue 2026-05-28 10:16:33 +09:00
syuilo c6d0855a2e wip 2026-05-28 10:15:02 +09:00
syuilo 624b858758 wip 2026-05-28 09:56:40 +09:00
syuilo d722b8e9d7 wip 2026-05-28 09:40:02 +09:00
syuilo fd02ea906f wip 2026-05-28 09:24:03 +09:00
syuilo aed9a4a1b4 Update preferences.vue 2026-05-28 08:54:35 +09:00
syuilo ace51f7605 wip 2026-05-28 08:53:14 +09:00
syuilo c10e1c4b31 wip 2026-05-28 08:51:23 +09:00
syuilo 4c80e92522 wip 2026-05-28 08:04:50 +09:00
syuilo 71c3f921cc wip 2026-05-28 07:53:30 +09:00
syuilo 6bc9eb9d84 wip 2026-05-27 21:12:34 +09:00
syuilo fbf3c02392 wip 2026-05-27 21:00:05 +09:00
syuilo 0c8b1055d4 wip 2026-05-27 19:54:40 +09:00
syuilo 0e7b517554 wip 2026-05-27 19:18:55 +09:00
syuilo 5cefdd224b wip 2026-05-27 18:58:50 +09:00
syuilo 1a5a4c834f wip 2026-05-27 17:58:34 +09:00
syuilo 2df145c458 wip 2026-05-27 17:28:32 +09:00
syuilo 43958fc70d wip 2026-05-27 16:57:11 +09:00
syuilo 8f94b04edc Update WorldRoomService.ts 2026-05-27 10:11:03 +09:00
syuilo c7d07d0364 Update WorldRoomService.ts 2026-05-27 10:06:59 +09:00
syuilo 5d974e8242 wip 2026-05-27 09:56:17 +09:00
syuilo 807ade4c37 wip 2026-05-27 09:55:12 +09:00
syuilo 2efd52f400 wip 2026-05-26 15:00:14 +09:00
syuilo f0375aa420 wip 2026-05-26 14:42:19 +09:00
syuilo 04c2703ace wip 2026-05-26 10:51:07 +09:00
syuilo 65022ed342 wip 2026-05-26 10:44:40 +09:00
syuilo 0d025d99f2 Update ObjectContainer.ts 2026-05-26 10:43:20 +09:00
syuilo e68847e563 wip 2026-05-26 10:42:40 +09:00
syuilo 409e9f744c wip 2026-05-26 10:01:50 +09:00
syuilo 9f81a88466 wip 2026-05-26 09:59:11 +09:00
syuilo 564d2446cb wip 2026-05-26 09:48:29 +09:00
syuilo cb605a3978 wip 2026-05-26 09:45:40 +09:00
syuilo 4f89010aca wip 2026-05-26 09:44:05 +09:00
syuilo 8933ac119a wip 2026-05-26 09:38:58 +09:00
syuilo 982897f1b5 wip 2026-05-26 09:36:14 +09:00
syuilo dd69662603 wip 2026-05-26 09:34:55 +09:00
syuilo 9485b3fba9 wip 2026-05-26 09:30:06 +09:00
syuilo 245f673a78 wip 2026-05-26 09:29:48 +09:00
syuilo 71bb388b8b wip: separate package 2026-05-25 22:17:58 +09:00
syuilo 39f2614f9a Update steelRack.schema.ts 2026-05-25 20:27:33 +09:00
syuilo 46efad5b12 wip 2026-05-25 20:26:36 +09:00
syuilo fda845cb63 wip 2026-05-25 18:28:46 +09:00
syuilo cccc66bbbf Create object-schema-defs.ts 2026-05-25 16:28:47 +09:00
syuilo 32822d6d07 mi-objet 2026-05-25 16:25:13 +09:00
syuilo 7bca3ca179 wip 2026-05-25 16:24:07 +09:00
syuilo 6961f277b5 temporary del 2026-05-25 15:57:37 +09:00
syuilo eb33c8a93f wip 2026-05-25 15:51:51 +09:00
syuilo 43232d5baa wip 2026-05-25 15:07:47 +09:00
syuilo b8f67a3e69 wip 2026-05-25 15:07:22 +09:00
syuilo 748b571856 wip i18n separation 2026-05-25 14:58:28 +09:00
syuilo f0bf3cda75 wip i18n separation 2026-05-25 14:33:22 +09:00
syuilo bf28b303d7 clean 2026-05-25 14:01:13 +09:00
syuilo 1a064ea430 wip 2026-05-25 13:51:54 +09:00
syuilo 68431f62f1 Update icosahedron.blend 2026-05-25 13:49:13 +09:00
syuilo 2c8abf8239 icosahedron 2026-05-25 13:48:08 +09:00
syuilo 822b567742 wip 2026-05-25 13:21:14 +09:00
syuilo a89475e820 Update room.add-object-dialog.vue 2026-05-25 13:12:06 +09:00
syuilo 2d8c6ca585 Update room.object-customize-form.vue 2026-05-25 12:48:04 +09:00
syuilo fc6a840132 chair 2026-05-25 12:40:46 +09:00
syuilo 9a44c69177 wip 2026-05-25 12:33:17 +09:00
syuilo 0be946a10b Update ja-JP.yml 2026-05-25 12:17:54 +09:00
syuilo a201f72c8a wip 2026-05-25 12:13:16 +09:00
syuilo 83a4281a30 wip 2026-05-25 11:59:23 +09:00
syuilo 7546bf7dc2 wip 2026-05-25 10:53:47 +09:00
syuilo 2942234778 Update cardboardBox.ts 2026-05-25 10:31:37 +09:00
syuilo 540e20864c wip 2026-05-25 10:21:57 +09:00
syuilo 6ee6894184 Update woodRingsPendantLight.ts 2026-05-25 09:37:48 +09:00
syuilo 2a880164f8 Update woodRingsPendantLight.ts 2026-05-25 09:24:09 +09:00
syuilo c002752d65 Update woodRingsPendantLight.ts 2026-05-25 09:17:44 +09:00
syuilo 05e1a43a1c wip 2026-05-24 05:48:13 +09:00
syuilo ec79d6bee7 Update room.object-customize-form.vue 2026-05-24 05:25:29 +09:00
syuilo a3b6dc0899 wip 2026-05-24 05:09:52 +09:00
syuilo 2fffa94eb2 wip 2026-05-24 04:48:56 +09:00
syuilo 0fade25574 Update object.ts 2026-05-24 04:35:33 +09:00
syuilo aa4fa9c54e wip 2026-05-24 04:33:42 +09:00
syuilo fdba915288 Update object.ts 2026-05-23 20:24:07 +09:00
syuilo 773077470e Update engine.ts 2026-05-23 20:20:51 +09:00
syuilo 3eafdbe57a Update cardboardBox.ts 2026-05-23 20:10:42 +09:00
syuilo 8138fd2c34 Update cardboardBox.ts 2026-05-23 20:10:11 +09:00
syuilo ce72d10491 Update engine.ts 2026-05-23 19:43:36 +09:00
syuilo f8851c0f2e wip 2026-05-23 19:43:10 +09:00
syuilo d3a7dc0ea5 Update engine.ts 2026-05-23 18:15:39 +09:00
syuilo 32bdb77e97 Update room.core.vue 2026-05-23 18:14:21 +09:00
syuilo 8ac3c77ae1 Update object.ts 2026-05-23 18:09:36 +09:00
syuilo 8611d55ffc wip 2026-05-23 17:57:35 +09:00
syuilo 14b648faa6 wip 2026-05-23 17:48:39 +09:00
syuilo c6a755c0a7 wip 2026-05-23 16:53:37 +09:00
syuilo 1154bb5370 wip 2026-05-23 15:54:07 +09:00
syuilo 1701e993cf Update cardboardBox.ts 2026-05-23 13:38:52 +09:00
syuilo 99f998cf60 wip 2026-05-23 13:32:51 +09:00
syuilo 05a8dcf897 wip 2026-05-22 18:59:21 +09:00
syuilo 90ea7902eb Update engine.ts 2026-05-22 17:43:44 +09:00
syuilo 644c16aee6 Update engine.ts 2026-05-22 17:42:29 +09:00
syuilo ce5857b265 Update engine.ts 2026-05-22 17:32:20 +09:00
syuilo fbae160442 Update README.md 2026-05-22 11:15:35 +09:00
syuilo 2daad2223d Update engine.ts 2026-05-22 10:40:50 +09:00
syuilo 6cbae336d0 Update env.ts 2026-05-22 10:39:42 +09:00
syuilo 47f9f38a40 wip 2026-05-22 10:31:40 +09:00
syuilo 73d6764a0b wip 2026-05-21 21:23:05 +09:00
syuilo c85ee681bb Update engine.ts 2026-05-21 21:03:19 +09:00
syuilo 61d5a5620a Update issyoubin.ts 2026-05-21 18:38:04 +09:00
syuilo 2873768cd2 Update engine.ts 2026-05-21 18:08:15 +09:00
syuilo 4c957ecd9b wip 2026-05-21 17:29:37 +09:00
syuilo 8c61fc2be0 refactor 2026-05-21 16:52:44 +09:00
syuilo 57057f56f5 wip 2026-05-21 16:37:54 +09:00
syuilo 985dd72a7f Merge branch 'develop' into room 2026-05-21 16:15:27 +09:00
syuilo dfbe765baa ObjectContainer 2026-05-21 16:15:18 +09:00
syuilo 555fa80709 wip 2026-05-21 14:26:27 +09:00
syuilo 7546e7c800 wip 2026-05-21 14:17:03 +09:00
syuilo ce36de7e4f Update steelRack.ts 2026-05-21 14:09:00 +09:00
syuilo 1cb96731e6 Update steelRack.ts 2026-05-21 14:07:03 +09:00
syuilo 4bf0ba3716 semi-procedual steel-rack 2026-05-21 13:29:55 +09:00
syuilo d2e48b707b wip 2026-05-21 12:04:43 +09:00
syuilo 8e70550c84 wip 2026-05-21 11:52:47 +09:00
syuilo 199c0f533b wip 2026-05-21 10:46:15 +09:00
syuilo ad44cc3446 wip 2026-05-21 09:08:14 +09:00
syuilo 8f73c22df3 Update engine.ts 2026-05-20 19:54:23 +09:00
syuilo 20eb342eff wip 2026-05-20 19:09:41 +09:00
syuilo 5d17b557c1 wip 2026-05-20 15:21:02 +09:00
syuilo 43cb18b3e9 wip 2026-05-19 20:26:33 +09:00
syuilo 75dfcf48b6 Update electronicDisplayBoard.ts 2026-05-19 19:23:45 +09:00
syuilo 9e9ae54c26 Update package.json 2026-05-19 16:08:43 +09:00
syuilo e78694f11b up 2026-05-19 16:04:48 +09:00
syuilo 5397beb965 Merge branch 'develop' into room 2026-05-19 15:56:21 +09:00
syuilo 92bc50cb90 Merge branch 'develop' into room 2026-05-19 15:23:21 +09:00
syuilo 45d8b656cb Update EngineBase.ts 2026-05-19 13:41:08 +09:00
syuilo a5e0c594b2 Update engineControllerBase.ts 2026-05-19 13:15:40 +09:00
syuilo 646b0ca041 wip 2026-05-19 13:15:20 +09:00
syuilo de27cc92dc Update engine.ts 2026-05-18 13:27:48 +09:00
syuilo 09d5502eb6 Update utility.ts 2026-05-18 10:44:05 +09:00
syuilo dc6ac2529f Update utility.ts 2026-05-18 10:43:46 +09:00
syuilo a766609963 wip 2026-05-18 10:24:18 +09:00
syuilo e1aa62d2b7 Update engine.ts 2026-05-18 09:40:07 +09:00
syuilo c62d92d88f Update engine.ts 2026-05-18 07:51:14 +09:00
syuilo 1a456998bc Merge branch 'develop' into room 2026-05-18 07:21:45 +09:00
syuilo 14fe51c0a4 wip 2026-05-16 16:40:43 +09:00
syuilo f203ebe4d5 Update engineControllerBase.ts 2026-05-16 11:36:27 +09:00
syuilo de979d3a98 wip 2026-05-16 11:26:46 +09:00
syuilo 7c2e3adf25 wip 2026-05-16 11:15:27 +09:00
syuilo e24cf73a83 update text 2026-05-16 10:52:43 +09:00
syuilo d3eb3867bd handle lost event 2026-05-16 10:49:25 +09:00
syuilo fcfd3331dd miWorldRoomTemp 2026-05-16 09:05:19 +09:00
syuilo 5aa1f0d562 wip 2026-05-16 07:27:50 +09:00
syuilo 491510a11e refactor 2026-05-16 07:15:27 +09:00
syuilo e0382cdf86 duplicateSelectedObject 2026-05-16 07:12:11 +09:00
syuilo 9ae6a9f426 tweak anim 2026-05-16 07:00:19 +09:00
syuilo a8ca0cf9ed refactor 2026-05-16 07:00:00 +09:00
syuilo 45fc8994f0 Merge branch 'room' of https://github.com/misskey-dev/misskey into room 2026-05-16 06:56:40 +09:00
syuilo 85ae7c7efc Update engine.ts 2026-05-16 06:56:38 +09:00
syuilo 2867f03f50 Update engineControllerBase.ts 2026-05-15 22:03:01 +09:00
syuilo 1cd75fe5a8 wip 2026-05-15 19:59:41 +09:00
syuilo 7f2286f1fd Update room.core.vue 2026-05-15 19:45:17 +09:00
syuilo 840e2c3744 wip 2026-05-15 18:09:14 +09:00
syuilo af1dbd3a71 Update room.core.vue 2026-05-15 17:47:16 +09:00
syuilo 82cdaeb56e wip 2026-05-15 17:45:20 +09:00
syuilo ea732458ab Update room.core.vue 2026-05-15 17:23:42 +09:00
syuilo b6e92cdef9 Update engine.ts 2026-05-15 17:12:55 +09:00
syuilo 11b699a180 wip 2026-05-15 16:06:52 +09:00
syuilo e90a53a851 wip 2026-05-15 15:05:15 +09:00
syuilo 1469f890dd Update engine.ts 2026-05-15 14:40:37 +09:00
syuilo 6c82e0fef8 Update engine.ts 2026-05-15 14:34:26 +09:00
syuilo 6ee52259c4 lazy create putParticleSystem 2026-05-15 13:23:05 +09:00
syuilo 414b522d0a Update engine.ts 2026-05-15 13:15:54 +09:00
syuilo a95f3c9467 Update engine.ts 2026-05-15 13:06:15 +09:00
syuilo ecb2ae379e Update engine.ts 2026-05-15 12:56:39 +09:00
syuilo 2e34e6f454 wip 2026-05-15 12:52:54 +09:00
syuilo bee415625a Update WorldRoomService.ts 2026-05-15 09:45:52 +09:00
syuilo d0d3aef76c Merge branch 'develop' into room 2026-05-15 09:41:51 +09:00
syuilo 93a46ae2d6 Merge branch 'develop' into room 2026-05-15 09:39:21 +09:00
syuilo 8b5d006248 Update room.core.vue 2026-05-14 21:39:02 +09:00
syuilo bcc965a19c Update room.core.vue 2026-05-14 21:26:24 +09:00
syuilo 2c5ce5bfae Update room.core.vue 2026-05-14 21:25:55 +09:00
syuilo 170980a0a6 Update WorldRoomService.ts 2026-05-14 21:19:35 +09:00
syuilo 216fb56a4b wip 2026-05-14 21:08:50 +09:00
syuilo 8defcd9f9f wip 2026-05-14 19:35:50 +09:00
syuilo d38f04c97f wip 2026-05-14 19:08:36 +09:00
syuilo ecedd71192 Update engine.ts 2026-05-14 18:47:51 +09:00
syuilo b582895ac6 up 2026-05-14 17:45:40 +09:00
syuilo 267dc5fb64 Update object.ts 2026-05-14 17:20:11 +09:00
syuilo ab02a99a0e wip 2026-05-14 17:19:59 +09:00
syuilo 03481322a9 wip 2026-05-14 17:10:28 +09:00
syuilo fb130466ae wip 2026-05-14 15:41:12 +09:00
syuilo 43f20e44c3 Update room.add-object-dialog.vue 2026-05-14 15:04:37 +09:00
syuilo 2966d2a862 Merge branch 'develop' into room 2026-05-14 14:43:34 +09:00
syuilo 8f23991723 Update engineControllerBase.ts 2026-05-14 14:38:59 +09:00
syuilo bfdda83a6d wip 2026-05-14 14:38:00 +09:00
syuilo ddce5ff526 wip 2026-05-14 14:30:14 +09:00
syuilo 8dd62e57e2 Update object.ts 2026-05-14 14:27:45 +09:00
syuilo 0b17663eeb wip 2026-05-14 14:25:17 +09:00
syuilo 41c5e7242d add note 2026-05-14 12:44:52 +09:00
syuilo ef8f593de8 attachments 2026-05-14 12:43:30 +09:00
syuilo 410cc8ac50 Update engineControllerBase.ts 2026-05-13 21:13:26 +09:00
syuilo a905eeef03 Update engine.ts 2026-05-13 19:53:34 +09:00
syuilo ebb74eff92 Update room.add-object-dialog.vue 2026-05-13 19:44:03 +09:00
syuilo b69efeeb79 wip 2026-05-13 19:26:55 +09:00
syuilo 1a57b8177b Update room.add-object-dialog.vue 2026-05-13 18:36:09 +09:00
syuilo 0e300a9795 wip 2026-05-13 18:33:04 +09:00
syuilo 13d35f8124 fix 2026-05-13 18:23:48 +09:00
syuilo b53e4f6742 wip 2026-05-13 15:48:34 +09:00
syuilo 9d102c2a70 wip 2026-05-13 14:09:43 +09:00
syuilo 84f389aebd wip 2026-05-13 12:29:34 +09:00
syuilo 8072dbef8b wip 2026-05-13 10:30:42 +09:00
syuilo 8195f5257f support pinch/zoom 2026-05-12 20:23:20 +09:00
syuilo b8f8ac031c wip 2026-05-11 20:40:21 +09:00
syuilo 57f1adb402 wip 2026-05-11 15:45:16 +09:00
syuilo 48ce2e09ab Update utility.ts 2026-05-11 15:35:04 +09:00
syuilo dca5450340 wip 2026-05-11 15:28:46 +09:00
syuilo 202c9e8f25 wip 2026-05-11 15:07:44 +09:00
syuilo 8e1e69e60e Update engine.ts 2026-05-11 14:40:34 +09:00
syuilo f3d0edf546 wip: sr in edit mode 2026-05-11 13:35:32 +09:00
syuilo 08dfd23c19 Update engine.ts 2026-05-11 11:01:17 +09:00
syuilo b231357ae8 Update engine.ts 2026-05-11 10:30:40 +09:00
syuilo 102cf03213 tweak 2026-05-11 10:19:15 +09:00
syuilo d1da15128e wip 2026-05-11 09:50:27 +09:00
syuilo 0d96f418ab wip 2026-05-11 09:34:36 +09:00
syuilo b879f2f1e3 Update engine.ts 2026-05-09 16:36:59 +09:00
syuilo fb77eb4349 Update previewEngine.ts 2026-05-09 14:41:39 +09:00
syuilo 79ad7e274c Update previewEngine.ts 2026-05-09 14:29:04 +09:00
syuilo 4890673013 Update previewEngine.ts 2026-05-09 14:28:14 +09:00
syuilo 1f204f572f Update room.vue 2026-05-09 14:15:15 +09:00
syuilo 9976588025 Update room.add-object-dialog.vue 2026-05-09 14:13:49 +09:00
syuilo 414a28fb19 wip 2026-05-09 14:06:07 +09:00
syuilo 3605ffdafc wip 2026-05-09 13:52:45 +09:00
syuilo 179c9fc70a Update previewEngine.ts 2026-05-09 13:10:30 +09:00
syuilo f3a7f10319 wip 2026-05-09 13:03:57 +09:00
syuilo 17333fd7e5 Merge branch 'develop' into room 2026-05-08 18:26:18 +09:00
syuilo 14f4d2c228 wip 2026-05-08 18:23:16 +09:00
syuilo 18b4210eef Update room.add-object-dialog.vue 2026-05-08 18:10:59 +09:00
syuilo 54c2c4dd53 wip 2026-05-07 17:38:49 +09:00
syuilo 42da479026 Update tabletopLcdButtonsController.ts 2026-05-07 16:00:49 +09:00
syuilo adc487ef78 wip 2026-05-07 15:17:49 +09:00
syuilo b08b3a2500 Update curtain.blend 2026-05-07 14:56:14 +09:00
syuilo 4324b6def2 wip 2026-05-07 14:17:09 +09:00
syuilo 21fe0f5e67 wip 2026-05-07 12:56:18 +09:00
syuilo 3ede04c563 Merge branch 'develop' into room 2026-05-07 11:43:07 +09:00
syuilo b750d69065 Merge branch 'develop' into room 2026-05-07 11:33:46 +09:00
syuilo 0c4b36e2d1 wip 2026-05-07 10:56:26 +09:00
syuilo 5cb9474494 wip 2026-05-06 19:17:46 +09:00
syuilo 3be075d281 Update room.add-object-dialog.vue 2026-05-06 18:30:57 +09:00
syuilo d7c94fbf86 handheldGameConsole 2026-05-06 18:25:07 +09:00
syuilo c6d7aa7be8 Update room.add-object-dialog.vue 2026-05-06 14:31:55 +09:00
syuilo d56c6dfe57 wip 2026-05-06 14:24:38 +09:00
syuilo f9be5d8c47 wip 2026-05-06 11:49:16 +09:00
syuilo ac023668a7 Update engine.ts 2026-05-06 11:42:02 +09:00
syuilo 59c9b86842 Update engine.ts 2026-05-06 11:39:13 +09:00
syuilo c4adcde114 Update room.vue 2026-05-05 20:29:19 +09:00
syuilo db1b5e9ce9 wip 2026-05-05 20:29:10 +09:00
syuilo a1cb4b8304 Update README.md 2026-05-04 22:37:55 +09:00
syuilo fd04c5f2fc Update README.md 2026-05-04 22:36:57 +09:00
syuilo 7be7465703 wip 2026-05-04 21:41:21 +09:00
syuilo 626ae675bc wip 2026-05-04 21:30:43 +09:00
syuilo 2a1cd5c197 wip 2026-05-04 19:32:47 +09:00
syuilo a9dd5fd5bf wip 2026-05-04 16:41:50 +09:00
syuilo beb5d1dec5 wip 2026-05-04 16:25:11 +09:00
syuilo d451ce8c36 wip 2026-05-04 15:41:44 +09:00
syuilo f0f78a11cb Update woodRingsPendantLight.ts 2026-05-04 10:47:14 +09:00
syuilo 69dd2675fa Update env.ts 2026-05-04 10:05:25 +09:00
syuilo eb7691e3ba wip 2026-05-04 09:38:35 +09:00
syuilo db90e4ebc0 Update env.ts 2026-05-04 09:24:42 +09:00
syuilo 29491997ea wip 2026-05-04 09:22:29 +09:00
syuilo 015e6d1c81 wip 2026-05-04 09:14:58 +09:00
syuilo 49ee15dd9a Update room.vue 2026-05-03 21:35:19 +09:00
syuilo e6b5758d54 wip 2026-05-03 21:34:11 +09:00
syuilo 210368d597 wip 2026-05-03 20:49:03 +09:00
syuilo 203f29afb9 wip 2026-05-03 18:42:04 +09:00
syuilo fa0eac34c2 Update engine.ts 2026-05-03 17:06:17 +09:00
syuilo d4fa5cf7ca Update engine.ts 2026-05-03 17:02:36 +09:00
syuilo 479e9af17e Update engine.ts 2026-05-03 17:01:49 +09:00
syuilo f03af71dc0 wip 2026-05-03 16:54:11 +09:00
syuilo 6d94f00ecf Update env.ts 2026-05-03 16:21:57 +09:00
syuilo 9e848f3135 default -> simple 2026-05-03 16:20:53 +09:00
syuilo b16b158372 Update room.vue 2026-05-03 16:19:12 +09:00
syuilo 39525c66c2 heya -> env 2026-05-03 16:19:03 +09:00
syuilo d3dc9bc86c wip 2026-05-03 16:12:58 +09:00
syuilo 444d862eac wip 2026-05-03 16:00:57 +09:00
syuilo 74e9851511 wip 2026-05-03 15:45:33 +09:00
syuilo a8586fe224 Update wallMountSpotLight.ts 2026-05-03 14:15:08 +09:00
syuilo d132fdfc04 Update ductRailSpotLights.ts 2026-05-03 14:11:31 +09:00
syuilo 3ba902c2b6 Update ductRailSpotLights.ts 2026-05-03 14:02:59 +09:00
syuilo 50f7c74259 Update controller.ts 2026-05-03 12:17:55 +09:00
syuilo 9b7c908c68 Update room.vue 2026-05-03 12:13:33 +09:00
syuilo e7290c0486 rotation 2026-05-03 12:12:45 +09:00
syuilo db22eddd1e Update engine.ts 2026-05-03 11:51:35 +09:00
syuilo a8cea0622d Update room.object-customize-form.vue 2026-05-03 10:53:21 +09:00
syuilo 5bf1b5569f 🎨 2026-05-03 10:50:10 +09:00
syuilo d1cb2c5bc7 Update worker.ts 2026-05-03 10:00:01 +09:00
syuilo a49697042a refactor: use AbortController 2026-05-03 09:40:04 +09:00
syuilo 4656d93358 wip 2026-05-02 21:27:17 +09:00
syuilo 2e5a02a85a Update controller.ts 2026-05-02 20:12:38 +09:00
syuilo 8421ec75da Merge branch 'develop' into room 2026-05-02 12:41:38 +09:00
syuilo dcd2160294 Update ductRailSpotLights.ts 2026-05-01 20:37:47 +09:00
syuilo 3833469955 Update engine.ts 2026-05-01 20:32:50 +09:00
syuilo 0adfbc8d51 Update lavaLamp.ts 2026-05-01 19:46:44 +09:00
syuilo a931079896 Update lavaLamp.ts 2026-05-01 19:37:50 +09:00
syuilo 18c08f52f1 wip 2026-05-01 19:31:36 +09:00
syuilo 8975449538 Update engine.ts 2026-05-01 18:04:25 +09:00
syuilo 4e149a642d wip 2026-05-01 17:53:40 +09:00
syuilo 09d133242d Update engine.ts 2026-05-01 12:19:03 +09:00
syuilo a8db20259b fix wasdVec calculation 2026-05-01 12:04:05 +09:00
syuilo d1eda166de wip 2026-05-01 11:54:52 +09:00
syuilo 7df4b729e9 Update controller.ts 2026-05-01 11:36:30 +09:00
syuilo 3ed6148f6a wip 2026-05-01 10:02:39 +09:00
syuilo ebe5739ce3 recordPlayer 2026-05-01 09:58:13 +09:00
syuilo 9bc404a8f5 wip 2026-04-30 21:49:09 +09:00
syuilo 90ff3d79d1 Update electronicDisplayBoard.ts 2026-04-30 21:20:55 +09:00
syuilo 6fb49ab88d note 2026-04-30 20:55:21 +09:00
syuilo 722d09b1ae wip 2026-04-30 20:52:53 +09:00
syuilo 6d6ae6728c wip 2026-04-30 19:24:10 +09:00
syuilo ce2e74f3ca wip 2026-04-30 18:06:50 +09:00
syuilo b34e957c25 Update electronicDisplayBoard.ts 2026-04-30 17:38:34 +09:00
syuilo 157b4673fd Update tv.ts 2026-04-30 16:48:45 +09:00
syuilo ac1a19e95c Merge branch 'develop' into room 2026-04-30 16:44:38 +09:00
syuilo 5639324077 up 2026-04-30 16:44:20 +09:00
syuilo a471fe16fa wip 2026-04-30 16:34:07 +09:00
syuilo 6cf90fd714 wip 2026-04-30 16:01:11 +09:00
syuilo c7c785ad2a Update heya.ts 2026-04-30 14:05:58 +09:00
syuilo bde64b5b1f Update engine.ts 2026-04-30 13:53:28 +09:00
syuilo 30f7727e33 wip 2026-04-30 13:32:10 +09:00
syuilo 5d36da17fe wip 2026-04-30 12:16:35 +09:00
syuilo 7bcda08339 wip 2026-04-30 11:52:53 +09:00
syuilo a15575078f Update engine.ts 2026-04-30 11:22:05 +09:00
syuilo 56d813a184 Update engine.ts 2026-04-30 11:13:26 +09:00
syuilo 8f5c09daa1 wip 2026-04-30 09:17:22 +09:00
syuilo c2d5a33400 wip 2026-04-30 08:36:23 +09:00
syuilo 6f3f4e7ef1 wip 2026-04-30 08:32:55 +09:00
syuilo 49f21d7423 wip 2026-04-29 21:06:15 +09:00
syuilo 92a6086e21 Create wall-wood.png 2026-04-29 21:03:40 +09:00
syuilo 9d1c2d52d2 Update tabletopLcdButtonsController.ts 2026-04-29 20:57:59 +09:00
syuilo f97a6c6d55 wip 2026-04-29 20:16:23 +09:00
syuilo 0f69a284c6 wip 2026-04-29 20:07:34 +09:00
syuilo 1427d887dd wip 2026-04-29 20:00:56 +09:00
syuilo a4c9aff8a9 wip 2026-04-29 19:38:21 +09:00
syuilo 5db8ccec74 Update worker.ts 2026-04-29 17:34:02 +09:00
syuilo 70ebc0d32c clean 2026-04-29 17:17:42 +09:00
syuilo d885627350 refactor 2026-04-29 17:16:05 +09:00
syuilo 4a77db7866 refactor 2026-04-29 17:04:06 +09:00
syuilo e50d4fa8ab Update engine.ts 2026-04-29 16:58:11 +09:00
syuilo 27578f2688 wip 2026-04-29 15:23:00 +09:00
syuilo fc97ba41af Update README.md 2026-04-29 15:08:43 +09:00
syuilo a3610ae6c4 🎨 2026-04-29 15:05:04 +09:00
syuilo fc615daad3 Update engine.ts 2026-04-29 13:02:56 +09:00
syuilo 0321edb1ac Merge branch 'room' of https://github.com/misskey-dev/misskey into room 2026-04-29 13:02:31 +09:00
syuilo 8593737886 clean 2026-04-29 13:02:10 +09:00
syuilo 35ad1d758e wip 2026-04-29 12:59:46 +09:00
syuilo 110e5daa6f Update README.md 2026-04-29 12:19:26 +09:00
syuilo 5de191f01a wip 2026-04-29 11:41:14 +09:00
syuilo 80c2b1fa65 mhq 2026-04-29 11:01:42 +09:00
syuilo c8441da835 wip 2026-04-29 10:04:45 +09:00
syuilo fa2b1d6096 wip 2026-04-29 09:53:29 +09:00
syuilo 18a4da4ad7 wip 2026-04-29 09:49:04 +09:00
syuilo 52e9395fab wip 2026-04-29 09:40:12 +09:00
syuilo d05d7938a4 Update engine.ts 2026-04-28 21:12:05 +09:00
syuilo 7846e8efb8 Update engine.ts 2026-04-28 21:08:14 +09:00
syuilo e09f832fad wip 2026-04-28 17:53:48 +09:00
syuilo 515f6d9790 wip 2026-04-28 16:52:24 +09:00
syuilo 657159da45 wip 2026-04-28 16:25:43 +09:00
syuilo e88188cd6d wip 2026-04-28 16:22:08 +09:00
syuilo dcb834ed41 Update controller.ts 2026-04-28 16:11:27 +09:00
syuilo 5a7960d0a9 wip 2026-04-28 16:03:28 +09:00
syuilo 11e55d8fe8 wip 2026-04-28 15:54:41 +09:00
syuilo 836de1bb28 wip 2026-04-28 14:24:20 +09:00
syuilo 58e617af6d wip 2026-04-28 12:20:14 +09:00
syuilo f44d566933 wip@p 2026-04-28 11:10:57 +09:00
syuilo 72fbc4bc9c Update engine.ts 2026-04-28 09:00:10 +09:00
syuilo 746c16aecc Update engine.ts 2026-04-28 08:58:00 +09:00
syuilo 7bef2cd8e0 wip 2026-04-28 08:46:33 +09:00
syuilo aed73eb074 Update room.vue 2026-04-28 08:23:13 +09:00
syuilo a756ca6ffb 🎨 2026-04-28 07:59:23 +09:00
syuilo f19040888a wip 2026-04-27 20:57:30 +09:00
syuilo 634cdf5e1e wip 2026-04-27 19:05:03 +09:00
syuilo c09d445215 wip 2026-04-27 18:28:15 +09:00
syuilo 1d71c0c6dd wip 2026-04-27 17:13:25 +09:00
syuilo 130a43f39a wip 2026-04-27 17:02:43 +09:00
syuilo 9c25c44a8a wip 2026-04-27 16:58:01 +09:00
syuilo 76ce6c84c0 wip 2026-04-27 16:51:05 +09:00
syuilo 24caff71e1 Update engine.ts 2026-04-27 15:22:50 +09:00
syuilo 036b8ea320 wip 2026-04-27 14:46:57 +09:00
syuilo f98394fc60 Update engine.ts 2026-04-27 11:04:46 +09:00
syuilo f52ac6351d wip 2026-04-27 10:58:23 +09:00
syuilo eb1357026d Merge branch 'develop' into room 2026-04-27 10:35:14 +09:00
syuilo 1b5be37f9b Update room.vue 2026-04-27 10:23:56 +09:00
syuilo 42c659c580 Merge branch 'develop' into room 2026-04-27 10:19:37 +09:00
syuilo ec32cad19f Update room.vue 2026-04-27 10:05:02 +09:00
syuilo 0910c47612 Update room.vue 2026-04-27 09:14:36 +09:00
syuilo 88e7303779 wip 2026-04-27 09:02:49 +09:00
syuilo 3a5532211b wip 2026-04-27 08:36:41 +09:00
syuilo 5b945278f9 wip 2026-04-26 21:21:04 +09:00
syuilo 0db2e5a42f Update room.vue 2026-04-26 20:56:47 +09:00
syuilo 8e1c5673b8 Update room.vue 2026-04-26 20:51:27 +09:00
syuilo a763c396bd Update MkProgressBar.vue 2026-04-26 20:51:23 +09:00
syuilo f5dae1d4c8 wip 2026-04-26 18:45:33 +09:00
syuilo 17697ba6ec joystick 2026-04-26 17:08:51 +09:00
syuilo a77987ab28 Update engine.ts 2026-04-26 13:58:33 +09:00
syuilo 47d9e92776 Update engine.ts 2026-04-26 13:25:28 +09:00
syuilo af928ffe93 Update engine.ts 2026-04-26 12:17:43 +09:00
syuilo 4adca586ed wip 2026-04-26 12:11:15 +09:00
syuilo e703705d60 Update engine.ts 2026-04-26 11:33:30 +09:00
syuilo be434949a4 Update engine.ts 2026-04-26 11:26:23 +09:00
syuilo bbff43e9e6 Update room.vue 2026-04-26 11:22:46 +09:00
syuilo 5c28ee0536 Update engine.ts 2026-04-26 10:08:43 +09:00
syuilo 7e41d17c6a Update engine.ts 2026-04-26 10:05:00 +09:00
syuilo 1cd6d01fdd wip 2026-04-25 04:19:52 +09:00
syuilo 3263f4bcc0 Update tabletopLcdButtonsController.ts 2026-04-24 19:40:39 +09:00
syuilo 58feedb53d wip 2026-04-24 19:38:25 +09:00
syuilo e62c85a971 wip 2026-04-24 19:35:19 +09:00
syuilo 585d727297 wip 2026-04-24 19:04:37 +09:00
syuilo abd6c85b41 Update engine.ts 2026-04-24 18:10:43 +09:00
syuilo 9551a3d01a wip 2026-04-24 17:55:13 +09:00
syuilo d281a81200 Update engine.ts 2026-04-24 16:43:04 +09:00
syuilo 82741c2d61 Update engine.ts 2026-04-24 16:23:13 +09:00
syuilo b00880c21f wip 2026-04-24 15:42:58 +09:00
syuilo df5d5d23cc Update engine.ts 2026-04-24 13:51:25 +09:00
syuilo 35f6cac9f6 wip 2026-04-24 13:26:38 +09:00
syuilo 7ddcbf5e94 Update previewEngine.ts 2026-04-24 11:42:49 +09:00
syuilo 634bae3c49 wip 2026-04-24 11:36:35 +09:00
syuilo 0b50aa9d13 Update room.add-object-dialog.vue 2026-04-24 11:27:08 +09:00
syuilo e594ad9c6f Update room.add-object-dialog.vue 2026-04-24 11:19:49 +09:00
syuilo d105c707ec Update room.add-object-dialog.vue 2026-04-24 10:59:42 +09:00
syuilo 93f24c5b8f Update room.add-object-dialog.vue 2026-04-24 10:33:55 +09:00
syuilo 7c170a21e5 wip 2026-04-24 10:16:13 +09:00
syuilo 1de4440dbd ductRailSpotLights 2026-04-23 21:17:12 +09:00
syuilo e6ce36178c wip 2026-04-23 20:22:24 +09:00
syuilo 8a6e925297 wip 2026-04-23 20:18:46 +09:00
syuilo eeda7e7002 note 2026-04-23 18:11:54 +09:00
syuilo abfa67965e wip 2026-04-23 17:48:12 +09:00
syuilo 2c6560cc71 Update previewEngine.ts 2026-04-23 17:28:21 +09:00
syuilo 4984146f6e Update issyoubin.ts 2026-04-23 17:26:40 +09:00
syuilo c36dfc6643 wip 2026-04-23 17:17:09 +09:00
syuilo 9184f0d7b9 issyoubin wip 2026-04-23 16:54:59 +09:00
syuilo 4c659c3129 Update previewEngine.ts 2026-04-23 16:50:54 +09:00
syuilo 10926e5525 Update utility.ts 2026-04-23 15:37:26 +09:00
syuilo 8d3a5a6503 morph 2026-04-23 13:45:50 +09:00
syuilo 924c517bb5 Update randomBooks.ts 2026-04-23 12:31:44 +09:00
syuilo 54c339d89c Update randomBooks.ts 2026-04-23 12:23:34 +09:00
syuilo ce98d4244b Update tabletopDigitalClock.ts 2026-04-23 12:19:54 +09:00
syuilo 4e4b56699b wip 2026-04-23 11:57:53 +09:00
syuilo fab7667b0a 平積み 2026-04-23 09:59:15 +09:00
syuilo 942e32f4de 🎨 2026-04-23 08:19:00 +09:00
syuilo 3897b044fe Update previewEngine.ts 2026-04-22 22:39:42 +09:00
syuilo c47a0c33cf wip 2026-04-22 21:45:33 +09:00
syuilo 2b849685a1 Update engine.ts 2026-04-22 21:24:19 +09:00
syuilo bad29c7604 tableSalt 2026-04-22 21:07:33 +09:00
syuilo b7b24a2140 Update books.ts 2026-04-22 19:45:21 +09:00
syuilo 20d9a03bb3 wip 2026-04-22 19:10:18 +09:00
syuilo 09d58a3ecf wip 2026-04-22 18:33:17 +09:00
syuilo 94e8050455 wallMountSpotLight 2026-04-22 17:28:15 +09:00
syuilo 2f039ce2e9 Update controller.ts 2026-04-22 16:04:38 +09:00
syuilo 61fd35bc97 wip 2026-04-22 16:02:24 +09:00
syuilo 2d36ccf1b2 wip 2026-04-22 14:12:11 +09:00
syuilo 174221fdc4 Update engine.ts 2026-04-22 13:56:38 +09:00
syuilo 08a0f03a45 Update engine.ts 2026-04-22 12:12:58 +09:00
syuilo de795a48e8 Update engine.ts 2026-04-22 12:10:50 +09:00
syuilo af8a0bdf12 Update engine.ts 2026-04-22 11:54:24 +09:00
syuilo b734ab3419 Update engine.ts 2026-04-22 11:33:08 +09:00
syuilo 595e66c423 Update engine.ts 2026-04-22 11:26:53 +09:00
syuilo 7b0e839661 up 2026-04-22 09:36:40 +09:00
syuilo 6f79420fdc 🎨 2026-04-22 09:31:40 +09:00
syuilo b41bad4188 tabletop-lcd-buttons-controller 2026-04-22 09:16:31 +09:00
syuilo 2f34f1c6f1 🎨 2026-04-21 19:42:55 +09:00
syuilo 7b80da7737 🎨 2026-04-21 17:18:46 +09:00
syuilo e65e3b4569 Update previewEngine.ts 2026-04-21 17:09:39 +09:00
syuilo 0ef489513a 🎨 2026-04-21 17:01:32 +09:00
syuilo a92cae8e09 Merge branch 'develop' into room 2026-04-21 16:38:16 +09:00
syuilo 2619509d69 wip 2026-04-21 16:35:13 +09:00
syuilo da8945dc23 stormGlass 2026-04-21 16:30:42 +09:00
syuilo 64995bebc5 glassCylinderPotPlant 2026-04-21 15:54:05 +09:00
syuilo c2dde53c1c gizmo 2026-04-21 15:08:11 +09:00
syuilo efaf7bdd95 side grid snap 2026-04-21 14:28:27 +09:00
syuilo 3dcbfc0168 wall snap 2026-04-21 14:16:37 +09:00
syuilo e96e88b1ce fix scale 2026-04-21 13:33:43 +09:00
syuilo 493a2eb50c Update engine.ts 2026-04-21 13:23:13 +09:00
syuilo fc4d769e1e grid 2026-04-21 12:36:44 +09:00
syuilo 0f0bc9b54f fix scale 2026-04-21 12:18:20 +09:00
syuilo ead90471c4 note 2026-04-21 12:08:01 +09:00
syuilo 461e083454 fix scale 2026-04-21 12:02:58 +09:00
syuilo 79fe0fbd05 make WORLD_SCALE 1 2026-04-21 11:55:53 +09:00
syuilo db9d0090b7 clean up 2026-04-21 11:47:58 +09:00
syuilo 1f81960640 wip 2026-04-21 11:46:48 +09:00
syuilo 5d389732d9 Update randomBooks.ts 2026-04-21 11:27:56 +09:00
syuilo b300f9620c Update woodRingsPendantLight.ts 2026-04-21 11:24:43 +09:00
syuilo ddafd9e517 Update engine.ts 2026-04-21 11:23:18 +09:00
syuilo 2b9f593e03 Update engine.ts 2026-04-21 11:19:48 +09:00
syuilo 5f3f3d715a 9.3.3 2026-04-21 11:08:42 +09:00
syuilo de62fa3b59 Update engine.ts 2026-04-21 09:47:49 +09:00
syuilo 32cba5b979 note 2026-04-21 09:46:12 +09:00
syuilo ab90824b9c wip 2026-04-21 09:44:23 +09:00
syuilo ebec026508 Update engine.ts 2026-04-21 09:12:00 +09:00
syuilo 8ddb2cbe75 Update engine.ts 2026-04-21 09:09:25 +09:00
syuilo 97439d7718 Update engine.ts 2026-04-21 08:53:18 +09:00
syuilo 60a2cd9306 Update engine.ts 2026-04-21 08:52:31 +09:00
syuilo 62be4a258e fix 2026-04-21 07:53:24 +09:00
syuilo ae4a174de5 Merge branch 'develop' into room 2026-04-21 06:13:42 +09:00
syuilo 1e15503000 up 2026-04-21 06:13:17 +09:00
syuilo 61ac82d297 Update petBottle.ts 2026-04-20 21:39:23 +09:00
syuilo f18b3467d9 🎨 2026-04-20 21:37:20 +09:00
syuilo dec440b6cc wip 2026-04-20 21:25:31 +09:00
syuilo 26ac4f7732 note 2026-04-20 20:52:24 +09:00
syuilo b7b3c07a96 Update engine.ts 2026-04-20 20:50:52 +09:00
syuilo 2040827615 wip 2026-04-20 20:47:31 +09:00
syuilo 2b456fec47 wip 2026-04-20 20:21:42 +09:00
syuilo b21ad59db3 wip 2026-04-20 20:19:18 +09:00
syuilo f04799a4f5 wip 2026-04-20 20:07:49 +09:00
syuilo 28dec6b0a3 clean up 2026-04-20 20:01:25 +09:00
syuilo 714bff0835 Update room.add-object-dialog.vue 2026-04-20 20:00:41 +09:00
syuilo 772608ae99 wip 2026-04-20 19:13:29 +09:00
syuilo fe3b3704ba 🎨 2026-04-20 18:10:43 +09:00
syuilo d6caef7ee7 Update heya.ts 2026-04-20 16:29:28 +09:00
syuilo 83a15f74ef boxWallShelf 2026-04-20 16:07:35 +09:00
syuilo 27addb49cf wip 2026-04-20 14:38:12 +09:00
syuilo 6aa741f8d4 wip 2026-04-20 14:32:46 +09:00
syuilo e224bdf5e4 Update previewEngine.ts 2026-04-20 12:41:11 +09:00
syuilo 9fe2044f53 Update previewEngine.ts 2026-04-20 12:33:25 +09:00
syuilo b1aef2d308 Update previewEngine.ts 2026-04-20 12:29:08 +09:00
syuilo 79a063f692 wip 2026-04-20 12:17:57 +09:00
syuilo c3c36b06c2 Update previewEngine.ts 2026-04-20 11:36:39 +09:00
syuilo 358f0c0a6f Update engine.ts 2026-04-20 09:58:18 +09:00
syuilo 6041db87e8 wip 2026-04-20 09:46:10 +09:00
syuilo ff4af812b5 wip 2026-04-20 09:14:22 +09:00
syuilo c2428ca3cc fix 2026-04-19 20:23:07 +09:00
syuilo e402057d3b timer 2026-04-19 20:15:51 +09:00
syuilo 3811de2283 Update utility.ts 2026-04-19 20:07:05 +09:00
syuilo 421d466921 speakerStand 2026-04-19 17:36:24 +09:00
syuilo a211d0df51 wip 2026-04-19 17:11:59 +09:00
syuilo c0690c9b80 wip 2026-04-19 16:46:55 +09:00
syuilo 8b9164a8c3 wip 2026-04-19 16:38:42 +09:00
syuilo 5df01bae9a wip 2026-04-19 13:37:43 +09:00
syuilo f8e093466d fixes 2026-04-19 13:32:08 +09:00
syuilo 664ca528fe wip 2026-04-19 13:21:21 +09:00
syuilo aaab1e7260 wip 2026-04-19 09:52:02 +09:00
syuilo a85f05ca29 wip 2026-04-18 20:58:52 +09:00
syuilo 3253d30073 wip 2026-04-18 20:30:53 +09:00
syuilo 02d365e27b wip 2026-04-18 20:07:50 +09:00
syuilo a4be9b2078 wip 2026-04-18 18:08:37 +09:00
syuilo 47cd092380 wip 2026-04-18 15:08:36 +09:00
syuilo d1555d5423 Update engine.ts 2026-04-18 14:08:35 +09:00
syuilo acd9b94b49 wip 2026-04-18 14:07:14 +09:00
syuilo 4b135bccd8 wip 2026-04-18 11:54:03 +09:00
syuilo 109fdd2ff3 Merge branch 'develop' into room 2026-04-18 08:17:59 +09:00
syuilo aa5275137e wip 2026-04-18 08:13:37 +09:00
syuilo 669286c1d8 wip 2026-04-17 20:42:02 +09:00
syuilo 623b4f087f wip 2026-04-17 17:53:11 +09:00
syuilo 02c6e1b876 fix 2026-04-17 15:39:12 +09:00
syuilo 6cca5706f7 wip 2026-04-17 15:31:56 +09:00
syuilo ebdf627b19 update directory structure 2026-04-17 10:16:05 +09:00
syuilo ae463cde5e wip 2026-04-17 10:03:33 +09:00
syuilo 6350890e9f Update engine.ts 2026-04-16 19:04:13 +09:00
syuilo 77fc803612 Update engine.ts 2026-04-16 18:52:47 +09:00
syuilo e87752a07c clean up 2026-04-16 18:06:59 +09:00
syuilo a7c8a3d6d1 Update engine.ts 2026-04-16 17:34:35 +09:00
syuilo 2175a3a18f Update engine.ts 2026-04-16 17:20:49 +09:00
syuilo 3d48146b92 Update engine.ts 2026-04-16 15:40:48 +09:00
syuilo 4f9aded205 Update engine.ts 2026-04-16 15:27:23 +09:00
syuilo e634fb1456 wip 2026-04-16 14:50:10 +09:00
syuilo 2b0d0d4533 Merge branch 'develop' into room 2026-04-16 14:34:24 +09:00
syuilo 4ac9da7f1f Merge branch 'develop' into room 2026-04-16 12:47:24 +09:00
syuilo 08e0ecf99b Update engine.ts 2026-04-16 11:01:32 +09:00
syuilo 007a2481ef Update ceilingFanLight.ts 2026-04-16 10:58:12 +09:00
syuilo 61eea5799b 🎨 2026-04-16 10:57:58 +09:00
syuilo 1fe9117944 miObjet 2026-04-16 10:41:24 +09:00
syuilo 4a16a71fa2 note 2026-04-16 09:53:49 +09:00
syuilo 85701fcb6d Update README.md 2026-04-16 09:50:06 +09:00
syuilo cd918817d9 Update README.md 2026-04-16 09:48:02 +09:00
syuilo 24c0504cb0 🎨 2026-04-16 08:05:39 +09:00
syuilo 750a48df62 todo 2026-04-16 08:00:48 +09:00
syuilo ead79ab275 wip 2026-04-16 07:59:33 +09:00
syuilo 124079f80a wip 2026-04-15 21:44:26 +09:00
syuilo ac2c6b93ce 🎨 2026-04-15 19:48:52 +09:00
syuilo b0d4ab371b wip 2026-04-15 19:08:08 +09:00
syuilo a3c3f7ba91 clean uv 2026-04-15 18:57:37 +09:00
syuilo 12e1b86b53 note 2026-04-15 18:43:22 +09:00
syuilo 7fd9ac1cc8 wip 2026-04-15 18:30:13 +09:00
syuilo da8a7abcde setRenderingAutoClearDepthStencil 2026-04-15 18:24:15 +09:00
syuilo 6275196101 progress 2026-04-15 18:10:42 +09:00
syuilo 7eb8723082 Update engine.ts 2026-04-15 17:48:49 +09:00
syuilo d730c26fcc Update engine.ts 2026-04-15 17:44:33 +09:00
syuilo 56c2e1c989 Update engine.ts 2026-04-15 16:50:12 +09:00
syuilo 77acce78dd wip 2026-04-15 16:31:32 +09:00
syuilo db5f64b097 wip 2026-04-15 16:18:15 +09:00
syuilo ddfd9f46f3 Update engine.ts 2026-04-15 13:11:47 +09:00
syuilo 4254268f8d TransformNode 2026-04-15 12:14:27 +09:00
syuilo 5104bafe95 hasCollisions 2026-04-15 11:54:38 +09:00
syuilo c426f95bee wip 2026-04-15 11:36:26 +09:00
syuilo c9ae842258 optimize collisions 2026-04-15 11:18:20 +09:00
syuilo 6a7e05d00a Update room.vue 2026-04-15 10:45:50 +09:00
syuilo af5bdb4296 cm 2026-04-15 09:23:11 +09:00
syuilo cf46a4af1e scale intensity 2026-04-15 09:17:11 +09:00
syuilo 83daf43f49 scale intensity 2026-04-15 09:01:59 +09:00
syuilo 3db0b8a1fd cm 2026-04-15 08:51:47 +09:00
syuilo cf9349f29c controller 2026-04-14 21:30:07 +09:00
syuilo 7e0b5ff8be fps 2026-04-14 15:50:06 +09:00
syuilo 28030ea3fa Update engine.ts 2026-04-14 12:53:29 +09:00
syuilo 357aeff407 Update dj-player.glb 2026-04-14 12:45:05 +09:00
syuilo 2f80442b99 🎨 2026-04-14 12:40:33 +09:00
syuilo 3bad686c71 Update engine.ts 2026-04-14 12:26:15 +09:00
syuilo 240c055b45 collision 2026-04-14 10:43:40 +09:00
syuilo 02eb8cfe1c Update room.vue 2026-04-14 08:03:15 +09:00
syuilo 87e2a046b7 🎨 2026-04-14 05:43:42 +09:00
syuilo 4ce42a02c1 newtonsCradle 2026-04-14 04:56:55 +09:00
syuilo f61b2504cc Update engine.ts 2026-04-14 04:26:22 +09:00
syuilo 1b119c49a1 update 2026-04-14 04:15:17 +09:00
syuilo 0ae3eb0721 Update engine.ts 2026-04-14 03:26:34 +09:00
syuilo 205d2c3343 wip 2026-04-13 21:42:12 +09:00
syuilo b6e269d140 fix 2026-04-13 21:10:09 +09:00
syuilo b9335bc314 🎨 2026-04-13 20:21:09 +09:00
syuilo ae92f75345 fix 2026-04-13 18:39:09 +09:00
syuilo 2ff307fe42 Update ironFrameTable.ts 2026-04-13 18:18:26 +09:00
syuilo 60bdd30681 Update tabletopIronFrameStand.ts 2026-04-13 18:11:41 +09:00
syuilo c877210828 ironFrameTable 2026-04-13 18:08:39 +09:00
syuilo e85d1c6139 tabletopIronFrameStand 2026-04-13 17:29:07 +09:00
syuilo 2094e82a30 desk 2026-04-13 16:41:21 +09:00
syuilo f86c5fb3b2 Update engine.ts 2026-04-13 15:30:31 +09:00
syuilo 6b95d07930 woodRingsPendantLight 2026-04-13 13:14:46 +09:00
syuilo 886f64055d wallGlassPictureFrame 2026-04-13 11:58:08 +09:00
syuilo 4740c76128 wip 2026-04-13 11:18:53 +09:00
syuilo baad4ae929 variable iron-frame-shelf 2026-04-13 11:11:14 +09:00
syuilo e989c4b1a5 cuboid 2026-04-13 10:12:24 +09:00
syuilo 8f133d3fed wip 2026-04-13 09:58:02 +09:00
syuilo f26ebef565 pizza 2026-04-12 21:38:39 +09:00
syuilo 7f46bd4928 rename 2026-04-12 21:30:47 +09:00
syuilo abe22da9ed Update ironWoodShelf.ts 2026-04-12 21:28:17 +09:00
syuilo ebc9a60237 ironWoodShelf 2026-04-12 21:22:04 +09:00
syuilo 272c267ea0 Update engine.ts 2026-04-12 19:58:40 +09:00
syuilo 21e6ac6678 🎨 2026-04-12 18:57:46 +09:00
syuilo 2685e254f1 twistedCubeObjet 2026-04-12 17:56:01 +09:00
syuilo 7d5c2052eb Update tabletopGlassPictureFrame.ts 2026-04-12 16:43:19 +09:00
syuilo e8d43032cc wip 2026-04-12 16:41:48 +09:00
syuilo 4ad5234325 tabletopGlassPictureFrame 2026-04-12 15:40:03 +09:00
syuilo 6582087b2f 🎨 2026-04-12 14:30:59 +09:00
syuilo dc689a8c22 🎨 2026-04-12 11:29:39 +09:00
syuilo fa8bdf55be sprayer 2026-04-12 11:29:30 +09:00
syuilo 87828dc0ad fix 2026-04-11 19:37:21 +09:00
syuilo 9fe161ec7c wip 2026-04-11 18:28:31 +09:00
syuilo 938dc5ce40 wip 2026-04-11 15:35:01 +09:00
syuilo 5049857e81 Update randomBooks.ts 2026-04-11 14:19:22 +09:00
syuilo 58cf1414fc Update texture.png 2026-04-11 13:19:04 +09:00
syuilo 6563eb9b8f Update randomBooks.ts 2026-04-11 13:15:07 +09:00
syuilo 69ac19d018 wip 2026-04-11 13:03:11 +09:00
syuilo eeae06014a Update randomBooks.ts 2026-04-11 11:35:33 +09:00
syuilo d808d20f8e wip 2026-04-11 11:19:22 +09:00
syuilo d775fa6360 wip 2026-04-11 05:54:20 +09:00
syuilo 5f4914e6dc Update engine.ts 2026-04-11 05:14:21 +09:00
syuilo 8427685f7b Update engine.ts 2026-04-11 05:08:42 +09:00
syuilo 7dd05a3d60 anim 2026-04-11 05:07:41 +09:00
syuilo 55747bdb99 Merge branch 'room' of https://github.com/misskey-dev/misskey into room 2026-04-11 05:05:40 +09:00
syuilo 909c0ae156 Update engine.ts 2026-04-11 05:05:30 +09:00
syuilo 4c0b78a1ac add put anim 2026-04-11 05:04:58 +09:00
syuilo 62a9795685 note 2026-04-10 20:38:56 +09:00
syuilo 04ff23c44f dj 2026-04-10 19:55:13 +09:00
syuilo ed119dfeb8 Update engine.ts 2026-04-10 16:10:22 +09:00
syuilo c341ad21db used-tissue 2026-04-10 15:00:26 +09:00
syuilo 11119ab046 book 2026-04-10 14:35:17 +09:00
syuilo dc59fd4edb icosahedron 2026-04-10 12:53:51 +09:00
syuilo 887f548985 wallMirror 2026-04-10 11:41:07 +09:00
syuilo a627b58e85 wallCanvas 2026-04-10 11:06:39 +09:00
syuilo 57fbebaea5 Update engine.ts 2026-04-10 09:59:49 +09:00
syuilo 6386ebe18e wip 2026-04-10 09:46:18 +09:00
syuilo 60bf7c6b1f wip 2026-04-09 22:16:33 +09:00
syuilo 6fe0e97ac6 🍮 2026-04-09 15:43:45 +09:00
syuilo bf72fb0d9a Merge branch 'develop' into room 2026-04-09 14:31:26 +09:00
syuilo 85af12d35a Update engine.ts 2026-04-09 11:29:20 +09:00
syuilo 7970fed6e1 Update engine.ts 2026-04-09 11:23:46 +09:00
syuilo 03c0b48e1e Update engine.ts 2026-04-09 11:16:43 +09:00
syuilo 45c851bb5b wip 2026-04-09 08:57:31 +09:00
syuilo 9b3424c5d3 wip 2026-04-08 22:21:42 +09:00
syuilo 9d9b47daca wip 2026-04-08 20:47:02 +09:00
syuilo 1d8f03e199 wip 2026-04-08 20:34:35 +09:00
syuilo 5e1c0d1064 wip 2026-04-08 18:40:20 +09:00
syuilo a6b7150371 Update engine.ts 2026-04-08 18:08:06 +09:00
syuilo 7701bca55b wip 2026-04-08 16:59:32 +09:00
syuilo 547da71743 refactor 2026-04-08 15:34:42 +09:00
syuilo 25ed41ba64 refactor 2026-04-08 15:24:17 +09:00
syuilo 5e4d128f68 refactor 2026-04-08 15:12:46 +09:00
syuilo 8ec7da0cf4 wip 2026-04-08 14:38:43 +09:00
syuilo 970efb3440 wip 2026-04-08 13:20:09 +09:00
syuilo 58acbac7ef wip 2026-04-08 12:49:32 +09:00
syuilo 5736b43149 wip 2026-04-08 12:40:40 +09:00
syuilo 682c5417cb wip 2026-04-08 12:31:34 +09:00
syuilo 127600a5d4 Update engine.ts 2026-04-08 10:48:44 +09:00
syuilo 61386f699e Update engine.ts 2026-04-08 10:29:16 +09:00
syuilo 3283ec410a Update engine.ts 2026-04-08 10:19:18 +09:00
syuilo 6fd8c8c908 note 2026-04-08 10:16:27 +09:00
syuilo 5b6fa78748 Update engine.ts 2026-04-08 10:06:44 +09:00
syuilo 64fd4b7c0a wip 2026-04-08 10:06:36 +09:00
syuilo 3356cf36d3 note 2026-04-07 21:44:58 +09:00
syuilo e2e4e83e6f note 2026-04-07 21:40:10 +09:00
syuilo 8030e4f3fa note 2026-04-07 21:37:13 +09:00
syuilo a00d80e30c add note 2026-04-07 21:32:37 +09:00
syuilo 82373f24d9 Update engine.ts 2026-04-07 20:16:59 +09:00
syuilo fb0c089a16 wip 2026-04-07 19:18:55 +09:00
syuilo c04c6502be wip 2026-04-07 19:00:25 +09:00
syuilo 6b4310c91d Update engine.ts 2026-04-07 17:11:07 +09:00
syuilo 662de635aa Update woodRingFloorLamp.ts 2026-04-07 17:02:38 +09:00
syuilo af8d14a139 Update engine.ts 2026-04-07 15:45:36 +09:00
syuilo afd731797e wip 2026-04-07 15:43:06 +09:00
syuilo d51d1191c5 apply scale 2026-04-07 09:03:55 +09:00
syuilo f4a060b9a8 Update woodRingFloorLamp.ts 2026-04-06 21:41:28 +09:00
syuilo f6677aa02c wip 2026-04-06 21:28:16 +09:00
syuilo 0d966d8ded wip 2026-04-06 21:14:50 +09:00
syuilo a54a8a10ad Update engine.ts 2026-04-06 15:35:21 +09:00
syuilo 602c8d8be1 Update engine.ts 2026-04-06 14:14:24 +09:00
syuilo bdbbe83421 Update engine.ts 2026-04-06 13:39:36 +09:00
syuilo 50575a272c Update engine.ts 2026-04-06 13:17:47 +09:00
syuilo 3b80a96412 Update engine.ts 2026-04-06 12:55:39 +09:00
syuilo fa979f1fed Update engine.ts 2026-04-06 11:40:53 +09:00
syuilo 38bf5fc0bf 🎨 2026-04-06 08:56:38 +09:00
syuilo 78911b24de wip 2026-04-05 19:54:52 +09:00
syuilo 83b5305671 Update engine.ts 2026-04-05 17:18:58 +09:00
syuilo 07d57b0edd wip 2026-04-05 17:15:19 +09:00
syuilo bd0e6f3268 wip 2026-04-05 15:57:47 +09:00
syuilo 0c808aa23d add note 2026-04-05 15:36:44 +09:00
syuilo 68eebb9d76 wip 2026-04-05 15:31:12 +09:00
syuilo b318cff137 Update engine.ts 2026-04-05 14:49:14 +09:00
syuilo 614e8a7254 Update engine.ts 2026-04-05 13:12:23 +09:00
syuilo 93f62f6054 pc 2026-04-05 13:07:30 +09:00
syuilo ca49618fdb Update engine.ts 2026-04-04 21:54:26 +09:00
syuilo fdd360f490 Update engine.ts 2026-04-04 21:25:13 +09:00
syuilo df5df25c80 wip 2026-04-04 20:55:48 +09:00
syuilo c454b8922b wip 2026-04-04 20:23:24 +09:00
syuilo ba4d495b42 Update engine.ts 2026-04-04 19:19:33 +09:00
syuilo 4ed1d56f03 Update ceilingFanLight.ts 2026-04-04 18:14:45 +09:00
syuilo 49823f9ec3 Update engine.ts 2026-04-04 17:18:55 +09:00
syuilo 85dea8b49d wip 2026-04-04 17:09:20 +09:00
syuilo f2be8a2169 Update engine.ts 2026-04-04 16:57:52 +09:00
syuilo 7121cd1ea9 Update engine.ts 2026-04-04 16:43:32 +09:00
syuilo e79532e50a add note 2026-04-04 16:20:24 +09:00
syuilo 17f97bab7b wip 2026-04-04 16:01:20 +09:00
syuilo f13ada97a7 Update books.ts 2026-04-04 14:55:01 +09:00
syuilo 0db754bdd6 Update engine.ts 2026-04-04 13:47:17 +09:00
syuilo 74dfbb7a74 Update engine.ts 2026-04-04 12:55:05 +09:00
syuilo 2a583509ab Update engine.ts 2026-04-04 12:42:27 +09:00
syuilo 4813ea5afc wip 2026-04-03 20:51:40 +09:00
syuilo 65b6821d4f Update engine.ts 2026-04-03 20:46:22 +09:00
syuilo 8a165321b7 Update engine.ts 2026-04-03 19:52:04 +09:00
syuilo b89fc36cd0 Update engine.ts 2026-04-03 19:49:15 +09:00
syuilo f5ebbbca50 Update engine.ts 2026-04-03 19:23:47 +09:00
syuilo 81414a18d3 wip 2026-04-03 19:16:24 +09:00
syuilo 6bf4feaef5 Update engine.ts 2026-04-03 18:55:50 +09:00
syuilo 4efe4fb519 wip 2026-04-03 18:00:05 +09:00
syuilo 3873eb0cd7 wip 2026-04-03 17:21:21 +09:00
syuilo df092dd120 wip 2026-04-03 16:13:42 +09:00
syuilo 654c2c5b05 Update blind.ts 2026-04-03 14:07:44 +09:00
syuilo f367bc37f8 wip 2026-04-03 13:45:43 +09:00
syuilo e8d8242c09 wip 2026-04-03 12:42:35 +09:00
syuilo 2ecaccedab Update laptopPc.ts 2026-04-03 11:26:15 +09:00
syuilo 4a7614f903 wip 2026-04-03 11:25:25 +09:00
syuilo d3da77c307 wip 2026-04-03 11:18:23 +09:00
syuilo 1c610ce825 Update engine.ts 2026-04-03 10:23:55 +09:00
syuilo d5a5b04468 wip 2026-04-03 10:13:43 +09:00
syuilo b91409f5c6 wip 2026-04-02 21:30:37 +09:00
syuilo 74c67b843e Update engine.ts 2026-04-02 17:51:10 +09:00
syuilo 913e35442c wip 2026-04-02 17:06:09 +09:00
syuilo 7a939dc5b3 Update engine.ts 2026-04-02 14:45:42 +09:00
syuilo 71179b8b24 Update engine.ts 2026-04-02 14:35:00 +09:00
syuilo c5a9c08814 wip 2026-04-02 14:31:20 +09:00
syuilo ea8df304c9 wip 2026-04-02 14:15:52 +09:00
syuilo a01bbf828d Merge branch 'develop' into room 2026-04-02 12:53:16 +09:00
syuilo d2807e974c Update utility.ts 2026-03-31 20:47:42 +09:00
syuilo d6f41f9c51 Update engine.ts 2026-03-31 20:47:16 +09:00
syuilo b2f2e9e75d wip 2026-03-27 19:03:11 +09:00
syuilo ee55a0a6cc Update engine.ts 2026-03-27 18:44:12 +09:00
syuilo df184c8fdf Update engine.ts 2026-03-27 15:48:53 +09:00
syuilo 4dd3bfc208 wip 2026-03-27 15:12:30 +09:00
syuilo c677dd6566 ✌️ 2026-03-27 14:46:53 +09:00
syuilo de53f475c5 Merge branch 'room' of https://github.com/misskey-dev/misskey into room 2026-03-27 14:20:02 +09:00
syuilo b2eed6b82a wip 2026-03-27 14:19:59 +09:00
syuilo 6eefe6899c Update engine.ts 2026-03-27 13:47:46 +09:00
syuilo 8619d0156e wip 2026-03-27 10:55:33 +09:00
syuilo 9bc0d7b361 wip 2026-03-26 22:20:34 +09:00
syuilo 9ffd8d777e Update engine.ts 2026-03-26 22:13:06 +09:00
syuilo c558f93a0e wip 2026-03-26 21:56:05 +09:00
syuilo 5458ee016d Update engine.ts 2026-03-26 21:13:29 +09:00
syuilo 5886260e0b wip 2026-03-26 21:09:24 +09:00
syuilo 42c7a483a4 wip 2026-03-26 20:27:10 +09:00
syuilo 7f5858a66f wip 2026-03-26 20:26:27 +09:00
syuilo 4965429069 wip 2026-03-26 19:13:45 +09:00
syuilo 58ec8af8cd wip 2026-03-26 14:34:22 +09:00
syuilo 1af8584aca Merge branch 'develop' into room 2026-03-26 14:19:36 +09:00
syuilo 564098a631 Merge branch 'develop' into room 2026-03-22 18:14:44 +09:00
syuilo 6fceb31c44 Update package.json 2026-03-15 16:38:41 +09:00
syuilo 2a1a27f8c7 Merge branch 'develop' into room 2026-03-15 16:38:33 +09:00
syuilo 0e5c8496ce wip 2026-03-07 20:42:43 +09:00
syuilo 7ff95b8f8a wip 2026-03-06 14:09:32 +09:00
syuilo 295c91c245 wip 2026-03-05 16:55:03 +09:00
syuilo 7322697707 Update engine.ts 2026-03-05 12:05:44 +09:00
syuilo ed4e3a51fd wip 2026-03-05 12:00:14 +09:00
syuilo bdc34305be wip 2026-03-05 11:54:25 +09:00
syuilo 354504b534 wip 2026-03-04 18:01:00 +09:00
syuilo f311105b54 wip 2026-03-04 14:22:40 +09:00
syuilo 2984b0000b wip 2026-03-04 13:00:10 +09:00
syuilo 98aadf8dcc wip 2026-03-04 12:20:11 +09:00
syuilo 1d3ddd279b Update pictureFrame.ts 2026-03-04 11:58:54 +09:00
syuilo 349ee141bb wip 2026-03-04 11:57:40 +09:00
syuilo 9070159db7 wip 2026-03-04 11:52:19 +09:00
syuilo 0a4b81b0cc Update pictureFrame.ts 2026-03-04 11:35:46 +09:00
syuilo 2de50f893f wip 2026-03-04 11:33:18 +09:00
syuilo 0157dd6b41 wip 2026-03-04 09:59:06 +09:00
syuilo 208c300460 refactor 2026-03-03 21:22:56 +09:00
syuilo 46791a3bf2 wip 2026-03-03 21:09:59 +09:00
syuilo 16b54e9615 wip 2026-03-03 21:01:31 +09:00
syuilo 78b689f41c wip 2026-03-03 20:27:26 +09:00
syuilo 7b04d5d434 Update engine.ts 2026-03-03 19:30:50 +09:00
syuilo c22345e3e0 wip 2026-03-03 19:10:13 +09:00
syuilo a90c179998 wip 2026-03-03 18:29:53 +09:00
syuilo 8eebeab692 Update poster.ts 2026-03-03 16:48:21 +09:00
syuilo 66e0eeedfb Update poster.ts 2026-03-03 16:34:05 +09:00
syuilo 3874f7abe9 Update poster.ts 2026-03-03 16:33:57 +09:00
syuilo 98f74b0c7a wip 2026-03-03 16:09:51 +09:00
syuilo dbffa5520c wip 2026-03-03 15:51:58 +09:00
syuilo e336cbad62 Update pictureFrame.ts 2026-03-03 14:56:42 +09:00
syuilo 2fab946b7a Update pictureFrame.ts 2026-03-03 14:52:16 +09:00
syuilo 239df4694c Update pictureFrame.ts 2026-03-03 13:43:31 +09:00
syuilo 1c9a324f3a Update engine.ts 2026-03-03 13:26:57 +09:00
syuilo dbdb7ec324 Update pictureFrame.ts 2026-03-03 13:01:57 +09:00
syuilo d43b3be6f0 wip 2026-03-03 12:46:55 +09:00
syuilo 34b46baaea wip 2026-03-03 12:34:52 +09:00
syuilo dc8dda3aac wip 2026-03-03 10:47:36 +09:00
syuilo 1b2717e256 wip 2026-03-02 21:14:25 +09:00
syuilo 9d723aaaa6 wip 2026-03-02 19:18:27 +09:00
syuilo 78bfcd71af wip 2026-03-02 19:05:35 +09:00
syuilo 5175a1e193 wip 2026-03-02 18:08:53 +09:00
syuilo 2438447ad3 wip 2026-03-02 15:26:50 +09:00
syuilo 01385575fd wip 2026-03-02 13:00:43 +09:00
syuilo 3f0c4b0577 wip 2026-03-02 10:46:16 +09:00
syuilo 36438e85d1 wip 2026-03-01 21:57:53 +09:00
syuilo 545009078a wip 2026-03-01 21:36:12 +09:00
syuilo 5910ec68e3 wip 2026-02-27 21:57:43 +09:00
syuilo 3e7166bd2c wip 2026-02-27 15:01:36 +09:00
syuilo dbfd1a751c wip 2026-02-27 13:56:29 +09:00
syuilo dda26f7f48 wip 2026-02-26 21:11:35 +09:00
syuilo 54b30d1138 wip 2026-02-26 15:57:27 +09:00
syuilo d8dc66781f wip 2026-02-26 15:48:59 +09:00
syuilo 4d37ada54d wip 2026-02-26 14:24:58 +09:00
syuilo a8456a45ab wip 2026-02-25 19:15:26 +09:00
syuilo 08667b4d35 wip 2026-02-25 19:05:50 +09:00
syuilo 03f20814c9 wip 2026-02-25 16:50:57 +09:00
syuilo 2672ae4463 Merge branch 'develop' into room 2026-02-24 17:58:15 +09:00
syuilo de4c1b3b66 wip 2026-02-22 21:25:36 +09:00
syuilo ec82773ff7 wip 2026-02-22 18:23:32 +09:00
syuilo cdb8d86fbf wip 2026-02-22 14:24:57 +09:00
syuilo 20dc48f221 wip 2026-02-22 13:55:08 +09:00
syuilo 0729e209c5 wip 2026-02-22 13:25:23 +09:00
syuilo c5eaf0f7af wip 2026-02-22 09:08:50 +09:00
syuilo dcae3ccaaa wip 2026-02-21 21:44:56 +09:00
syuilo fcc36759f7 Update tabletopDigitalClock.ts 2026-02-21 20:45:44 +09:00
syuilo 689c24c776 wip 2026-02-21 20:37:27 +09:00
syuilo 055121d698 Update engine.ts 2026-02-21 18:17:43 +09:00
syuilo 402dd538bf wip 2026-02-21 18:09:37 +09:00
syuilo 8bdf773a2b wip 2026-02-21 15:18:31 +09:00
syuilo 460f79d5cf wip 2026-02-20 21:02:28 +09:00
syuilo 998f85b260 wip 2026-02-20 17:56:29 +09:00
syuilo a0356d8d4d wip 2026-02-20 16:59:40 +09:00
syuilo d68655f5c2 wip 2026-02-20 16:43:12 +09:00
syuilo bba7076eca wip 2026-02-20 16:33:35 +09:00
syuilo aae03a914d wip 2026-02-20 13:15:34 +09:00
syuilo cdc9b47b78 wip 2026-02-20 11:39:42 +09:00
syuilo 41d40f53cf wip 2026-02-19 21:46:53 +09:00
syuilo 17a3bdb5eb wip 2026-02-19 21:38:44 +09:00
syuilo dadc5295fa wip 2026-02-19 19:44:08 +09:00
syuilo 679c75006a Update room.vue 2026-02-19 19:35:19 +09:00
syuilo cd9612e664 wip 2026-02-19 19:21:18 +09:00
syuilo d01b3036d6 wip 2026-02-19 19:09:00 +09:00
syuilo 376bb328df wip 2026-02-19 19:05:26 +09:00
syuilo 6a08231591 wip 2026-02-19 17:23:33 +09:00
syuilo 411c4ef3ae wip 2026-02-17 20:40:18 +09:00
syuilo 6f32e09db5 wip 2026-02-17 20:06:58 +09:00
syuilo 86f6498ddd wip 2026-02-17 16:45:54 +09:00
syuilo 5619cbb0da wip 2026-02-17 16:27:47 +09:00
syuilo 9475e6151f wip 2026-02-17 16:00:31 +09:00
syuilo af560802b3 wip 2026-02-17 13:46:58 +09:00
syuilo 3375220aee wip 2026-02-17 13:34:40 +09:00
syuilo ce7af6a308 wip 2026-02-17 13:27:25 +09:00
syuilo d446e00964 wip 2026-02-17 13:11:06 +09:00
syuilo 90fa65c96e wip 2026-02-17 12:09:07 +09:00
syuilo 3717962757 wip 2026-02-17 11:56:52 +09:00
syuilo 8bd2003a38 wip 2026-02-17 08:46:51 +09:00
syuilo 07909ab228 wip 2026-02-17 08:21:42 +09:00
syuilo 503a02ac42 Update engine.ts 2026-02-17 07:47:37 +09:00
syuilo 8a0ba3a18a Update engine.ts 2026-02-17 07:34:12 +09:00
syuilo 7b7767942f wip 2026-02-17 07:17:27 +09:00
syuilo 8c28c7c253 Update engine.ts 2026-02-16 21:11:13 +09:00
syuilo 0d8a6e8136 Update engine.ts 2026-02-16 21:06:39 +09:00
syuilo 6a4a09c8cf wip 2026-02-16 21:04:32 +09:00
syuilo 06adb3e045 wip 2026-02-16 20:51:48 +09:00
syuilo c12f330432 wip 2026-02-16 18:25:58 +09:00
syuilo a45611171a wip 2026-02-16 16:21:48 +09:00
syuilo f58de15d45 wip 2026-02-16 15:14:37 +09:00
syuilo aa6c9be133 Update engine.ts 2026-02-16 13:57:51 +09:00
syuilo 2841f67166 Update engine.ts 2026-02-16 12:23:06 +09:00
syuilo b1bb07542a Update engine.ts 2026-02-16 12:14:54 +09:00
syuilo eb0544e083 Update engine.ts 2026-02-16 11:14:53 +09:00
syuilo d490891acc Merge branch 'room' of https://github.com/misskey-dev/misskey into room 2026-02-16 10:27:15 +09:00
syuilo 4da92509cb wip 2026-02-16 10:27:13 +09:00
syuilo f85223c064 Update engine.ts 2026-02-15 23:39:50 +09:00
syuilo 0397fccdb3 wip 2026-02-15 22:42:09 +09:00
syuilo c93758b554 wip 2026-02-15 22:23:31 +09:00
syuilo be67e75ef9 wip 2026-02-15 20:43:25 +09:00
syuilo d8d4b230b0 wip 2026-02-15 19:42:31 +09:00
syuilo 0996c2d9b2 wip 2026-02-15 18:33:46 +09:00
syuilo 0bcc5a3695 wip 2026-02-15 16:12:53 +09:00
syuilo e70743bf40 wip 2026-02-15 15:46:09 +09:00
syuilo 9ecb3d6a5a wip 2026-02-15 14:14:44 +09:00
syuilo 9f0fbb8531 wip 2026-02-15 11:02:21 +09:00
syuilo 52a1b30503 Update engine.ts 2026-02-14 21:48:01 +09:00
syuilo 1dec481a7e wip 2026-02-14 21:43:02 +09:00
syuilo ad48f43524 wip 2026-02-14 20:32:59 +09:00
syuilo 7efa04d561 wip 2026-02-14 10:46:32 +09:00
syuilo bf7f771760 wip 2026-02-13 20:37:41 +09:00
syuilo 3acf6db835 wip 2026-02-13 18:44:10 +09:00
syuilo aafcffd1ad wip 2026-02-13 17:23:02 +09:00
syuilo 82f68f1e93 Update engine.ts 2026-02-13 16:44:47 +09:00
syuilo 3a02ae8b28 Update engine.ts 2026-02-13 15:24:07 +09:00
syuilo 3022313fac wip 2026-02-13 14:37:01 +09:00
syuilo 22d5c27ca7 wip 2026-02-13 14:29:55 +09:00
syuilo 8665923337 wip 2026-02-13 14:10:02 +09:00
syuilo efd101d0a0 wip 2026-02-13 12:17:16 +09:00
syuilo 36f17a156f wip 2026-02-13 10:43:00 +09:00
syuilo 2e84d2864c Update engine.ts 2026-02-12 21:19:35 +09:00
syuilo 909f78b33c wip 2026-02-12 21:16:47 +09:00
syuilo cb6c790d6c wip 2026-02-12 21:06:48 +09:00
syuilo b790608f52 wip 2026-02-12 20:45:18 +09:00
syuilo 13e3bdc90b wip 2026-02-12 19:16:37 +09:00
syuilo 2a1a03ef9d wip 2026-02-12 15:55:13 +09:00
syuilo fb25331661 wip 2026-02-12 14:38:05 +09:00
syuilo 51d2b0d6a5 wip 2026-02-12 10:55:57 +09:00
syuilo de1a3e3765 wip 2026-02-12 10:49:29 +09:00
syuilo 68d28eb4ac wip 2026-02-11 19:35:00 +09:00
syuilo ad150f4718 wi0p 2026-02-11 17:00:10 +09:00
syuilo 91e3249b23 wip 2026-02-11 15:55:47 +09:00
syuilo 24a7131b0b wip 2026-02-11 15:37:16 +09:00
syuilo a0e318b43f wip 2026-02-11 14:51:51 +09:00
syuilo e41e700f2d wip 2026-02-11 11:20:49 +09:00
syuilo 6c64e75412 Update engine.ts 2026-02-11 10:44:47 +09:00
syuilo 0c5c0ce67e wip 2026-02-11 10:29:46 +09:00
syuilo 6ac091096b wip 2026-02-11 09:50:12 +09:00
syuilo b05010bdc4 wip 2026-02-10 20:35:20 +09:00
syuilo d033704f12 wip 2026-02-10 18:53:48 +09:00
syuilo 367119a5a2 wip 2026-02-10 17:30:19 +09:00
syuilo d15c971125 wip 2026-02-10 16:47:43 +09:00
syuilo 4d532199b4 wip 2026-02-10 16:27:28 +09:00
syuilo cd15906c29 wip 2026-02-10 14:33:02 +09:00
syuilo 4ee5c73bca Update engine.ts 2026-02-10 11:43:16 +09:00
syuilo cb1d9c38df Update engine.ts 2026-02-10 11:40:25 +09:00
syuilo bce3411cef refactor 2026-02-10 11:01:41 +09:00
syuilo a721a94902 Update engine.ts 2026-02-10 10:56:24 +09:00
syuilo 3173290abb wip 2026-02-10 10:47:22 +09:00
syuilo 491b40ed80 wip 2026-02-10 09:59:54 +09:00
syuilo ab1362264a Update room.vue 2026-02-09 20:43:21 +09:00
syuilo 09993a8ac8 wip 2026-02-09 20:35:22 +09:00
syuilo 8619367ac0 wip 2026-02-09 20:26:44 +09:00
syuilo 148c853000 wip 2026-02-09 18:02:17 +09:00
syuilo 14efbe6584 wip 2026-02-09 15:39:12 +09:00
syuilo 9f054bb97b Update engine.ts 2026-02-09 15:35:39 +09:00
syuilo 6f07445185 wip 2026-02-09 14:58:45 +09:00
syuilo 80b5c6cd35 Update engine.ts 2026-02-09 12:09:40 +09:00
syuilo 7ab225662c wip 2026-02-09 11:57:55 +09:00
syuilo 85dc9c738b Update engine.ts 2026-02-09 11:06:54 +09:00
syuilo a09bf963f4 wip 2026-02-09 10:52:41 +09:00
syuilo 242fd56aec Update engine.ts 2026-02-08 22:23:38 +09:00
syuilo a42fdee480 wip 2026-02-08 21:53:33 +09:00
1158 changed files with 33839 additions and 3026 deletions
+23
View File
@@ -173,8 +173,31 @@ id: 'aidx'
#sentryForBackend:
# enableNodeProfiling: true
# # Specify Sentry integration names to disable individual auto-instrumentation.
# # The names are integration .name values, not factory function names.
# # To check enabled names, set `options.debug: true` and see
# # "Integration installed: <name>" in the Sentry logs.
# #
# # As of 2026-07-07 / @sentry/node 10.62.0, useful names for Misskey include:
# # Postgres ... DB queries (when pg is externalized)
# # Redis ... ioredis commands
# # Fastify ... inbound HTTP routes
# # Http ... inbound/outbound HTTP; disabling this can also affect request isolation
# # NodeFetch ... fetch/undici requests
# disabledIntegrations: ['Postgres']
# options:
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
# # By default, Misskey prevents Sentry trace headers (`sentry-trace` and
# # `baggage`) from being sent to remote ActivityPub/Webhook/etc. hosts.
# # To intentionally propagate distributed traces to trusted internal services,
# # list only those internal URL patterns here.
# # Avoid broad patterns that match remote servers unless you intentionally
# # want to restore Sentry's legacy behavior of propagating traces to all
# # outbound HTTP requests.
# #tracePropagationTargets: []
# # Internal allowlist example:
# #tracePropagationTargets:
# # - 'internal-service.example'
#sentryForFrontend:
# vueIntegration:
-57
View File
@@ -329,63 +329,6 @@ id: 'aidx'
# #tracePropagationTargets:
# # - 'internal-service.example'
# ┌─────────┐
#───┘ Tracing └────────────────────────────────────────────────
# OpenTelemetry distributed tracing (Traces only, opt-in).
# Existing API and Queue spans are exported to an OTLP http/protobuf endpoint.
#
# Standard OTEL_* environment variables are honored for values omitted below:
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_ENDPOINT,
# OTEL_EXPORTER_OTLP_HEADERS, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, etc.
#
# When this is enabled together with sentryForBackend, Misskey shares Sentry's
# TracerProvider and adds an OTLP SpanProcessor to it. In that mode:
# - sampleRate below is ignored; sampling follows sentryForBackend.options
# tracesSampleRate / tracesSampler.
# - resourceAttributes below is ignored; set OTEL_SERVICE_NAME and
# OTEL_RESOURCE_ATTRIBUTES in the environment if you need to override them.
# - spans produced by Sentry integrations are exported to both Sentry and OTLP.
# - sentry-trace / baggage propagation to outbound remote requests is disabled
# by default while OTel is enabled. Set propagateTraceToRemote: true only if
# you intentionally want Sentry trace propagation for your deployment.
#otelForBackend:
# endpoint: 'http://localhost:4318/v1/traces'
# #headers:
# # authorization: 'Bearer xxxxx'
# #sampleRate: 1.0
# # Record PostgreSQL query spans. Disabled by default to avoid instrumentation
# # overhead when database-level diagnostics are not needed.
# #capturePgSpans: false
# # Include raw SQL text in PostgreSQL spans. Requires capturePgSpans and is
# # disabled by default because non-parameterized queries can expose values to
# # the OTLP Collector.
# #capturePgStatement: false
# # Include PostgreSQL connection-pool spans with an existing parent span.
# # Requires capturePgSpans and is disabled by default to avoid noisy spans
# # from connection churn.
# #capturePgConnectionSpans: false
# # Record Redis command spans. Disabled by default because subscribing to the
# # ioredis diagnostics channel adds work to every Redis command, including
# # commands without a parent span. Enable for development diagnostics or when
# # the added overhead is acceptable in production.
# #captureRedisCommandSpans: false
# # Include Redis startup/reconnect spans. Disabled by default to avoid noisy
# # connection churn; these spans are recorded even without an HTTP or job
# # parent span.
# #captureRedisConnectionSpans: false
# # Record Redis commands without an HTTP or job parent span. Disabled by
# # default because queue polling and background work can produce many root
# # traces; only applies when captureRedisCommandSpans is enabled.
# #captureRedisRootSpans: false
# #resourceAttributes:
# # deployment.environment: 'production'
# #propagateTraceToRemote: false
# # Queue worker spans are independent traces linked to their enqueuer by default.
# # Set to 'parent' to make them child spans instead. This can create very large
# # traces for high-fan-out queues such as federation delivery.
# #jobTraceContextMode: 'link'
#sentryForFrontend:
# vueIntegration:
# tracingOptions:
@@ -0,0 +1,73 @@
name: Check Misskey JS autogen (comment)
on:
workflow_run:
types: [completed]
workflows:
- Check Misskey JS autogen # check-misskey-js-autogen.yml
jobs:
comment-misskey-js-autogen:
runs-on: ubuntu-latest
# No code is cloned or executed here, so it is safe to hold pull-requests: write
if: ${{ github.event.workflow_run.event == 'pull_request' }}
permissions:
actions: read
pull-requests: write
steps:
# The result is missing when the run failed before the check job (e.g. the PR does not build), so do not fail here
- name: Download result
id: download-result
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: misskey-js-autogen-result
path: result
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
# The artifact comes from a run that built fork code, so validate it before using
- name: Load result
id: load-result
if: steps.download-result.outcome == 'success'
run: |
pr_number="$(cat result/pr-number)"
changes="$(cat result/changes)"
case "$pr_number" in
''|*[!0-9]*) echo "invalid pr number"; exit 1;;
esac
case "$changes" in
true|false) ;;
*) echo "invalid result"; exit 1;;
esac
echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT"
echo "changes=$changes" >> "$GITHUB_OUTPUT"
- name: send message
if: steps.load-result.outputs.changes == 'true'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-result.outputs.pr-number }}
comment-tag: check-misskey-js-autogen
message: |-
Thank you for sending us a great Pull Request! 👍
Please regenerate misskey-js type definitions! 🙏
example:
```sh
pnpm run build-misskey-js-with-types
```
- name: send message
if: steps.load-result.outputs.changes == 'false'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-result.outputs.pr-number }}
comment-tag: check-misskey-js-autogen
mode: delete
message: "Thank you!"
create_if_not_exists: false
+17 -26
View File
@@ -1,7 +1,8 @@
# this name is used in check-misskey-js-autogen.comment.yml so be careful when change name
name: Check Misskey JS autogen
on:
pull_request_target:
pull_request:
branches:
- master
- develop
@@ -10,7 +11,6 @@ on:
- packages/backend/**
jobs:
# pull_request_target safety: permissions: read-all, and there are no secrets used in this job
generate-misskey-js:
runs-on: ubuntu-latest
permissions:
@@ -58,7 +58,6 @@ jobs:
name: generated-misskey-js
path: packages/misskey-js/generator/built/autogen
# pull_request_target safety: permissions: read-all, and no user codes are executed
get-actual-misskey-js:
runs-on: ubuntu-latest
permissions:
@@ -78,12 +77,11 @@ jobs:
name: actual-misskey-js
path: packages/misskey-js/src/autogen
# pull_request_target safety: nothing is cloned from repository
comment-misskey-js-autogen:
check-misskey-js-autogen:
runs-on: ubuntu-latest
needs: [generate-misskey-js, get-actual-misskey-js]
permissions:
pull-requests: write
contents: read
steps:
- name: download generated-misskey-js
uses: actions/download-artifact@v8
@@ -111,28 +109,21 @@ jobs:
- name: Print full diff
run: cat ./misskey-js.diff
- name: send message
if: steps.check-changes.outputs.changes == 'true'
uses: thollander/actions-comment-pull-request@v3
with:
comment-tag: check-misskey-js-autogen
message: |-
Thank you for sending us a great Pull Request! 👍
Please regenerate misskey-js type definitions! 🙏
# A fork's pull_request token cannot comment, so hand the result to check-misskey-js-autogen.comment.yml
- name: Save result
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
CHANGES: ${{ steps.check-changes.outputs.changes }}
run: |
mkdir -p result
echo "$PR_NUMBER" > result/pr-number
echo "$CHANGES" > result/changes
example:
```sh
pnpm run build-misskey-js-with-types
```
- name: send message
if: steps.check-changes.outputs.changes == 'false'
uses: thollander/actions-comment-pull-request@v3
- name: Upload result
uses: actions/upload-artifact@v7
with:
comment-tag: check-misskey-js-autogen
mode: delete
message: "Thank you!"
create_if_not_exists: false
name: misskey-js-autogen-result
path: result
- name: Make failure if changes are detected
if: steps.check-changes.outputs.changes == 'true'
+6
View File
@@ -16,6 +16,8 @@ on:
- packages/misskey-js/**
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/misskey-world/**
- packages/frontend-misskey-world-engine/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
@@ -32,6 +34,8 @@ on:
- packages/misskey-js/**
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/misskey-world/**
- packages/frontend-misskey-world-engine/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
@@ -69,6 +73,8 @@ jobs:
- misskey-js
- misskey-bubble-game
- misskey-reversi
- misskey-world
- frontend-misskey-world-engine
env:
eslint-cache-version: v1
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
+8 -11
View File
@@ -12,11 +12,14 @@
- Node.js のセキュリティアップデートに伴い、最低動作バージョンを 22.22.2 / 24.17.0 / 26.4.0 に引き上げました。
- Docker Image は Node.js 26.4.0-trixie に更新されています。
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
- YAMLパーサーをアップデートし、より厳格なチェックが行われるようになりました。起動時にconfigの読み取りで構文エラーが発生する可能性があります。\
例えば `allowPrivateNetworks` を 2026.6.0 までの `example.yml` の構文を元に記述している場合は、配列の閉じ括弧のインデントを上げるか、リスト表示に書き換える必要があります。詳しくは https://github.com/misskey-dev/misskey/pull/17701 をご覧ください。
### General
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
- Enhance: 依存関係の更新
### Client
- 2025.4.0 以前の設定情報の移行処理が削除されました
@@ -31,6 +34,7 @@
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Enhance: ファイルアップロード前にプレビューできるように
- Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように
- Enhance: 翻訳の更新
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
@@ -44,21 +48,12 @@
- Fix: ノートの詳細表示で削除された引用元が表示されない問題を修正
### Server
- Feat: OpenTelemetryサポート
- 詳細な設定はconfigファイルを参照してください。
- Sentryとの併用も可能です。Sentry併用時は、PostgreSQL Query と Redis command は Sentry で計装されます。
- 以下の自動計装をサポートしています。(計装対象にする項目は設定可能)
- PostgreSQL query
- Redis command
- 全ての受信HTTPリクエスト
- 全ての送信HTTPリクエスト
- ジョブキュー(エンキュー元のトレースを含む)
- Feat: ログ基盤の刷新
- API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持)
- ログ全体の既定出力レベルとドメインごとの出力レベルを設定できるように
- バックエンドのログを1行JSON形式で出力できるように
- OpenTelemetryのTrace ContextをJSON形式のログへ関連付けられるように
- HTTPのAccess logをstatus class単位で出力できるように(開発時のリクエスト・レスポンス本文、OpenTelemetryのTrace Contextにも対応)
- SentryのTrace ContextをJSON形式のログへ関連付けられるように
- HTTPのAccess logをstatus class単位で出力できるように(開発時のリクエスト・レスポンス本文、SentryのTrace Contextにも対応)
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: Node.js 22.22.2以降、24.17.0以降、26.4.0以降をサポートするように
@@ -71,6 +66,8 @@
- Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正
- Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正
- Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正
- Fix: フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように
- Fix: セキュリティに関する修正
## 2026.6.0
+10
View File
@@ -30,6 +30,8 @@ COPY --link ["packages/sw/package.json", "./packages/sw/"]
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
COPY --link ["packages/misskey-world/package.json", "./packages/misskey-world/"]
COPY --link ["packages/frontend-misskey-world-engine/package.json", "./packages/frontend-misskey-world-engine/"]
ARG NODE_ENV=production
@@ -61,6 +63,8 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"]
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
COPY --link ["packages/misskey-world/package.json", "./packages/misskey-world/"]
COPY --link ["packages/frontend-misskey-world-engine/package.json", "./packages/frontend-misskey-world-engine/"]
ARG NODE_ENV=production
@@ -99,12 +103,18 @@ COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/nod
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-reversi/node_modules ./packages/misskey-reversi/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-bubble-game/node_modules ./packages/misskey-bubble-game/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-world/node_modules ./packages/misskey-world/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/frontend-misskey-world-engine/node_modules ./packages/frontend-misskey-world-engine/node_modules
COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/built ./packages/misskey-js/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/i18n/built ./packages/i18n/built
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-world/built ./packages/misskey-world/built
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/frontend-misskey-world-engine/built ./packages/frontend-misskey-world-engine/built
COPY --chown=misskey:misskey . ./
ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so
+475
View File
@@ -1414,6 +1414,8 @@ viewRenotedChannel: "リノート先のチャンネルを見る"
previewingTheme: "テーマのプレビュー中"
previewingThemeRestore: "元に戻す"
accessToken: "アクセストークン"
choose: "選択"
rotate: "回転"
chooseEmojiPalette: "絵文字パレットを選択"
addToEmojiPalette: "絵文字パレットに追加"
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
@@ -3580,3 +3582,476 @@ _qr:
scanFile: "端末の画像をスキャン"
raw: "テキスト"
mfm: "MFM"
worldAvatar: "Worldアバター"
worldAvatar_description: "MisskeyWorld / MisskeyRoomsで使用可能な3Dアバターを作成できます。"
_miWorld:
separateRenderingThread: "描画を別スレッドに分離"
separateRenderingThread_description: "有効にするとパフォーマンスが向上します。不安定になる場合は無効すると改善する可能性があります。"
graphicsQuality: "グラフィックの品質"
graphicsSettings: "グラフィック設定"
frameRateLimitation: "フレームレート制限"
higherValuePerformanceNote: "高くすると体験が向上しますが、消費電力が増加するなどパフォーマンスに影響を与えます。"
resolution: "解像度"
fov: "視野角"
failedToInitialize: "初期化に失敗しました"
crushed_description: "描画が継続できなくなりました。デバイスのリソース不足の可能性が考えられます。"
antialiasing: "アンチエイリアス"
avatar: "アバター"
advancedCustomize: "高度なアレンジ"
attachAccessory: "アクセサリーをつける"
takeScreenShot: "スクリーンショット"
onlineMenu: "オンラインメニュー"
connectToOnline: "オンラインに接続"
disconnectToOnline: "オンラインから切断"
character: "キャラクター"
sit: "座る"
lyingDown: "寝そべる"
standUp: "立ち上がる"
showUsernameOnAvatar: "アバターにユーザー名を表示"
show2dAvatarOnAvatar: "アバターにユーザーアイコンを表示"
_avatars:
_default:
body: "ボディ"
eyes: "目"
mouth: "口"
_avatarAccessories:
mug: "マグカップ"
_mug:
bodyMat: "コップの素材"
liquidMat: "液体の素材"
mikan: "みかん"
bolt: "ボルト"
_bolt:
mat: "素材"
_miRoom:
snapToGrid: "グリッドにスナップ"
gridScale: "グリッドサイズ"
thereAreUnsavedChanges: "未保存の変更があります"
revertAllChangesConfirmation: "全ての変更を取り消し、部屋を最後に保存した状態まで戻しますか?"
yourDeviceNotSupported_title: "MisskeyRoomを起動できません"
yourDeviceNotSupported_description: "お使いのデバイスがMisskeyRoomをサポートしていないか、デバイスのリソース不足などにより一時的に利用できなくなっています。\nMisskeyRoomを動作させるには、WebGPUをサポートするデバイス・ブラウザが必要です。"
imageFit: "画像のはめ込み"
imageFit_cover: "覆う"
imageFit_contain: "収める"
imageFit_stretch: "伸縮"
material_metallic: "光沢"
material_roughness: "粗さ"
light_brightness: "明るさ"
advancedCustomize: "高度なアレンジ"
enterEditMode: "エディットモードを始める"
exitEditMode: "エディットモードを終了"
installFurniture: "家具を設置"
roomCustomize: "部屋のカスタマイズ"
changeRoomName: "ルーム名を編集"
roomInfo: "ルーム情報"
duplicate: "複製"
grab: "掴む"
furnitureCustomize: "家具のアレンジ"
uninstallFurniture: "しまう"
furnituresCount: "家具の数"
attachedFilesCount: "添付ファイル数"
_furniturePlacement:
top: "上面設置"
bottom: "下面設置"
side: "側面設置"
_furnitures:
haniwa: "はにわ"
_haniwa:
bodyMat: "本体の素材"
insideColor: "中身の色"
woodRingFloorLamp: "リングシェードフロアランプ"
_woodRingFloorLamp:
shadeMat: "シェードの素材"
bodyMat: "本体の素材"
light: "照明"
a4Case: "A4ケース"
_a4Case:
mat: "素材"
aircon: "エアコン"
allInOnePc: "一体型PC"
_allInOnePc:
bezelMat: "ベゼルの素材"
bodyMat: "本体の素材"
image: "画面の画像"
image_desktop: "デスクトップ"
screenBrightness: "画面の明るさ"
aquarium: "水槽"
aromaReedDiffuser: "アロマリードディフューザー"
_aromaReedDiffuser:
bottleMat: "ボトルの素材"
oilMat: "オイルの素材"
banknote: "紙幣"
beamLamp: "ビームランプ"
bed: "ベッド"
_bed:
frameMat: "フレームの素材"
blind: "ブラインド"
_blind:
angle: "羽根の回転角度"
blades: "羽根の枚数"
open: "開閉状態"
book: "本"
_book:
height: "高さ"
thickness: "厚み"
variation: "バリエーション"
width: "幅"
books: "本の束"
_books:
variation: "バリエーション"
boxWallShelf: "ボックス型ウォールシェルフ"
_boxWallShelf:
bodyMat: "本体の素材"
height: "高さ"
width: "幅"
withBack: "背板"
cactusS: "サボテン S"
_cactusS:
potMat: "鉢の素材"
cardboardBox: "段ボール箱"
_cardboardBox:
variation: "種類"
variation_aizon: "Aizon"
variation_default: "デフォルト"
variation_mikan: "みかん"
ceilingFanLight: "シーリングファンライト"
_ceilingFanLight:
shadeMat: "シェードの素材"
bodyMat: "本体の素材"
ceilingFan: "シーリングファン"
_ceilingFan:
shadeMat: "シェードの素材"
bodyMat: "本体の素材"
chair: "椅子"
_chair:
primaryMat: "メインの素材"
secondaryMat: "サブの素材"
frameMat: "フレームの素材"
clippedPicture: "留められた写真"
_clippedPicture:
height: "高さ"
image: "画像"
width: "幅"
coffeeCup: "コーヒーカップ"
colorBox: "カラーボックス"
_colorBox:
mat: "素材"
cuboid: "直方体"
_cuboid:
mat: "素材"
x: "X"
y: "Y"
z: "Z"
cupNoodle: "インスタントラーメン"
curtain: "カーテン"
custardPudding: "プリン"
descriptionPlate: "説明が書かれたプレート"
desk: "デスク"
_desk:
boardMat: "天板の素材"
depth: "奥行き"
frameMat: "フレームの素材"
width: "幅"
desktopPc: "デスクトップPC"
_desktopPc:
bodyMat: "本体の素材"
coverMat: "カバーの素材"
inner1Mat: "内部素材1"
inner2Mat: "内部素材2"
inner3Mat: "内部素材3"
ledColor: "LEDの色"
djMixer: "DJミキサー"
djPlayer: "DJプレーヤー"
_djPlayer:
image: "画像"
"image:waveform": "波形"
screenBrightness: "画面の明るさ"
ductRailSpotLights: "スポットライト付きダクトレール"
_ductRailSpotLights:
angleH: "水平角度"
angleV: "垂直角度"
bodyMat: "本体の素材"
light: "照明"
ductTape: "ガムテープ"
herbarium: "ハーバリウム"
electronicDisplayBoard: "電光掲示板"
_electronicDisplayBoard:
frameMat: "フレームの素材"
ledBrightness: "LEDの明るさ"
ledColor: "LEDの色"
text: "テキスト"
emptyBento: "空の弁当容器"
energyDrink: "エナジードリンク"
envelope: "封筒"
facialTissue: "ティッシュ"
glassCylinderPotPlant: "ガラスシリンダーの鉢植えと植物"
handheldGameConsole: "携帯ゲーム機"
_handheldGameConsole:
bodyMat: "本体の素材"
image: "画像"
screenBrightness: "画面の明るさ"
hangingDuctRail: "吊り下げダクトレール"
_hangingDuctRail:
bodyMat: "本体の素材"
height: "高さ"
width: "幅"
hangingTShirt: "吊り下げTシャツ"
icosahedron: "正二十面体のオブジェ"
_icosahedron:
mat: "素材"
ironFrameTable: "アイアンフレームテーブル"
_ironFrameTable:
boardMat: "天板の素材"
depth: "奥行き"
frameMat: "フレームの素材"
height: "高さ"
width: "幅"
issyoubin: "一升瓶"
_issyoubin:
variation: "種類"
keyboard: "キーボード"
_keyboard:
bodyMat: "本体の素材"
keyMat: "キーの素材"
laptopPc: "ノートPC"
_laptopPc:
bezelMat: "ベゼルの素材"
bodyMat: "本体の素材"
image: "画像"
openAngle: "開き具合"
screenBrightness: "画面の明るさ"
largeMousepad: "大きいマウスパッド"
_largeMousepad:
image: "画像"
lavaLamp: "ラバランプ"
_lavaLamp:
bodyMat: "本体の素材"
glassMat: "ガラスの素材"
lavaColor: "オイルの色"
lightColor: "ランプの色"
letterCase: "レターケース"
lowPartitionBar: "低いパーティションバー"
_lowPartitionBar:
bodyMat: "本体の素材"
width: "幅"
miObjet: "Miオブジェ"
miPlate: "Miプレート"
miPlateDisplayed: "飾られたMiプレート"
milk: "牛乳"
mixer: "ミキサー"
monitor: "モニター"
_monitor:
bodyMat: "本体の素材"
image: "画像"
screenBrightness: "画面の明るさ"
monitorSpeaker: "モニタースピーカー"
_monitorSpeaker:
mat: "素材"
monstera: "モンステラ"
_monstera:
potMat: "鉢の素材"
mug: "マグカップ"
_mug:
bodyMat: "コップの素材"
liquidMat: "液体の素材"
newtonsCradle: "ニュートンクレードル"
_newtonsCradle:
frameMat: "フレームの素材"
openedCardboardBox: "開いた段ボール箱"
pachira: "パキラ"
_pachira:
potMat: "鉢の素材"
petBottle: "ペットボトル"
_petBottle:
empty: "空"
variation: "種類"
variation_greenTea: "緑茶"
variation_mineralWater: "ミネラルウォーター"
withCap: "キャップ"
withLabel: "ラベル"
piano: "ピアノ"
_piano:
bodyMat: "本体の素材"
pictureFrame: "シンプルな額縁"
_pictureFrame:
depth: "厚さ"
frameMat: "フレームの素材"
frameThickness: "フレームの幅"
height: "高さ"
image: "画像"
matHThickness: "マットの横幅"
matVThickness: "マットの縦幅"
width: "幅"
withCover: "カバーあり"
pizza: "ピザ"
plant: "植物"
plant2: "植物 2"
poster: "ポスター"
_poster:
height: "高さ"
image: "画像"
width: "幅"
powerStrip: "電源タップ"
radiometer: "ラジオメーター"
randomBooks: "雑多な本"
_randomBooks:
count: "数"
seed: "シード"
stackVertically: "平積み"
variation: "バリエーション"
variation_mix: "いろいろ"
variation_mixPlain: "いろいろ(無地)"
recordPlayer: "レコードプレーヤー"
rolledUpPoster: "丸めたポスター"
roundRug: "円形のラグ"
router: "ルーター"
siphon: "サイフォン"
snakeplant: "サンセベリア"
_snakeplant:
potMat: "鉢の素材"
sofa: "ソファ"
_sofa:
bodyMat: "本体の素材"
speaker: "スピーカー"
_speaker:
innerMat: "内側の素材"
outerMat: "外側の素材"
speakerStand: "スピーカースタンド"
_speakerStand:
bodyMat: "本体の素材"
height: "高さ"
spotLight: "スポットライト"
_spotLight:
angleH: "横方向の角度"
angleV: "縦方向の角度"
bodyMat: "本体の素材"
light: "照明"
downlight: "ダウンライト"
_downlight:
bodyMat: "本体の素材"
light: "照明"
sprayer: "霧吹き"
stanchionPole: "スタンションポール"
_stanchionPole:
bodyMat: "本体の素材"
ropeMat: "ロープの素材"
steelRack: "スチールラック"
_steelRack:
height: "高さ"
numberOfShelfs: "シェルフの数"
poleMat: "ポールの素材"
shelfPositionOf: "シェルフの位置"
shelfMat: "シェルフの素材"
widthAndDepthVariation: "W x D"
stormGlass: "ストームグラス"
tableSalt: "食卓塩"
tabletopCalendar: "卓上カレンダー"
tabletopDigitalClock: "卓上デジタル時計"
_tabletopDigitalClock:
bodyMat: "本体の素材"
lcdColor: "LCDの色"
tabletopFlag: "卓上旗"
_tabletopFlag:
image: "画像"
tabletopGlassPictureFrame: "卓上ガラス製フォトフレーム"
_tabletopGlassPictureFrame:
height: "高さ"
image: "画像"
width: "幅"
tabletopIronFrameStand: "卓上アイアンフレームスタンド"
_tabletopIronFrameStand:
boardMat: "板の素材"
depth: "奥行き"
frameMat: "フレームの素材"
height: "高さ"
width: "幅"
tabletopLcdButtonsController: "LCDボタン付き卓上コントローラー"
_tabletopLcdButtonsController:
bodyMat: "本体の素材"
image: "画像"
screenBrightness: "画面の明るさ"
tabletopPictureFrame: "卓上フォトフレーム"
_tabletopPictureFrame:
depth: "厚さ"
frameMat: "フレームの素材"
frameThickness: "フレームの幅"
height: "高さ"
image: "画像"
matHThickness: "マットの横幅"
matVThickness: "マットの縦幅"
width: "幅"
tapestry: "タペストリー"
_tapestry:
height: "高さ"
image: "画像"
width: "幅"
tetrapod: "波消ブロックの置物"
tv: "テレビ"
_tv:
bodyMat: "本体の素材"
screenBrightness: "画面の明るさ"
twistedCubeObjet: "ねじれた立方体のオブジェ"
usedTissue: "使用済みティッシュ"
wallCanvas: "壁掛けキャンバス"
_wallCanvas:
height: "高さ"
image: "画像"
width: "幅"
wallClock: "壁掛け時計"
_wallClock:
frameMat: "フレームの素材"
faceMat: "文字盤の素材"
handsMat: "針の素材"
wallGlassPictureFrame: "ガラスの壁掛けフォトフレーム"
_wallGlassPictureFrame:
height: "高さ"
image: "画像"
width: "幅"
wallMirror: "壁掛けミラー"
_wallMirror:
frameMat: "フレームの素材"
frameThickness: "フレームの厚み"
height: "高さ"
width: "幅"
wallMountSpotLight: "ウォールマウントスポットライト"
_wallMountSpotLight:
angleH: "横方向の角度"
angleV: "縦方向の角度"
bodyMat: "本体の素材"
light: "照明"
wallShelf: "ウォールシェルフ"
_wallShelf:
boardMat: "板の素材"
boardStyle: "板のスタイル"
"boardStyle:color": "単色"
"boardStyle:wood": "木目"
style: "スタイル"
wireBasket: "ワイヤーバスケット"
_wireBasket:
bodyMat: "本体の素材"
wireNet: "ワイヤーネット"
_wireNet:
bodyMat: "本体の素材"
woodRingsPendantLight: "リングペンダントライト"
_woodRingsPendantLight:
bodyMat: "本体の素材"
length: "長さ"
light: "照明"
shadeMat: "シェードの素材"
woodSoundAbsorbingPanel: "木製吸音パネル"
ironFrameShelf: "アイアンフレームシェルフ"
_ironFrameShelf:
boardMat: "板の素材"
frameMat: "フレームの素材"
height: "段数"
width: "幅"
kakejiku: "掛軸"
_kakejiku:
image: "画像"
"image:kitsuaishinsei": "喫藍心整"
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.7.0-beta.6",
"version": "2026.7.0-rc.0",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -20,6 +20,8 @@
"packages/misskey-js/generator",
"packages/misskey-reversi",
"packages/misskey-bubble-game",
"packages/misskey-world",
"packages/frontend-misskey-world-engine",
"packages-private/*"
],
"private": true,
@@ -57,7 +59,7 @@
"esbuild": "0.28.1",
"execa": "9.6.1",
"ignore-walk": "9.0.0",
"js-yaml": "5.2.1",
"js-yaml": "5.2.2",
"tar": "7.5.21"
},
"devDependencies": {
@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class WorldRoom1778744540138 {
name = 'WorldRoom1778744540138'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "world_room" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "description" character varying(1024) NOT NULL, "userId" character varying(32) NOT NULL, "likedCount" integer NOT NULL DEFAULT '0', "visibility" character varying(128) NOT NULL DEFAULT 'public', "def" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_40cfacaf35b0b54bb2281c89767" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_88289375952050da4a7752a366" ON "world_room" ("updatedAt") `);
await queryRunner.query(`CREATE INDEX "IDX_f803a5efb4125c5fd8a414285e" ON "world_room" ("userId") `);
await queryRunner.query(`ALTER TABLE "world_room" ADD CONSTRAINT "FK_f803a5efb4125c5fd8a414285ed" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "world_room" DROP CONSTRAINT "FK_f803a5efb4125c5fd8a414285ed"`);
await queryRunner.query(`DROP INDEX "public"."IDX_f803a5efb4125c5fd8a414285e"`);
await queryRunner.query(`DROP INDEX "public"."IDX_88289375952050da4a7752a366"`);
await queryRunner.query(`DROP TABLE "world_room"`);
}
}
@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class WorldAvatar1779921322355 {
name = 'WorldAvatar1779921322355'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "world_avatar" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "userId" character varying(32) NOT NULL, "def" jsonb NOT NULL DEFAULT '{}', "active" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_e7a27262285cc2c27114871f866" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_0f1d0bdfaca455cc2f13defabe" ON "world_avatar" ("updatedAt") `);
await queryRunner.query(`CREATE INDEX "IDX_4eba43c8e2540a92e99dd7f5a9" ON "world_avatar" ("userId") `);
await queryRunner.query(`ALTER TABLE "world_room" ADD "accessCount" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "world_avatar" ADD CONSTRAINT "FK_4eba43c8e2540a92e99dd7f5a9a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "world_avatar" DROP CONSTRAINT "FK_4eba43c8e2540a92e99dd7f5a9a"`);
await queryRunner.query(`ALTER TABLE "world_room" DROP COLUMN "accessCount"`);
await queryRunner.query(`DROP INDEX "public"."IDX_4eba43c8e2540a92e99dd7f5a9"`);
await queryRunner.query(`DROP INDEX "public"."IDX_0f1d0bdfaca455cc2f13defabe"`);
await queryRunner.query(`DROP TABLE "world_avatar"`);
}
}
@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class HashtagTableDefaults1784899839024 {
name = 'HashtagTableDefaults1784899839024'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" SET DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" DROP DEFAULT`);
}
}
+1 -11
View File
@@ -57,7 +57,6 @@
"@fastify/cors": "11.3.0",
"@fastify/http-proxy": "11.5.0",
"@fastify/multipart": "10.1.0",
"@fastify/otel": "0.20.1",
"@fastify/static": "10.1.2",
"@kitajs/html": "4.2.13",
"@misskey-dev/emoji-assets": "17.0.3",
@@ -68,15 +67,6 @@
"@nestjs/common": "11.1.28",
"@nestjs/core": "11.1.28",
"@nestjs/testing": "11.1.28",
"@opentelemetry/api": "1.9.1",
"@opentelemetry/core": "2.9.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.220.0",
"@opentelemetry/instrumentation": "0.219.0",
"@opentelemetry/instrumentation-pg": "0.72.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/sdk-trace-base": "2.9.0",
"@opentelemetry/sdk-trace-node": "2.9.0",
"@opentelemetry/semantic-conventions": "1.43.0",
"@oxc-project/runtime": "0.139.0",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "10.65.0",
@@ -193,7 +183,7 @@
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",
"fkill": "10.0.3",
"js-yaml": "5.2.1",
"js-yaml": "5.2.2",
"pid-port": "2.1.1",
"rolldown": "1.1.5",
"simple-oauth2": "5.1.0",
-1
View File
@@ -87,7 +87,6 @@ export default defineConfig((args) => {
'class-validator',
/^@sentry\/.*/,
/^@sentry-internal\/.*/,
/^@opentelemetry\/.*/,
'@nestjs/websockets/socket-module',
'@nestjs/microservices/microservices-module',
'@nestjs/microservices',
-18
View File
@@ -27,21 +27,6 @@ type SentryBackendConfig = {
disabledIntegrations?: string[];
};
type OtelBackendConfig = {
endpoint?: string;
headers?: Record<string, string>;
sampleRate?: number;
capturePgSpans?: boolean;
capturePgStatement?: boolean;
capturePgConnectionSpans?: boolean;
captureRedisCommandSpans?: boolean;
captureRedisConnectionSpans?: boolean;
captureRedisRootSpans?: boolean;
resourceAttributes?: Record<string, string>;
propagateTraceToRemote?: boolean;
jobTraceContextMode?: 'link' | 'parent';
};
/**
* 設定ファイルの型
*/
@@ -87,7 +72,6 @@ type Source = {
scope?: 'local' | 'global' | string[];
};
sentryForBackend?: SentryBackendConfig;
otelForBackend?: OtelBackendConfig;
sentryForFrontend?: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -233,7 +217,6 @@ export type Config = {
redisForTimelines: RedisOptions & RedisOptionsSource;
redisForReactions: RedisOptions & RedisOptionsSource;
sentryForBackend: SentryBackendConfig | undefined;
otelForBackend: OtelBackendConfig | undefined;
sentryForFrontend: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -339,7 +322,6 @@ export function loadConfig(): Config {
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis,
sentryForBackend: config.sentryForBackend,
otelForBackend: config.otelForBackend,
sentryForFrontend: config.sentryForFrontend,
id: config.id,
proxy: config.proxy,
@@ -13,6 +13,8 @@ import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
import { bindThis } from '@/decorators.js';
import type { MiLocalUser } from '@/models/User.js';
import { RedisKVCache } from '@/misc/cache.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
@Injectable()
export class ChannelFollowingService implements OnModuleInit {
@@ -96,11 +98,18 @@ export class ChannelFollowingService implements OnModuleInit {
requestUser: MiLocalUser,
targetChannel: MiChannel,
): Promise<void> {
await this.channelFollowingsRepository.insert({
id: this.idService.gen(),
followerId: requestUser.id,
followeeId: targetChannel.id,
});
try {
await this.channelFollowingsRepository.insert({
id: this.idService.gen(),
followerId: requestUser.id,
followeeId: targetChannel.id,
});
} catch (e) {
if (isDuplicateKeyValueError(e)) {
throw new IdentifiableError('6e335e39-0203-4418-a936-b3f2dc987845', 'already following');
}
throw e;
}
this.globalEventService.publishInternalEvent('followChannel', {
userId: requestUser.id,
+30
View File
@@ -154,6 +154,11 @@ import { ApQuestionService } from './activitypub/models/ApQuestionService.js';
import { QueueModule } from './QueueModule.js';
import { QueueService } from './QueueService.js';
import { LoggerService } from './LoggerService.js';
import { WorldRoomService } from './WorldRoomService.js';
import { WorldRoomEntityService } from './entities/WorldRoomEntityService.js';
import { WorldRoomMultiplayService } from './WorldRoomMultiplayService.js';
import { WorldAvatarService } from './WorldAvatarService.js';
import { WorldAvatarEntityService } from './entities/WorldAvatarEntityService.js';
import { TelemetryService } from './telemetry/TelemetryService.js';
import type { Provider } from '@nestjs/common';
@@ -231,6 +236,9 @@ const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatServic
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
const $PageService: Provider = { provide: 'PageService', useExisting: PageService };
const $WorldRoomService: Provider = { provide: 'WorldRoomService', useExisting: WorldRoomService };
const $WorldRoomMultiplayService: Provider = { provide: 'WorldRoomMultiplayService', useExisting: WorldRoomMultiplayService };
const $WorldAvatarService: Provider = { provide: 'WorldAvatarService', useExisting: WorldAvatarService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@@ -286,6 +294,8 @@ const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting
const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService };
const $MetaEntityService: Provider = { provide: 'MetaEntityService', useExisting: MetaEntityService };
const $SystemWebhookEntityService: Provider = { provide: 'SystemWebhookEntityService', useExisting: SystemWebhookEntityService };
const $WorldRoomEntityService: Provider = { provide: 'WorldRoomEntityService', useExisting: WorldRoomEntityService };
const $WorldAvatarEntityService: Provider = { provide: 'WorldAvatarEntityService', useExisting: WorldAvatarEntityService };
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
@@ -384,6 +394,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RegistryApiService,
ReversiService,
PageService,
WorldRoomService,
WorldRoomMultiplayService,
WorldAvatarService,
ChartLoggerService,
FederationChart,
@@ -439,6 +452,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ReversiGameEntityService,
MetaEntityService,
SystemWebhookEntityService,
WorldRoomEntityService,
WorldAvatarEntityService,
ApAudienceService,
ApDbResolverService,
@@ -535,6 +550,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RegistryApiService,
$ReversiService,
$PageService,
$WorldRoomService,
$WorldRoomMultiplayService,
$WorldAvatarService,
$ChartLoggerService,
$FederationChart,
@@ -590,6 +608,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ReversiGameEntityService,
$MetaEntityService,
$SystemWebhookEntityService,
$WorldRoomEntityService,
$WorldAvatarEntityService,
$ApAudienceService,
$ApDbResolverService,
@@ -686,6 +706,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RegistryApiService,
ReversiService,
PageService,
WorldRoomService,
WorldRoomMultiplayService,
WorldAvatarService,
FederationChart,
NotesChart,
@@ -740,6 +763,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ReversiGameEntityService,
MetaEntityService,
SystemWebhookEntityService,
WorldRoomEntityService,
WorldAvatarEntityService,
ApAudienceService,
ApDbResolverService,
@@ -835,6 +860,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RegistryApiService,
$ReversiService,
$PageService,
$WorldRoomService,
$WorldRoomMultiplayService,
$WorldAvatarService,
$FederationChart,
$NotesChart,
@@ -889,6 +917,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ReversiGameEntityService,
$MetaEntityService,
$SystemWebhookEntityService,
$WorldRoomEntityService,
$WorldAvatarEntityService,
$ApAudienceService,
$ApDbResolverService,
@@ -173,6 +173,16 @@ export interface ChatEventTypes {
};
}
export interface WorldRoomEventTypes {
enter: {
user: Packed<'UserLite'>;
avatar: Packed<'WorldAvatarLite'>['def'] | null;
};
left: {
userId: MiUser['id'];
};
}
export interface ReversiEventTypes {
matched: {
game: Packed<'ReversiGameDetailed'>;
@@ -315,6 +325,10 @@ export type GlobalEvents = {
name: `chatRoomStream:${MiChatRoom['id']}`;
payload: EventTypesToEventPayload<ChatEventTypes>;
};
worldRoom: {
name: `worldRoomStream:${string}`;
payload: EventTypesToEventPayload<WorldRoomEventTypes>;
};
reversi: {
name: `reversiStream:${MiUser['id']}`;
payload: EventTypesToEventPayload<ReversiEventTypes>;
@@ -435,4 +449,9 @@ export class GlobalEventService {
public publishReversiGameStream<K extends keyof ReversiGameEventTypes>(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void {
this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
public publishWorldRoomStream<K extends keyof WorldRoomEventTypes>(roomId: string, type: K, value?: WorldRoomEventTypes[K]): void {
this.publish(`worldRoomStream:${roomId}`, type, typeof value === 'undefined' ? null : value);
}
}
+90 -114
View File
@@ -16,11 +16,20 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import Logger from '../logger.js';
const logger = new Logger('hashtag/create');
type AttachedOrMentioned = 'attached' | 'mentioned';
type UpdatingHashtagColumn = {
totalUserIds: keyof MiHashtag & `${AttachedOrMentioned}UserIds`,
totalUsersCount: keyof MiHashtag & `${AttachedOrMentioned}UsersCount`,
localUserIds: keyof MiHashtag & `${AttachedOrMentioned}LocalUserIds`,
localUsersCount: keyof MiHashtag & `${AttachedOrMentioned}LocalUsersCount`,
remoteUserIds: keyof MiHashtag & `${AttachedOrMentioned}RemoteUserIds`,
remoteUsersCount: keyof MiHashtag & `${AttachedOrMentioned}RemoteUsersCount`,
};
@Injectable()
export class HashtagService {
constructor(
@@ -68,126 +77,93 @@ export class HashtagService {
// TODO: サンプリング
this.updateHashtagsRanking(tag, user.id);
{
const index = await this.hashtagsRepository.findOneBy({ name: tag });
const column: UpdatingHashtagColumn = isUserAttached ? {
totalUserIds: 'attachedUserIds',
totalUsersCount: 'attachedUsersCount',
localUserIds: 'attachedLocalUserIds',
localUsersCount: 'attachedLocalUsersCount',
remoteUserIds: 'attachedRemoteUserIds',
remoteUsersCount: 'attachedRemoteUsersCount',
} : {
totalUserIds: 'mentionedUserIds',
totalUsersCount: 'mentionedUsersCount',
localUserIds: 'mentionedLocalUserIds',
localUsersCount: 'mentionedLocalUsersCount',
remoteUserIds: 'mentionedRemoteUserIds',
remoteUsersCount: 'mentionedRemoteUsersCount',
};
if (index == null && inc) {
try {
if (isUserAttached) {
await this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [],
mentionedUsersCount: 0,
mentionedLocalUserIds: [],
mentionedLocalUsersCount: 0,
mentionedRemoteUserIds: [],
mentionedRemoteUsersCount: 0,
attachedUserIds: [user.id],
attachedUsersCount: 1,
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
} as MiHashtag);
} else {
await this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [user.id],
mentionedUsersCount: 1,
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
attachedUserIds: [],
attachedUsersCount: 0,
attachedLocalUserIds: [],
attachedLocalUsersCount: 0,
attachedRemoteUserIds: [],
attachedRemoteUsersCount: 0,
} as MiHashtag);
}
return;
} catch (err) {
if (isDuplicateKeyValueError(err)) {
logger.info(`Duplicate insertion detected. Falling back to update. #${tag}`);
} else {
throw err;
}
}
}
if (inc) {
await this.#incrementHashTag(user, tag, column);
} else {
await this.#decrementHashTag(user, tag, column);
}
}
async #incrementHashTag(
user: { id: MiUser['id']; host: MiUser['host']; },
tag: string,
columns: UpdatingHashtagColumn,
) {
const isLocal = this.userEntityService.isLocalUser(user);
const { totalUserIds, totalUsersCount } = columns;
const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds;
const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount;
const runner = this.db.createQueryRunner('master');
try {
await runner.query(
`INSERT into "hashtag"("id", "name", "${totalUserIds}", "${totalUsersCount}", "${localOrRemoteUserIds}",
"${localOrRemoteUserCount}")
VALUES ($3, $1, ARRAY [$2], 1, ARRAY [$2], 1)
ON CONFLICT ("name")
DO UPDATE SET "${totalUserIds}" = ${appendUserIdIfNotExists(totalUserIds)},
"${totalUsersCount}" = ${incrementCountIfNotExists(totalUserIds, totalUsersCount)},
"${localOrRemoteUserIds}" = ${appendUserIdIfNotExists(localOrRemoteUserIds)},
"${localOrRemoteUserCount}" = ${incrementCountIfNotExists(localOrRemoteUserIds, localOrRemoteUserCount)}`,
[tag, user.id, this.idService.gen()],
);
} finally {
await runner.release();
}
await this.db.transaction(async transactionalEntityManager => {
const transactionalHashtagRepository = transactionalEntityManager
.getRepository(MiHashtag);
function appendUserIdIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`): string {
return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN array_append("hashtag"."${userIds}", $2) ELSE "hashtag"."${userIds}" END`;
}
const index = await transactionalHashtagRepository
.createQueryBuilder()
.setLock('pessimistic_write')
.where('name = :name', { name: tag })
.getOne();
function incrementCountIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN "hashtag"."${userCount}" + 1 ELSE "hashtag"."${userCount}" END`;
}
}
if (index == null) return;
async #decrementHashTag(
user: { id: MiUser['id']; host: MiUser['host']; },
tag: string,
columns: UpdatingHashtagColumn,
) {
const isLocal = this.userEntityService.isLocalUser(user);
const { totalUserIds, totalUsersCount } = columns;
const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds;
const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount;
const set = {} as any;
const runner = this.db.createQueryRunner('master');
try {
await runner.query(
`UPDATE "hashtag"
SET "${totalUserIds}" = array_remove("${totalUserIds}", $2),
"${totalUsersCount}" = ${decrementIfExists(totalUserIds, totalUsersCount)},
"${localOrRemoteUserIds}" = array_remove("${localOrRemoteUserIds}", $2),
"${localOrRemoteUserCount}" = ${decrementIfExists(localOrRemoteUserIds, localOrRemoteUserCount)}
WHERE "name" = $1`,
[tag, user.id],
);
} finally {
await runner.release();
}
if (isUserAttached) {
if (inc) {
// 自分が初めてこのタグを使ったなら
if (!index.attachedUserIds.some(id => id === user.id)) {
set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => '"attachedUsersCount" + 1';
}
// 自分が(ローカル内で)初めてこのタグを使ったなら
if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) {
set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`;
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1';
}
// 自分が(リモートで)初めてこのタグを使ったなら
if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) {
set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`;
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1';
}
} else {
set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => '"attachedUsersCount" - 1';
if (this.userEntityService.isLocalUser(user)) {
set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`;
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1';
} else {
set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`;
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1';
}
}
} else {
// 自分が初めてこのタグを使ったなら
if (!index.mentionedUserIds.some(id => id === user.id)) {
set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`;
set.mentionedUsersCount = () => '"mentionedUsersCount" + 1';
}
// 自分が(ローカル内で)初めてこのタグを使ったなら
if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) {
set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`;
set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1';
}
// 自分が(リモートで)初めてこのタグを使ったなら
if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) {
set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`;
set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1';
}
}
if (Object.keys(set).length > 0) {
await transactionalHashtagRepository
.createQueryBuilder()
.update()
.where('id = :id', { id: index.id })
.set(set)
.execute();
}
});
function decrementIfExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
return `CASE WHEN ("${userIds}" @> ARRAY[$2]) THEN "${userCount}" - 1 ELSE "${userCount}" END`;
}
}
@bindThis
+1 -7
View File
@@ -9,7 +9,6 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { baseQueueOptions, QUEUE } from '@/queue/const.js';
import { allSettled } from '@/misc/promise-tracker.js';
import { instrumentQueue } from '@/core/telemetry/queue-instrumentation.js';
import {
DeliverJobData,
EndedPollNotificationJobData,
@@ -33,12 +32,7 @@ export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
function createQueue<T extends object>(queueName: string, config: Config): Bull.Queue<T> {
const queue = new Bull.Queue<T>(queueName, baseQueueOptions(config, queueName));
// Queue のラップは、enqueue 時に OTel context をジョブデータへ埋め込むためのもの。
// Sentry 単独ではジョブ間の context 伝播を使わないので、OTel 未設定時は元の Queue を返す。
if (config.otelForBackend == null) return queue;
return instrumentQueue(queue);
return new Bull.Queue<T>(queueName, baseQueueOptions(config, queueName));
}
const $system: Provider = {
@@ -0,0 +1,148 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import {
MiDriveFile,
MiWorldAvatar,
} from '@/models/_.js';
import type { DriveFilesRepository, WorldAvatarsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import type { MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { QueryService } from '@/core/QueryService.js';
@Injectable()
export class WorldAvatarService {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.worldAvatarsRepository)
private worldAvatarsRepository: WorldAvatarsRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
private queryService: QueryService,
private idService: IdService,
) {
}
public defaultAvatar = {
type: 'default',
body: {
color: [0.8, 0.8, 0.8],
roughness: 1,
metallic: 0,
},
eyes: {
type: 'a',
color: [0, 0, 0],
},
mouth: {
type: 'a',
color: [0, 0, 0],
},
accessories: [],
} satisfies MiWorldAvatar['def'];
@bindThis
public async validateDef(
me: MiUser,
def: MiWorldAvatar['def'],
): Promise<boolean> {
// TODO
return true;
}
@bindThis
public async findMyAvatarById(userId: MiUser['id'], avatarId: MiWorldAvatar['id']) {
return this.worldAvatarsRepository.findOneBy({ id: avatarId, userId: userId });
}
@bindThis
public async findAvatarById(avatarId: MiWorldAvatar['id']) {
return this.worldAvatarsRepository.findOne({ where: { id: avatarId }, relations: { user: true } });
}
@bindThis
public async getMyAvatarsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiWorldAvatar['id'] | null, untilId?: MiWorldAvatar['id'] | null) {
const query = this.queryService.makePaginationQuery(this.worldAvatarsRepository.createQueryBuilder('avatar'), sinceId, untilId)
.andWhere('avatar.userId = :userId', { userId });
const avatars = await query.take(limit).getMany();
return avatars;
}
@bindThis
public async create(
me: MiUser,
body: Partial<MiWorldAvatar>,
): Promise<MiWorldAvatar> {
const currentAvatarsCount = await this.worldAvatarsRepository.countBy({ userId: me.id });
// TODO: limit by role policy
const avatar = await this.worldAvatarsRepository.insertOne(new MiWorldAvatar({
id: this.idService.gen(),
updatedAt: new Date(),
name: body.name,
def: body.def,
userId: me.id,
active: currentAvatarsCount === 0,
}));
return avatar;
}
@bindThis
public async update(
avatar: MiWorldAvatar,
body: Partial<MiWorldAvatar>,
): Promise<void> {
body.updatedAt = new Date();
const updated = await this.worldAvatarsRepository.createQueryBuilder().update()
.set(body)
.where('id = :id', { id: avatar.id })
.returning('*')
.execute()
.then((response) => {
return response.raw[0];
});
if (body.active) {
await this.worldAvatarsRepository.createQueryBuilder().update()
.set({ active: false })
.where('userId = :userId', { userId: avatar.userId })
.andWhere('id != :id', { id: avatar.id })
.execute();
}
return updated;
}
@bindThis
public async delete(avatar: MiWorldAvatar, deleter?: MiUser): Promise<void> {
await this.worldAvatarsRepository.delete(avatar.id);
}
@bindThis
public async getActiveAvatarOfUser(userId: MiUser['id']) {
return this.worldAvatarsRepository.findOneBy({ userId, active: true });
}
@bindThis
public async getActiveAvatarOfUsers(userIds: MiUser['id'][]): Promise<MiWorldAvatar[]> {
if (userIds.length === 0) return [];
return this.worldAvatarsRepository.findBy({ userId: In(userIds), active: true });
}
}
@@ -0,0 +1,153 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, Not } from 'typeorm';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import {
MiWorldRoom,
} from '@/models/_.js';
import type { MiWorldAvatar, WorldRoomsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import type { MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { QueryService } from '@/core/QueryService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { Packed } from '@/misc/json-schema.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
type PlayerState = {
position: [number, number, number],
rotation: [number, number, number],
sit?: string; // id
};
@Injectable()
export class WorldRoomMultiplayService {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.worldRoomsRepository)
private worldRoomsRepository: WorldRoomsRepository,
private roleService: RoleService,
private queryService: QueryService,
private idService: IdService,
private globalEventService: GlobalEventService,
private userEntityService: UserEntityService,
private worldAvatarService: WorldAvatarService,
private worldAvatarEntityService: WorldAvatarEntityService,
) {
}
@bindThis
public async enter(userId: MiUser['id'], roomId: MiWorldRoom['id']) {
console.log('enter', { userId, roomId });
// TODO: atomicにやる
const currentPlayers = await this.redisClient.hlen(`worldRoom:${roomId}:players`);
if (currentPlayers < 10) {
const redisPipeline = this.redisClient.pipeline();
redisPipeline.hset(`worldRoom:${roomId}:players`, userId, 1);
redisPipeline.hexpire(`worldRoom:${roomId}:players`, 30, 'FIELDS', 1, userId);
await redisPipeline.exec();
} else {
throw new Error('Room is full.');
}
// TODO: 既に入っていたらスキップ
const avatar = await this.worldAvatarService.getActiveAvatarOfUser(userId);
this.globalEventService.publishWorldRoomStream(roomId, 'enter', {
user: await this.userEntityService.pack(userId),
avatar: avatar?.def,
});
}
@bindThis
public async heartbeat(userId: MiUser['id'], roomId: MiWorldRoom['id']) {
const exists = await this.redisClient.hexists(`worldRoom:${roomId}:players`, userId);
if (exists) {
const redisPipeline = this.redisClient.pipeline();
redisPipeline.hexpire(`worldRoom:${roomId}:players`, 30, 'FIELDS', 1, userId);
redisPipeline.hexpire(`worldRoom:${roomId}:playerStates`, 30, 'FIELDS', 1, userId);
await redisPipeline.exec();
} else {
throw new Error('Not in room.');
}
}
@bindThis
public async left(userId: MiUser['id'], roomId: MiWorldRoom['id']) {
console.log('left', { userId, roomId });
const redisPipeline = this.redisClient.pipeline();
redisPipeline.hdel(`worldRoom:${roomId}:players`, userId);
redisPipeline.hdel(`worldRoom:${roomId}:playerStates`, userId);
await redisPipeline.exec();
this.globalEventService.publishWorldRoomStream(roomId, 'left', {
userId,
});
}
@bindThis
public async updatePlayerState(userId: MiUser['id'], roomId: MiWorldRoom['id'], state: PlayerState) {
const redisPipeline = this.redisClient.pipeline();
redisPipeline.hset(`worldRoom:${roomId}:playerStates`, userId, JSON.stringify(state));
redisPipeline.hexpire(`worldRoom:${roomId}:playerStates`, 30, 'FIELDS', 1, userId);
await redisPipeline.exec();
}
@bindThis
public async getPlayerStates(roomId: MiWorldRoom['id']): Promise<Record<string, PlayerState>> {
const entries = await this.redisClient.hgetall(`worldRoom:${roomId}:playerStates`);
return Object.fromEntries(Object.entries(entries).map(([userId, state]) => [userId, JSON.parse(state) as PlayerState]));
}
@bindThis
public getPlayerStatesAndHeatbeat(userId: MiUser['id'], roomId: MiWorldRoom['id']): Promise<Record<string, PlayerState>> {
// TODO: atomicにやる
this.heartbeat(userId, roomId);
return this.getPlayerStates(roomId);
}
@bindThis
public packPlayerProfile(user: Packed<'UserLite'>, avatar: Packed<'WorldAvatarLite'>['def'] | null) {
return {
user: {
name: user.name,
username: user.username,
avatarUrl: user.avatarUrl,
},
avatar: avatar ?? this.worldAvatarService.defaultAvatar,
};
}
@bindThis
public async getPlayerProfiles(roomId: MiWorldRoom['id'], userId?: MiUser['id']): Promise<Record<string, any>> {
let playerIds = await this.redisClient.hkeys(`worldRoom:${roomId}:players`);
playerIds = playerIds.filter(id => id !== userId);
const packedUsers = await this.userEntityService.packMany(playerIds);
const avatars = await this.worldAvatarService.getActiveAvatarOfUsers(playerIds);
const profiles: Record<string, any> = {};
for (const playerId of playerIds) {
const packedUser = packedUsers.find(u => u.id === playerId);
if (packedUser == null) continue;
profiles[playerId] = this.packPlayerProfile(packedUser, avatars.find(a => a.userId === playerId)?.def ?? null);
}
return profiles;
}
}
@@ -0,0 +1,176 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import {
MiDriveFile,
MiWorldRoom,
} from '@/models/_.js';
import type { DriveFilesRepository, WorldRoomsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import type { MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { QueryService } from '@/core/QueryService.js';
const driveFileReferencingOptions = {
clippedPicture: ['image'],
tapestry: ['image'],
poster: ['image'],
pictureFrame: ['image'],
tabletopPictureFrame: ['image'],
tabletopGlassPictureFrame: ['image'],
wallCanvas: ['image'],
wallGlassPictureFrame: ['image'],
tabletopFlag: ['image'],
tabletopLcdButtonsController: ['image'],
djPlayer: ['image'],
monitor: ['image'],
allInOnePc: ['image'],
laptopPc: ['image'],
handheldGameConsole: ['image'],
largeMousepad: ['image'],
kakejiku: ['image'],
} as Record<string, string[]>;
@Injectable()
export class WorldRoomService {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.worldRoomsRepository)
private worldRoomsRepository: WorldRoomsRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
private queryService: QueryService,
private idService: IdService,
) {
}
@bindThis
public async validateDef(
me: MiUser,
def: MiWorldRoom['def'],
): Promise<boolean> {
// TODO: スキーマ検証(関係ないプロパティを入れたり不正な値を入れたりできないように)
// そのためにはJSON SchemaでRoomState/各objectのoptionsを定義する必要がある
const objectsLimit = 100; // TODO: ref role policy
if (def.installedFurnitures.length > objectsLimit) {
return false;
}
const attachedFilesLimit = 30; // TODO: ref role policy
const attachedFileIds = this.collectReferencedDriveFileIds(def);
if (attachedFileIds.size > attachedFilesLimit) {
return false;
}
const attachedFiles = attachedFileIds.size === 0 ? [] : await this.driveFilesRepository.findBy({ id: In([...attachedFileIds]), userId: me.id });
for (const file of attachedFiles) {
if (!file.type.startsWith('image/')) {
return false;
}
if (file.size > 5 * 1024 * 1024) {
return false;
}
if (Math.max(file.properties.width ?? 0, file.properties.height ?? 0) > 2048) {
return false;
}
}
return true;
}
@bindThis
public async findMyRoomById(userId: MiUser['id'], roomId: MiWorldRoom['id']) {
return this.worldRoomsRepository.findOneBy({ id: roomId, userId: userId });
}
@bindThis
public async findRoomById(roomId: MiWorldRoom['id']) {
return this.worldRoomsRepository.findOne({ where: { id: roomId }, relations: { user: true } });
}
@bindThis
public async getRoomsOfUserWithPagination(userId: MiUser['id'], self: boolean, limit: number, sinceId?: MiWorldRoom['id'] | null, untilId?: MiWorldRoom['id'] | null) {
const query = this.queryService.makePaginationQuery(this.worldRoomsRepository.createQueryBuilder('room'), sinceId, untilId)
.andWhere('room.userId = :userId', { userId });
if (!self) {
query.andWhere('room.visibility = :visibility', { visibility: 'public' });
}
const rooms = await query.take(limit).getMany();
return rooms;
}
@bindThis
public async create(
me: MiUser,
body: Partial<MiWorldRoom>,
): Promise<MiWorldRoom> {
const room = await this.worldRoomsRepository.insertOne(new MiWorldRoom({
id: this.idService.gen(),
updatedAt: new Date(),
name: body.name,
description: body.description,
def: body.def,
userId: me.id,
visibility: body.visibility,
}));
return room;
}
@bindThis
public async update(
room: MiWorldRoom,
body: Partial<MiWorldRoom>,
): Promise<void> {
body.updatedAt = new Date();
return this.worldRoomsRepository.createQueryBuilder().update()
.set(body)
.where('id = :id', { id: room.id })
.returning('*')
.execute()
.then((response) => {
return response.raw[0];
});
}
@bindThis
public async delete(room: MiWorldRoom, deleter?: MiUser): Promise<void> {
await this.worldRoomsRepository.delete(room.id);
}
@bindThis
public collectReferencedDriveFileIds(roomState: MiWorldRoom['def']): Set<MiDriveFile['id']> {
const fileIds = new Set<MiDriveFile['id']>();
const installedFurnitures = roomState.installedFurnitures ?? roomState.installedObjects; // 後方互換性のため
for (const o of installedFurnitures) {
const def = driveFileReferencingOptions[o.type];
if (def == null) continue;
for (const key of def) {
const optionValue = o.options[key];
if (optionValue != null && optionValue.driveFileId != null && optionValue.driveFileId !== '') {
fileIds.add(optionValue.driveFileId);
}
}
}
return fileIds;
}
}
@@ -0,0 +1,93 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository, MiWorldAvatar, WorldAvatarsRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { UserEntityService } from './UserEntityService.js';
import { DriveFileEntityService } from './DriveFileEntityService.js';
import { In } from 'typeorm';
@Injectable()
export class WorldAvatarEntityService {
constructor(
@Inject(DI.worldAvatarsRepository)
private worldAvatarsRepository: WorldAvatarsRepository,
private worldAvatarService: WorldAvatarService,
private userEntityService: UserEntityService,
private idService: IdService,
) {
}
@bindThis
public async packLite(
src: MiWorldAvatar['id'] | MiWorldAvatar,
me?: { id: MiUser['id'] } | null | undefined,
hint?: {
packedUser?: Packed<'UserLite'>
},
): Promise<Packed<'WorldAvatarLite'>> {
const meId = me ? me.id : null;
const avatar = typeof src === 'object' ? src : await this.worldAvatarsRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: avatar.id,
def: avatar.def,
});
}
@bindThis
public async packDetailed(
src: MiWorldAvatar['id'] | MiWorldAvatar,
me?: { id: MiUser['id'] } | null | undefined,
hint?: {
packedUser?: Packed<'UserLite'>
},
): Promise<Packed<'WorldAvatarDetailed'>> {
const meId = me ? me.id : null;
const avatar = typeof src === 'object' ? src : await this.worldAvatarsRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: avatar.id,
createdAt: this.idService.parse(avatar.id).date.toISOString(),
updatedAt: avatar.updatedAt.toISOString(),
name: avatar.name,
def: avatar.def,
active: avatar.active,
});
}
@bindThis
public async packLiteMany(
avatars: MiWorldAvatar[],
me?: { id: MiUser['id'] } | null | undefined,
) {
const _users = avatars.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(avatars.map(avatar => this.packLite(avatar, me, { packedUser: _userMap.get(avatar.userId) })));
}
@bindThis
public async packDetailedMany(
avatars: MiWorldAvatar[],
me?: { id: MiUser['id'] } | null | undefined,
) {
const _users = avatars.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(avatars.map(avatar => this.packDetailed(avatar, me, { packedUser: _userMap.get(avatar.userId) })));
}
}
@@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository, MiWorldRoom, WorldRoomsRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { UserEntityService } from './UserEntityService.js';
import { DriveFileEntityService } from './DriveFileEntityService.js';
import { In } from 'typeorm';
@Injectable()
export class WorldRoomEntityService {
constructor(
@Inject(DI.worldRoomsRepository)
private worldRoomsRepository: WorldRoomsRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private worldRoomService: WorldRoomService,
private userEntityService: UserEntityService,
private driveFileEntityService: DriveFileEntityService,
private idService: IdService,
) {
}
@bindThis
public async packLite(
src: MiWorldRoom['id'] | MiWorldRoom,
me?: { id: MiUser['id'] } | null | undefined,
hint?: {
packedUser?: Packed<'UserLite'>
},
): Promise<Packed<'WorldRoomLite'>> {
const meId = me ? me.id : null;
const room = typeof src === 'object' ? src : await this.worldRoomsRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: room.id,
createdAt: this.idService.parse(room.id).date.toISOString(),
updatedAt: room.updatedAt.toISOString(),
userId: room.userId,
user: hint?.packedUser ?? this.userEntityService.pack(room.user ?? room.userId, me),
name: room.name,
description: room.description,
});
}
@bindThis
public async packDetailed(
src: MiWorldRoom['id'] | MiWorldRoom,
me?: { id: MiUser['id'] } | null | undefined,
hint?: {
packedUser?: Packed<'UserLite'>
},
): Promise<Packed<'WorldRoomDetailed'>> {
const meId = me ? me.id : null;
const room = typeof src === 'object' ? src : await this.worldRoomsRepository.findOneByOrFail({ id: src });
const attachedFileIds = this.worldRoomService.collectReferencedDriveFileIds(room.def);
const attachedFiles = attachedFileIds.size === 0 ? [] : await this.driveFilesRepository.findBy({ id: In([...attachedFileIds]), userId: room.userId });
return await awaitAll({
id: room.id,
createdAt: this.idService.parse(room.id).date.toISOString(),
updatedAt: room.updatedAt.toISOString(),
userId: room.userId,
user: hint?.packedUser ?? this.userEntityService.pack(room.user ?? room.userId, me),
name: room.name,
description: room.description,
def: room.def,
attachedFiles: this.driveFileEntityService.packMany(attachedFiles),
});
}
@bindThis
public async packLiteMany(
rooms: MiWorldRoom[],
me?: { id: MiUser['id'] } | null | undefined,
) {
const _users = rooms.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(rooms.map(room => this.packLite(room, me, { packedUser: _userMap.get(room.userId) })));
}
}
@@ -5,7 +5,7 @@
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { captureMessage, shutdownTelemetry, startSpan, startSpanWithTraceContext } from './telemetry-registry.js';
import { captureMessage, shutdownTelemetry, startSpan } from './telemetry-registry.js';
import type { OnApplicationShutdown } from '@nestjs/common';
import type { TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
@@ -21,13 +21,6 @@ export class TelemetryService implements OnApplicationShutdown {
return startSpan(name, fn);
}
@bindThis
public startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
// jobData に enqueue 元の context があれば worker span へ復元する。
// context の無い既存ジョブは、通常の startSpan と同じ扱いになる。
return startSpanWithTraceContext(name, jobData, fn);
}
@bindThis
public async onApplicationShutdown(_signal?: string): Promise<void> {
await shutdownTelemetry();
@@ -1,259 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as os from 'node:os';
import cluster from 'node:cluster';
import { envOption } from '@/env.js';
import { registerDiagLogger } from '@/core/telemetry/telemetry-diag.js';
import { installHttpClientInstrumentation } from '@/core/telemetry/http-client-instrumentation.js';
import { installDatabaseInstrumentation } from '@/core/telemetry/database-instrumentation.js';
import { installRedisInstrumentation } from '@/core/telemetry/redis-instrumentation.js';
import { executeSpan, getQueueTraceContextMode, injectActiveTraceContext, recordSpanError, startSpanWithQueueTraceContext } from '@/core/telemetry/queue-trace-context.js';
import type { LogTraceContext } from '@/logging/types.js';
import type { Span, SpanStatusCode, Tracer } from '@opentelemetry/api';
import type { Resource, ResourceDetector } from '@opentelemetry/resources';
import type { ParentBasedSampler, Sampler } from '@opentelemetry/sdk-trace-base';
import type { OtelBackendRuntimeConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
import type { QueueTraceContextCarrier, QueueTraceContextDeps } from '../queue-trace-context.js';
const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
type OpenTelemetryAdapterDeps = {
tracer: Pick<Tracer, 'startActiveSpan'>;
provider: {
shutdown(): Promise<void>;
};
getActiveSpan: () => Span | undefined;
spanStatusCodeError: SpanStatusCode;
shutdownTimeout: number;
shutdownHttpClientInstrumentation?: () => void;
shutdownDatabaseInstrumentation?: () => void;
shutdownRedisInstrumentation?: () => void;
queueTraceContext?: QueueTraceContextDeps;
};
type CreateSamplerDeps = {
ParentBasedSampler: new (config: { root: Sampler }) => ParentBasedSampler;
TraceIdRatioBasedSampler: new (sampleRate: number) => Sampler;
};
type CreateResourceDeps = {
defaultResource: () => Resource;
resourceFromAttributes: (attributes: Record<string, string>) => Resource;
detectResources: (config: { detectors: ResourceDetector[] }) => Resource;
envDetector: ResourceDetector;
serviceNameAttribute: string;
serviceInstanceIdAttribute: string;
serviceVersionAttribute: string;
serviceVersion: string;
};
export class OpenTelemetryAdapter implements TelemetryAdapter {
public constructor(
private readonly deps: OpenTelemetryAdapterDeps,
) {
}
public static async create(config: OtelBackendRuntimeConfig): Promise<OpenTelemetryAdapter> {
const [
{ context, diag, DiagLogLevel, propagation, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace },
{ W3CTraceContextPropagator },
{ OTLPTraceExporter },
{ defaultResource, detectResources, envDetector, resourceFromAttributes },
{ BatchSpanProcessor, ParentBasedSampler, TraceIdRatioBasedSampler },
{ NodeTracerProvider },
{ ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION },
] = await Promise.all([
import('@opentelemetry/api'),
import('@opentelemetry/core'),
import('@opentelemetry/exporter-trace-otlp-proto'),
import('@opentelemetry/resources'),
import('@opentelemetry/sdk-trace-base'),
import('@opentelemetry/sdk-trace-node'),
import('@opentelemetry/semantic-conventions'),
]);
// OTel SDK内部のexport失敗は既定だと見えにくいため、Misskeyのloggerへ橋渡しする。
registerDiagLogger(diag, DiagLogLevel.WARN);
// endpoint/headersを未指定にしておくと、OTEL_EXPORTER_OTLP_* 環境変数の標準fallbackが効く。
const exporter = new OTLPTraceExporter({
...(config.endpoint != null ? { url: config.endpoint } : {}),
...(config.headers != null ? { headers: config.headers } : {}),
});
const spanProcessor = new BatchSpanProcessor(exporter);
// SDK 2.xではSpanProcessorをprovider生成時に渡す。ここでOTel単体用のproviderを作る。
const provider = new NodeTracerProvider({
resource: createResource(config, {
defaultResource,
resourceFromAttributes,
detectResources,
envDetector,
serviceNameAttribute: ATTR_SERVICE_NAME,
serviceInstanceIdAttribute: ATTR_SERVICE_INSTANCE_ID,
serviceVersionAttribute: ATTR_SERVICE_VERSION,
serviceVersion: config.serviceVersion,
}),
...(config.sampleRate != null ? { sampler: createSampler(config.sampleRate, {
ParentBasedSampler,
TraceIdRatioBasedSampler,
}) } : {}),
spanProcessors: [spanProcessor],
});
// HTTP送信には注入しないが、将来のQueue連結でpropagation APIを使える状態にする。
provider.register({
propagator: new W3CTraceContextPropagator(),
});
// provider操作をdepsに閉じ込め、span wrapper本体をユニットテストしやすくする。
const tracer = provider.getTracer('misskey-backend');
return new OpenTelemetryAdapter({
tracer,
provider,
getActiveSpan: () => trace.getActiveSpan(),
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: DEFAULT_SHUTDOWN_TIMEOUT,
shutdownHttpClientInstrumentation: installHttpClientInstrumentation({
tracer,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}),
// pg のrequire hookとioredis diagnostics channelは、Nest moduleの動的importより前に有効化する。
shutdownDatabaseInstrumentation: await installDatabaseInstrumentation(provider, {
capturePgSpans: config.capturePgSpans === true,
capturePgStatement: config.capturePgStatement === true,
capturePgConnectionSpans: config.capturePgConnectionSpans === true,
}),
shutdownRedisInstrumentation: installRedisInstrumentation(tracer, SpanKind.CLIENT, SpanStatusCode.ERROR, {
captureConnectionSpans: config.captureRedisConnectionSpans === true,
captureCommandSpans: config.captureRedisCommandSpans === true,
requireParentSpan: config.captureRedisRootSpans !== true,
}),
queueTraceContext: {
tracer,
propagation,
trace,
getActiveContext: () => context.active(),
rootContext: ROOT_CONTEXT,
mode: getQueueTraceContextMode(config.jobTraceContextMode),
spanStatusCodeError: SpanStatusCode.ERROR,
},
});
}
public captureMessage(message: string, _opts: TelemetryCaptureMessageOptions): void {
// captureMessageは例外通知APIなので、OTelでは対象spanにエラー状態を付ける。
// アクティブspanが無い場合(例: BullMQのjob処理が既に完了しspanが閉じた後の'failed'イベント)でも
// 通知を握り潰さないよう、報告専用の短命spanを作ってそこに記録する。
const span = this.deps.getActiveSpan();
if (span != null) {
recordSpanError(span, new Error(message), this.deps.spanStatusCodeError);
return;
}
this.deps.tracer.startActiveSpan('captureMessage', reportSpan => {
recordSpanError(reportSpan, new Error(message), this.deps.spanStatusCodeError);
reportSpan.end();
});
}
/** activeなSpanの識別子を、Logging基盤で扱える形式へ変換します。 */
public getActiveTraceContext(): LogTraceContext | undefined {
const activeSpan = this.deps.getActiveSpan();
if (activeSpan == null) return undefined;
const { traceId, spanId, traceFlags } = activeSpan.spanContext();
return { traceId, spanId, traceFlags };
}
public startSpan<T>(name: string, fn: () => T): T {
// 既存のTelemetryAdapter契約に合わせ、同期/非同期どちらでも同じspan lifetimeを保証する。
return this.deps.tracer.startActiveSpan(name, span => executeSpan(span, fn, this.deps.spanStatusCodeError));
}
public injectTraceContext(carrier: QueueTraceContextCarrier): void {
const queueTraceContext = this.deps.queueTraceContext;
// Queue context 用の依存は任意なので、無い場合はジョブデータを変更しない。
if (queueTraceContext == null) return;
injectActiveTraceContext(queueTraceContext, carrier);
}
public startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
const queueTraceContext = this.deps.queueTraceContext;
// Queue context 用の依存が無い場合は、従来の span 作成経路と同じ動作を保つ。
if (queueTraceContext == null) return this.startSpan(name, fn);
return startSpanWithQueueTraceContext(queueTraceContext, name, jobData, fn, () => this.startSpan(name, fn));
}
public async shutdown(): Promise<void> {
this.deps.shutdownHttpClientInstrumentation?.();
this.deps.shutdownDatabaseInstrumentation?.();
this.deps.shutdownRedisInstrumentation?.();
// BatchSpanProcessorのflushが詰まってもプロセス終了を妨げないよう、上限時間を設ける。
// タイムアウト側のtimerは、flushが先に終わった場合にイベントループを無駄に引き留めないようclearする。
let timer: NodeJS.Timeout | undefined;
await Promise.race([
this.deps.provider.shutdown(),
new Promise<void>(resolve => {
timer = setTimeout(resolve, this.deps.shutdownTimeout);
}),
]).finally(() => {
if (timer != null) clearTimeout(timer);
});
}
}
export function createResource(config: OtelBackendRuntimeConfig, deps: CreateResourceDeps): Resource {
// resourceを明示指定するとSDKのdefaultResource()は自動付与されなくなる(マージではなく上書き)ため、
// telemetry.sdk.*等の標準属性を失わないよう明示的にmergeする。
const misskeyDefaultResource = deps.resourceFromAttributes({
[deps.serviceNameAttribute]: 'misskey-backend',
[deps.serviceInstanceIdAttribute]: `${os.hostname()}:${process.pid}`,
[deps.serviceVersionAttribute]: deps.serviceVersion,
'misskey.process.role': getMisskeyProcessRole(),
});
// OTel標準の OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES を尊重する。
// mergeは右辺が優先されるため、config.resourceAttributesを最優先にする。
return deps.defaultResource()
.merge(misskeyDefaultResource)
.merge(deps.detectResources({ detectors: [deps.envDetector] }))
.merge(deps.resourceFromAttributes(config.resourceAttributes ?? {}));
}
export function createSampler(sampleRate: number, deps: CreateSamplerDeps): ParentBasedSampler {
// 設定ミスを無言でAlwaysOn/AlwaysOffに倒さず、起動時に明確に失敗させる。
// (YAMLでクォートされた数値文字列などnumber型の保証が無い値が来てもここで弾く)
if (typeof sampleRate !== 'number' || !Number.isFinite(sampleRate) || sampleRate < 0 || sampleRate > 1) {
throw new Error('otelForBackend.sampleRate must be a number between 0.0 and 1.0.');
}
return new deps.ParentBasedSampler({
root: new deps.TraceIdRatioBasedSampler(sampleRate),
});
}
export function getMisskeyProcessRole(): string {
// Trace backend上でserver/queue/workerを見分けられるよう、Misskey固有の役割をresourceに載せる。
if (envOption.disableClustering) {
if (envOption.onlyServer) return 'primary-server';
if (envOption.onlyQueue) return 'primary-queue';
return 'primary-server+queue';
}
if (cluster.isPrimary) {
if (envOption.onlyServer) return 'fork-only';
if (envOption.onlyQueue) return 'primary-queue';
return 'primary-server';
}
// worker.tsのworkerMainに合わせる: onlyServerならserver()、それ以外はjobQueue()を実行する。
if (envOption.onlyServer) return 'worker-server';
return 'worker-queue';
}
@@ -3,18 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import Logger from '@/logger.js';
import { registerDiagLogger } from '@/core/telemetry/telemetry-diag.js';
import { getQueueTraceContextMode, injectActiveTraceContext, startSpanWithQueueTraceContext } from '@/core/telemetry/queue-trace-context.js';
import type { LogTraceContext } from '@/logging/types.js';
import type * as SentryNode from '@sentry/node';
import type { NodeOptions } from '@sentry/node';
import type { OtelBackendRuntimeConfig, SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
import type { QueueTraceContextCarrier, QueueTraceContextDeps } from '../queue-trace-context.js';
import type { SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
// OpenTelemetryAdapterのDEFAULT_SHUTDOWN_TIMEOUTと揃え、Sentryのtransportが詰まってもプロセス終了を妨げないようにする。
// Sentryのtransportが詰まってもプロセス終了を妨げないようにする。
const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
const logger = new Logger('telemetry', 'green');
type SentryIntegrationsOption = NonNullable<NodeOptions['integrations']>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -73,50 +68,9 @@ export function buildSentryNodeOptions(
};
}
type BuildSentryOtlpInitOptions = {
sentryConfig: SentryBackendConfig;
otelConfig: OtelBackendRuntimeConfig;
otlpProcessor: unknown;
nodeProfilingIntegration?: () => SentryIntegration;
warn?: (message: string) => void;
};
export function buildSentryOtlpInitOptions(options: BuildSentryOtlpInitOptions): SentryNodeOptions {
// OTel併存時も、remoteへtrace headerを漏らさないデフォルトはSentry単体時と揃える。
// propagateTraceToRemote: true か、options.tracePropagationTargets の明示指定がある場合のみ既定を上書きする。
const { tracePropagationTargets, ...sentryOptions } = options.sentryConfig.options;
const propagateTraceToRemote = options.otelConfig.propagateTraceToRemote === true || tracePropagationTargets != null;
const warn = options.warn ?? ((message: string) => logger.warn(message));
if (options.otelConfig.sampleRate != null) {
warn('otelForBackend.sampleRate is ignored when sentryForBackend is also configured; configure sentryForBackend.options.tracesSampleRate or tracesSampler instead.');
}
if (options.otelConfig.resourceAttributes != null) {
warn('otelForBackend.resourceAttributes is ignored when sentryForBackend is also configured; configure OTEL_RESOURCE_ATTRIBUTES instead.');
}
return {
...buildSentryNodeOptions({
...options.sentryConfig,
options: {
...sentryOptions,
...(propagateTraceToRemote ? { tracePropagationTargets } : {}),
},
}, options.nodeProfilingIntegration),
// Sentryの単一TracerProviderにOTLP processorを追加し、親欠損や二重providerを避ける。
openTelemetrySpanProcessors: [
...(options.sentryConfig.options.openTelemetrySpanProcessors ?? []),
options.otlpProcessor as NonNullable<SentryNodeOptions['openTelemetrySpanProcessors']>[number],
],
};
}
export class SentryTelemetryAdapter implements TelemetryAdapter {
private constructor(
private readonly Sentry: typeof SentryNode,
private readonly queueTraceContext?: QueueTraceContextDeps,
) {
}
@@ -129,45 +83,6 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
return new SentryTelemetryAdapter(Sentry);
}
public static async createWithOtlpExport(
sentryConfig: SentryBackendConfig,
otelConfig: OtelBackendRuntimeConfig,
): Promise<SentryTelemetryAdapter> {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
const { context, diag, DiagLogLevel, propagation, ROOT_CONTEXT, SpanStatusCode, trace } = await import('@opentelemetry/api');
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-base');
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-proto');
registerDiagLogger(diag, DiagLogLevel.WARN);
// OTLP送信だけを担うprocessorを作り、provider生成はSentry.init側に任せる。
const otlpProcessor = new BatchSpanProcessor(new OTLPTraceExporter({
...(otelConfig.endpoint != null ? { url: otelConfig.endpoint } : {}),
...(otelConfig.headers != null ? { headers: otelConfig.headers } : {}),
}));
// SentryとOTLPを同一providerに集約することで、どちらの宛先にも同じspan実体を流す。
Sentry.init(buildSentryOtlpInitOptions({
sentryConfig,
otelConfig,
otlpProcessor,
nodeProfilingIntegration,
}));
// Sentry が初期化した同じ OTel provider から tracer/context API を受け取り、
// Queue を跨ぐ context 伝播も Sentry と OTLP の両方へ同一 span として出力する。
return new SentryTelemetryAdapter(Sentry, {
tracer: trace.getTracer('misskey-backend'),
propagation,
trace,
getActiveContext: () => context.active(),
rootContext: ROOT_CONTEXT,
mode: getQueueTraceContextMode(otelConfig.jobTraceContextMode),
spanStatusCodeError: SpanStatusCode.ERROR,
});
}
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
this.Sentry.captureMessage(message, {
level: opts.level,
@@ -189,19 +104,6 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
return this.Sentry.startSpan({ name }, fn);
}
public injectTraceContext(carrier: QueueTraceContextCarrier): void {
// Sentry 単体構成では queueTraceContext を持たず、従来どおりジョブデータを変更しない。
if (this.queueTraceContext == null) return;
injectActiveTraceContext(this.queueTraceContext, carrier);
}
public startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
// Sentry 単体構成では Sentry 既存の span 作成経路を使う。
if (this.queueTraceContext == null) return this.startSpan(name, fn);
return startSpanWithQueueTraceContext(this.queueTraceContext, name, jobData, fn, () => this.startSpan(name, fn));
}
public async shutdown(): Promise<void> {
// timeout未指定だとtransportのflushが詰まった際にプロセス終了を妨げるため、上限時間を設ける。
await this.Sentry.close(DEFAULT_SHUTDOWN_TIMEOUT);
@@ -5,19 +5,14 @@
import type { Config } from '@/config.js';
import type { LogTraceContext } from '@/logging/types.js';
import type { QueueTraceContextCarrier } from '../queue-trace-context.js';
export type SentryBackendConfig = NonNullable<Config['sentryForBackend']>;
export type OtelBackendConfig = NonNullable<Config['otelForBackend']>;
export type OtelBackendRuntimeConfig = OtelBackendConfig & {
serviceVersion: string;
};
export interface TelemetryCaptureMessageOptions {
/** 現在はエラー通知用途だけに絞る。追加する場合は各adapterでの扱いを揃えること。 */
level: 'error';
/** Sentryではuser.idへ渡す。OTel adapterは現在span属性へ付与していないため、必要ならadapter側で拡張する。 */
/** Sentryではuser.idへ渡す補助情報です。 */
userId?: string;
/** queue名やendpoint名など、通知先で調査に使う補助情報。 */
@@ -25,14 +20,14 @@ export interface TelemetryCaptureMessageOptions {
}
/**
* SentryOpenTelemetryなど/APMサービスごとの実装差異を隠蔽するための抽象
* /APMサービスごとの実装差異を隠蔽するための抽象
*
* telemetry-registry.tsのinitTelemetry内で登録する
*/
export interface TelemetryAdapter {
/**
*
* Sentryはmessage通知OTelはactive spanまたは短命spanへの例外記録として扱う
* Sentryはmessage通知として扱う
*/
captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void;
@@ -45,18 +40,6 @@ export interface TelemetryAdapter {
*/
startSpan<T>(name: string, fn: () => T): T;
/**
* BullMQ carrier active trace context
* OTel 使 adapter
*/
injectTraceContext?(carrier: QueueTraceContextCarrier): void;
/**
* enqueue context worker span Link parent
* context adapter
*/
startSpanWithTraceContext?<T>(name: string, jobData: object, fn: () => T): T;
/**
* telemetry backendへ残りのデータをflushする
* transport停止に引きずられないよう
@@ -1,86 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Span, TracerProvider } from '@opentelemetry/api';
type Instrumentation = {
setTracerProvider(provider: TracerProvider): void;
enable(): void;
disable(): void;
};
type PgInstrumentationConfig = {
enhancedDatabaseReporting: boolean;
requireParentSpan: boolean;
ignoreConnectSpans: boolean;
requestHook?(span: Span): void;
};
type InstrumentationConstructor = new (config: PgInstrumentationConfig) => Instrumentation;
type InstrumentationDeps = {
PgInstrumentation: InstrumentationConstructor;
};
type DatabaseInstrumentationOptions = {
capturePgStatement: boolean;
capturePgConnectionSpans: boolean;
};
type InstallDatabaseInstrumentationOptions = DatabaseInstrumentationOptions & {
capturePgSpans: boolean;
};
/**
* pg import require hook
* patch
* telemetry provider
*/
export async function installDatabaseInstrumentation(provider: TracerProvider, options: InstallDatabaseInstrumentationOptions): Promise<() => void> {
if (!options.capturePgSpans) return () => {};
const { PgInstrumentation } = await import('@opentelemetry/instrumentation-pg');
return installInstrumentation(provider, { PgInstrumentation }, options);
}
export function installInstrumentation(provider: TracerProvider, deps: InstrumentationDeps, options: DatabaseInstrumentationOptions = {
capturePgStatement: false,
capturePgConnectionSpans: false,
}): () => void {
const instrumentations = [
new deps.PgInstrumentation({
// SQLパラメータには投稿内容・認証情報などが含まれ得るため、常に記録しない。
enhancedDatabaseReporting: false,
requireParentSpan: true,
ignoreConnectSpans: !options.capturePgConnectionSpans,
// instrumentation-pgはSQL本文を無加工で属性へ追加する。明示opt-in時だけ残す。
...(options.capturePgStatement ? {} : {
requestHook: (span: Span) => {
span.setAttribute('db.statement', '[REDACTED]');
span.setAttribute('db.query.text', '[REDACTED]');
},
}),
}),
];
try {
for (const instrumentation of instrumentations) {
instrumentation.setTracerProvider(provider);
instrumentation.enable();
}
} catch (error) {
for (const instrumentation of instrumentations) {
instrumentation.disable();
}
throw error;
}
return () => {
for (const instrumentation of instrumentations) {
instrumentation.disable();
}
};
}
@@ -1,122 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { channel } from 'node:diagnostics_channel';
import type { ClientRequest, IncomingMessage } from 'node:http';
import type { Span, SpanOptions, SpanStatusCode, Tracer } from '@opentelemetry/api';
const HTTP_CLIENT_REQUEST_CREATED = 'http.client.request.created';
const HTTP_CLIENT_RESPONSE_FINISH = 'http.client.response.finish';
const HTTP_CLIENT_REQUEST_ERROR = 'http.client.request.error';
type HttpClientSpan = Pick<Span, 'end' | 'recordException' | 'setAttribute' | 'setStatus'>;
type HttpClientInstrumentationDeps = {
tracer: Pick<Tracer, 'startSpan'>;
spanKindClient: SpanOptions['kind'];
spanStatusCodeError: SpanStatusCode;
subscribe: (name: string, listener: (message: unknown) => void) => () => void;
};
type RequestCreatedMessage = { request: ClientRequest };
type ResponseFinishMessage = { request: ClientRequest; response: IncomingMessage };
type RequestErrorMessage = { request: ClientRequest; error: Error };
/**
* require 使Node.js HTTP diagnostics channel
* telemetry
*/
export function createHttpClientInstrumentation(deps: HttpClientInstrumentationDeps): () => void {
const spans = new WeakMap<ClientRequest, HttpClientSpan>();
const unsubscribeCreated = deps.subscribe(HTTP_CLIENT_REQUEST_CREATED, (message: unknown) => {
const { request } = message as RequestCreatedMessage;
const { url, host, port } = getRequestDetails(request);
const method = request.method ?? 'GET';
const span = deps.tracer.startSpan(method, {
kind: deps.spanKindClient,
attributes: {
'http.request.method': method,
'url.full': url,
'server.address': host,
'server.port': port,
},
});
spans.set(request, span);
});
const unsubscribeResponseFinish = deps.subscribe(HTTP_CLIENT_RESPONSE_FINISH, (message: unknown) => {
const { request, response } = message as ResponseFinishMessage;
const span = spans.get(request);
if (span == null) return;
const statusCode = response.statusCode;
if (statusCode != null) {
span.setAttribute('http.response.status_code', statusCode);
}
if (response.httpVersion != null) {
span.setAttribute('network.protocol.version', response.httpVersion);
}
if (statusCode != null && statusCode >= 400) {
span.setAttribute('error.type', String(statusCode));
span.setStatus({ code: deps.spanStatusCodeError });
}
span.end();
spans.delete(request);
});
const unsubscribeRequestError = deps.subscribe(HTTP_CLIENT_REQUEST_ERROR, (message: unknown) => {
const { request, error } = message as RequestErrorMessage;
const span = spans.get(request);
if (span == null) return;
span.recordException(error);
span.setAttribute('error.type', getErrorType(error));
span.setStatus({ code: deps.spanStatusCodeError });
span.end();
spans.delete(request);
});
return () => {
unsubscribeCreated();
unsubscribeResponseFinish();
unsubscribeRequestError();
};
}
export function installHttpClientInstrumentation(deps: Omit<HttpClientInstrumentationDeps, 'subscribe'>): () => void {
return createHttpClientInstrumentation({
...deps,
subscribe: (name, listener) => {
const diagnosticChannel = channel(name);
diagnosticChannel.subscribe(listener);
return () => diagnosticChannel.unsubscribe(listener);
},
});
}
function getRequestDetails(request: ClientRequest): { url: string; host: string; port: number } {
const protocol = request.protocol ?? 'http:';
const host = request.getHeader('host')?.toString() ?? request.host ?? 'localhost';
const url = new URL(request.path || '/', `${protocol}//${host}`);
// URL 属性には認証情報やクエリ文字列を含めない。
url.username = '';
url.password = '';
url.search = '';
url.hash = '';
return {
url: url.toString(),
host: url.hostname,
// URL.port は既定ポートでは空文字列になるため、スキームから補う。
port: url.port === '' ? (url.protocol === 'https:' ? 443 : 80) : Number(url.port),
};
}
function getErrorType(error: Error): string {
// Node.js の system error code は安定した低カーディナリティの識別子になる。
const code = (error as NodeJS.ErrnoException).code;
return code ?? error.name;
}
@@ -1,33 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { injectTraceContext } from './telemetry-registry.js';
import { injectQueueTraceContext } from './queue-trace-context.js';
import type * as Bull from 'bullmq';
/**
* Queue add/addBulk BullMQ enqueue
* QueueService add/addBulk
*/
export function instrumentQueue<T extends object>(queue: Bull.Queue<T>): Bull.Queue<T> {
// BullMQ のメソッドは Queue インスタンスを this として使うため、差し替え前に bind して保持する。
const add = queue.add.bind(queue);
queue.add = ((name, data, opts) => {
// BullMQ が data を Redis 用にシリアライズする前に、enqueue 元の context を内部フィールドへ追加する。
injectQueueTraceContext(data, injectTraceContext);
return add(name, data, opts);
}) as typeof queue.add;
// addBulk は複数ジョブを一度にシリアライズするので、各 data へ同じ context を注入する。
const addBulk = queue.addBulk.bind(queue);
queue.addBulk = ((jobs) => {
for (const job of jobs) {
injectQueueTraceContext(job.data, injectTraceContext);
}
return addBulk(jobs);
}) as typeof queue.addBulk;
return queue;
}
@@ -1,182 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Context, PropagationAPI, Span, SpanContext, SpanOptions, SpanStatusCode, Tracer } from '@opentelemetry/api';
/*
* enqueue (push) worker (pop)
* Queue
* trace context :
*
* 1. producer active context W3C Trace Context carrier
* 2. carrier BullMQ/Redis worker
* 3. worker carrier context Link parent 使
*
* OTel API OTel Sentry + OTLP
* 使
*/
/** Redis 上のジョブデータにだけ保存する、OpenTelemetry propagator 用の carrier。 */
export type QueueTraceContextCarrier = Record<string, string>;
export type QueueTraceContextMode = 'link' | 'parent';
// 通常のジョブプロセッサが参照しない Misskey 内部フィールドとして、ユーザー定義の data と区別する。
const QUEUE_TRACE_CONTEXT_KEY = '__misskeyTraceContext';
export type QueueTraceContextDeps = {
tracer: Pick<Tracer, 'startActiveSpan'>;
propagation: Pick<PropagationAPI, 'extract' | 'inject'>;
trace: {
getSpanContext(context: Context): SpanContext | undefined;
};
getActiveContext: () => Context;
rootContext: Context;
mode: QueueTraceContextMode;
spanStatusCodeError: SpanStatusCode;
};
export type QueueSpanContext = {
options: SpanOptions;
parentContext: Context;
};
type QueueSpanContextDeps = Pick<QueueTraceContextDeps, 'propagation' | 'trace' | 'rootContext' | 'mode'>;
/**
* enqueue active context
* propagator Redis
*/
export function injectQueueTraceContext(data: unknown, inject: (carrier: QueueTraceContextCarrier) => void): void {
// BullMQ の型定義外から渡される値も考慮し、書き込めない値は無視する。
if (data == null || typeof data !== 'object') return;
const carrier: QueueTraceContextCarrier = {};
inject(carrier);
// active span が無い場合、propagator は何も注入しない。空の内部フィールドは Redis に保存しない。
if (Object.keys(carrier).length === 0) return;
Object.assign(data, { [QUEUE_TRACE_CONTEXT_KEY]: carrier });
}
/** 現在実行中の span を propagator の標準形式で carrier へ書き出す。 */
export function injectActiveTraceContext(deps: QueueTraceContextDeps, carrier: QueueTraceContextCarrier): void {
deps.propagation.inject(deps.getActiveContext(), carrier);
}
/**
* carrier worker span context options
*
* - parent: enqueue span trace
* - link: worker span root trace enqueue span Link
*
* link trace worker sampling enqueue
*/
export function getQueueSpanContext(data: unknown, deps: QueueSpanContextDeps): QueueSpanContext | undefined {
const carrier = getQueueTraceContextCarrier(data);
if (carrier == null) return undefined;
// Redis から復元した carrier は別プロセス由来なので、現在の context ではなく ROOT_CONTEXT から展開する。
const extractedContext = deps.propagation.extract(deps.rootContext, carrier);
if (deps.mode === 'parent') {
return {
options: {},
parentContext: extractedContext,
};
}
// Link が受け取るのは Context ではなく SpanContext なので、extract 後に取り出す。
const spanContext = deps.trace.getSpanContext(extractedContext);
return {
options: {
root: true,
...(spanContext != null ? { links: [{ context: spanContext }] } : {}),
},
parentContext: deps.rootContext,
};
}
/**
* context Link/parent span
* enqueue context adapter
*/
export function startSpanWithQueueTraceContext<T>(
deps: QueueTraceContextDeps,
name: string,
jobData: object,
fn: () => T,
fallback: () => T,
): T {
const spanContext = getQueueSpanContext(jobData, deps);
if (spanContext == null) return fallback();
return deps.tracer.startActiveSpan(name, spanContext.options, spanContext.parentContext, span => executeSpan(span, fn, deps.spanStatusCodeError));
}
/**
* TelemetryAdapter Promise span
* span
*/
export function executeSpan<T>(span: Span, fn: () => T, spanStatusCodeError: SpanStatusCode): T {
try {
const result = fn();
if (isPromiseLike(result)) {
// fn() の戻り値は T のまま保ちつつ、Promise の settle 時に span を閉じる。
return result.then(
value => {
span.end();
return value;
},
error => {
recordSpanError(span, error, spanStatusCodeError);
span.end();
throw error;
},
) as T;
}
span.end();
return result;
} catch (error) {
// fn() が同期的に throw した場合も、Promise の reject と同じ形で記録して呼び出し元へ戻す。
recordSpanError(span, error, spanStatusCodeError);
span.end();
throw error;
}
}
/** Error 以外の throw 値も OTel exporter が扱える例外に正規化し、span をエラー状態にする。 */
export function recordSpanError(span: Span, error: unknown, spanStatusCodeError: SpanStatusCode): void {
const exception = error instanceof Error ? error : new Error(String(error));
span.recordException(exception);
span.setStatus({
code: spanStatusCodeError,
message: exception.message,
});
}
/**
* enqueue worker sampling link 使
*
*/
export function getQueueTraceContextMode(mode: unknown): QueueTraceContextMode {
if (mode == null || mode === 'link') return 'link';
if (mode === 'parent') return 'parent';
throw new Error('otelForBackend.jobTraceContextMode must be either \'link\' or \'parent\'.');
}
function getQueueTraceContextCarrier(data: unknown): QueueTraceContextCarrier | undefined {
if (data == null || typeof data !== 'object') return undefined;
const carrier = (data as Record<string, unknown>)[QUEUE_TRACE_CONTEXT_KEY];
if (carrier == null || typeof carrier !== 'object' || Array.isArray(carrier)) return undefined;
// Redis 上の旧いジョブや壊れたデータで worker を落とさないよう、propagator に渡せる string map だけを受け入れる。
const entries = Object.entries(carrier);
if (entries.length === 0 || entries.some(([, value]) => typeof value !== 'string')) return undefined;
return Object.fromEntries(entries) as QueueTraceContextCarrier;
}
function isPromiseLike<T>(value: T): value is T & PromiseLike<Awaited<T>> {
// native Promise に限らず thenable も span の完了を待てるよう、instanceof ではなく then の有無で判定する。
return value != null && typeof (value as { then?: unknown }).then === 'function';
}
@@ -1,178 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { tracingChannel } from 'node:diagnostics_channel';
import { context, trace } from '@opentelemetry/api';
import type { Span, SpanKind, SpanStatusCode, Tracer } from '@opentelemetry/api';
type IORedisCommandContext = {
command: string;
args: string[];
database: number;
serverAddress: string;
serverPort: number | undefined;
};
type IORedisConnectContext = {
serverAddress: string;
serverPort: number | undefined;
};
type TracingChannelSubscribers<T extends object> = {
start(message: T): void;
end(message: T & { error?: unknown }): void;
asyncStart(message: T & { error?: unknown }): void;
asyncEnd(message: T & { error?: unknown }): void;
error(message: T & { error: unknown }): void;
};
type TracingChannel<T extends object> = {
subscribe(subscribers: TracingChannelSubscribers<T>): void;
unsubscribe(subscribers: TracingChannelSubscribers<T>): void;
};
type RedisInstrumentationDeps = {
tracingChannel<T extends object>(name: string): TracingChannel<T>;
tracer: Pick<Tracer, 'startSpan'>;
getActiveSpan(): Span | undefined;
spanKindClient: SpanKind;
spanStatusCodeError: SpanStatusCode;
};
type RedisInstrumentationOptions = {
captureCommandSpans?: boolean;
/** requireParentSpan is the official ioredis instrumentation's default. */
requireParentSpan?: boolean;
captureConnectionSpans?: boolean;
};
/**
* ioredis 5.11 native diagnostics channel
* ioredis自身が発行するイベントを使うためrequire hook
* ESM import span
*/
export function installRedisInstrumentation(
tracer: Pick<Tracer, 'startSpan'>,
spanKindClient: SpanKind,
spanStatusCodeError: SpanStatusCode,
options: RedisInstrumentationOptions = {},
): () => void {
return createRedisInstrumentation({
tracingChannel,
tracer,
getActiveSpan: () => trace.getSpan(context.active()),
spanKindClient,
spanStatusCodeError,
}, options);
}
export function createRedisInstrumentation(deps: RedisInstrumentationDeps, options: RedisInstrumentationOptions = {}): () => void {
const requireParentSpan = options.requireParentSpan ?? true;
const cleanup: Array<() => void> = [];
if (options.captureCommandSpans === true) {
const commandChannel = deps.tracingChannel<IORedisCommandContext>('ioredis:command');
const commandSubscribers = createTracingChannelSubscribers(commandChannel, deps, requireParentSpan, message => ({
name: message.command,
attributes: {
'db.system.name': 'redis',
'db.namespace': message.database.toString(10),
'db.operation.name': message.command,
'server.address': message.serverAddress,
...(message.serverPort != null ? { 'server.port': message.serverPort } : {}),
},
}));
cleanup.push(() => commandChannel.unsubscribe(commandSubscribers));
}
if (options.captureConnectionSpans === true) {
const connectChannel = deps.tracingChannel<IORedisConnectContext>('ioredis:connect');
// Connection spans are explicitly opt-in and should include startup and reconnect attempts.
const connectSubscribers = createTracingChannelSubscribers(connectChannel, deps, false, message => ({
name: 'connect',
attributes: {
'db.system.name': 'redis',
'db.operation.name': 'connect',
'server.address': message.serverAddress,
...(message.serverPort != null ? { 'server.port': message.serverPort } : {}),
},
}));
cleanup.push(() => connectChannel.unsubscribe(connectSubscribers));
}
return () => {
for (const unsubscribe of cleanup) {
unsubscribe();
}
};
}
function createTracingChannelSubscribers<T extends object>(
channel: TracingChannel<T>,
deps: RedisInstrumentationDeps,
requireParentSpan: boolean,
getSpanOptions: (message: T) => { name: string; attributes: Record<string, string | number> },
): TracingChannelSubscribers<T> {
const spans = new WeakMap<object, { span: Span; recordedError: boolean }>();
const recordError = (state: { span: Span; recordedError: boolean }, value: unknown): void => {
if (state.recordedError) return;
const error = toError(value);
state.span.recordException(error);
state.span.setStatus({ code: deps.spanStatusCodeError, message: error.message });
state.span.setAttribute('error.type', getErrorType(error));
const statusCode = getRedisErrorStatusCode(error.message);
if (statusCode != null) state.span.setAttribute('db.response.status_code', statusCode);
state.recordedError = true;
};
const finish = (message: T & { error?: unknown }): void => {
const state = spans.get(message);
if (state == null) return;
if (message.error != null) {
recordError(state, message.error);
}
state.span.end();
spans.delete(message);
};
const subscribers: TracingChannelSubscribers<T> = {
start: (message) => {
if (requireParentSpan && deps.getActiveSpan() == null) return;
const options = getSpanOptions(message);
const span = deps.tracer.startSpan(options.name, {
kind: deps.spanKindClient,
attributes: options.attributes,
});
spans.set(message, { span, recordedError: false });
},
// Promiseを返すコマンドでは完了前にもendイベントが来るため、同期例外だけここで閉じる。
end: (message) => {
if (message.error != null) finish(message);
},
asyncStart: () => {},
asyncEnd: finish,
error: (message) => {
const state = spans.get(message);
if (state == null) return;
recordError(state, message.error);
},
};
channel.subscribe(subscribers);
return subscribers;
}
function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
function getRedisErrorStatusCode(message: string): string | undefined {
return message.match(/^([A-Z][A-Z0-9_]*)\b/)?.[1];
}
function getErrorType(error: Error): string {
return (error as NodeJS.ErrnoException).code ?? error.name;
}
@@ -1,40 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import Logger from '@/logger.js';
import type { DiagAPI, DiagLogger, DiagLogLevel } from '@opentelemetry/api';
export function registerDiagLogger(
diagApi: DiagAPI,
diagLogLevelWarn: DiagLogLevel,
): void {
// diagはプロセスグローバルなので、通常運用で必要なWARN以上だけをMisskeyのログに流す。
const logger = new Logger('otel', 'green');
const diagLogger: DiagLogger = {
error: (message, ...args) => logger.error(formatDiagMessage(message, args)),
warn: (message, ...args) => logger.warn(formatDiagMessage(message, args)),
info: (message, ...args) => logger.info(formatDiagMessage(message, args)),
debug: (message, ...args) => logger.debug(formatDiagMessage(message, args)),
verbose: (message, ...args) => logger.debug(formatDiagMessage(message, args)),
};
diagApi.setLogger(diagLogger, {
logLevel: diagLogLevelWarn,
suppressOverrideMessage: true,
});
}
function formatDiagMessage(message: string, args: unknown[]): string {
if (args.length === 0) return message;
return `${message} ${args.map(arg => {
if (arg instanceof Error) return arg.stack ?? arg.message;
if (typeof arg === 'string') return arg;
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}).join(' ')}`;
}
@@ -5,10 +5,8 @@
import type { Config } from '@/config.js';
import { setLogTraceContextProvider } from '@/logging/logging-runtime.js';
import { OpenTelemetryAdapter } from './adapters/OpenTelemetryAdapter.js';
import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js';
import type { OtelBackendRuntimeConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
import type { QueueTraceContextCarrier } from './queue-trace-context.js';
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
/**
* NestのDIコンテナが構築される前(boot処理内)
@@ -18,24 +16,8 @@ import type { QueueTraceContextCarrier } from './queue-trace-context.js';
const adapters: TelemetryAdapter[] = [];
export async function initTelemetry(config: Config): Promise<void> {
const otelForBackend: OtelBackendRuntimeConfig | undefined = config.otelForBackend == null ? undefined : {
...config.otelForBackend,
serviceVersion: config.version,
};
// SentryとOTelを同時に使う場合はproviderを分けず、Sentry側へOTLP processorを追加する。
let adapter: TelemetryAdapter | undefined;
if (config.sentryForBackend && otelForBackend) {
adapter = await SentryTelemetryAdapter.createWithOtlpExport(config.sentryForBackend, otelForBackend);
} else if (config.sentryForBackend) {
// Sentry単体時は既存のSentry adapterだけを登録する。
adapter = await SentryTelemetryAdapter.create(config.sentryForBackend);
} else if (otelForBackend) {
// OTel単体時だけMisskey自身でNodeTracerProviderを立てる。
adapter = await OpenTelemetryAdapter.create(otelForBackend);
}
if (adapter != null) {
if (config.sentryForBackend) {
const adapter = await SentryTelemetryAdapter.create(config.sentryForBackend);
adapters.push(adapter);
// Telemetryの初期化後に登録し、初期化前のBootstrapログは従来どおり出力する。
setLogTraceContextProvider(() => adapter.getActiveTraceContext?.());
@@ -63,19 +45,6 @@ export function startSpan<T>(name: string, fn: () => T): T {
return wrapped();
}
export function injectTraceContext(carrier: QueueTraceContextCarrier): void {
// Queue の carrier は共有データなので、通知と異なり全 adapter にブロードキャストしない。
// OTel provider は現在 1 つだけなので、同じ header を上書きしないよう最初の対応 adapter だけを使う。
adapters.find(adapter => adapter.injectTraceContext != null)?.injectTraceContext?.(carrier);
}
export function startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
// Queue context を解釈できる adapter に span 作成を任せる。
// 対応 adapter が無い構成では通常の startSpan へフォールバックする。
const adapter = adapters.find(adapter => adapter.startSpanWithTraceContext != null);
return adapter?.startSpanWithTraceContext?.(name, jobData, fn) ?? startSpan(name, fn);
}
export async function shutdownTelemetry(): Promise<void> {
// 終了時は登録済みadapterを並列にflush/shutdownする。
await Promise.allSettled(adapters.map(adapter => adapter.shutdown()));
@@ -1,11 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Telemetry-specific shutdown is implemented by telemetry-registry.ts.
* Signal coordination belongs to boot/shutdown-handler.ts so telemetry and
* logging remain independent domains.
*/
export { shutdownTelemetry } from './telemetry-registry.js';
+2
View File
@@ -91,5 +91,7 @@ export const DI = {
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
noteDraftsRepository: Symbol('noteDraftsRepository'),
worldRoomsRepository: Symbol('worldRoomsRepository'),
worldAvatarsRepository: Symbol('worldAvatarsRepository'),
//#endregion
};
+6
View File
@@ -75,6 +75,8 @@ import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-i
import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js';
import { packedNoteDraftSchema } from '@/models/json-schema/note-draft.js';
import { packedWorldRoomDetailedSchema, packedWorldRoomLiteSchema } from '@/models/json-schema/world-room.js';
import { packedWorldAvatarDetailedSchema, packedWorldAvatarLiteSchema } from '@/models/json-schema/world-avatar.js';
export const refs = {
UserLite: packedUserLiteSchema,
@@ -147,6 +149,10 @@ export const refs = {
ChatRoom: packedChatRoomSchema,
ChatRoomInvitation: packedChatRoomInvitationSchema,
ChatRoomMembership: packedChatRoomMembershipSchema,
WorldRoomLite: packedWorldRoomLiteSchema,
WorldRoomDetailed: packedWorldRoomDetailedSchema,
WorldAvatarLite: packedWorldAvatarLiteSchema,
WorldAvatarDetailed: packedWorldAvatarDetailedSchema,
};
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
+6
View File
@@ -20,6 +20,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public mentionedUserIds: MiUser['id'][];
@@ -32,6 +33,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public mentionedLocalUserIds: MiUser['id'][];
@@ -44,6 +46,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public mentionedRemoteUserIds: MiUser['id'][];
@@ -56,6 +59,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public attachedUserIds: MiUser['id'][];
@@ -68,6 +72,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public attachedLocalUserIds: MiUser['id'][];
@@ -80,6 +85,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public attachedRemoteUserIds: MiUser['id'][];
@@ -84,6 +84,8 @@ import {
MiChatRoomMembership,
MiChatRoomInvitation,
MiChatApproval,
MiWorldRoom,
MiWorldAvatar,
} from './_.js';
import type { Provider } from '@nestjs/common';
import type { DataSource } from 'typeorm';
@@ -544,6 +546,18 @@ const $reversiGamesRepository: Provider = {
inject: [DI.db],
};
const $worldRoomsRepository: Provider = {
provide: DI.worldRoomsRepository,
useFactory: (db: DataSource) => db.getRepository(MiWorldRoom).extend(miRepository as MiRepository<MiWorldRoom>),
inject: [DI.db],
};
const $worldAvatarsRepository: Provider = {
provide: DI.worldAvatarsRepository,
useFactory: (db: DataSource) => db.getRepository(MiWorldAvatar).extend(miRepository as MiRepository<MiWorldAvatar>),
inject: [DI.db],
};
@Module({
imports: [],
providers: [
@@ -623,6 +637,8 @@ const $reversiGamesRepository: Provider = {
$chatApprovalsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
$worldRoomsRepository,
$worldAvatarsRepository,
],
exports: [
$usersRepository,
@@ -701,6 +717,8 @@ const $reversiGamesRepository: Provider = {
$chatApprovalsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
$worldRoomsRepository,
$worldAvatarsRepository,
],
})
export class RepositoryModule {
@@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
@Entity('world_avatar')
export class MiWorldAvatar {
@PrimaryColumn(id())
public id: string;
@Index()
@Column('timestamp with time zone', {
})
public updatedAt: Date;
@Column('varchar', {
length: 256,
})
public name: string;
@Index()
@Column({
...id(),
})
public userId: MiUser['id'];
@ManyToOne(() => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
@Column('boolean', {
default: false,
})
public active: boolean;
@Column('jsonb', {
default: {},
})
public def: Record<string, any>;
constructor(data: Partial<MiWorldAvatar>) {
if (data == null) return;
for (const [k, v] of Object.entries(data)) {
(this as any)[k] = v;
}
}
}
+72
View File
@@ -0,0 +1,72 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
export const worldRoomVisibility = ['public', 'private'] as const;
export type WorldRoomVisibility = typeof worldRoomVisibility[number];
@Entity('world_room')
export class MiWorldRoom {
@PrimaryColumn(id())
public id: string;
@Index()
@Column('timestamp with time zone', {
})
public updatedAt: Date;
@Column('varchar', {
length: 256,
})
public name: string;
@Column('varchar', {
length: 1024,
})
public description: string;
@Index()
@Column({
...id(),
})
public userId: MiUser['id'];
@ManyToOne(() => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
@Column('integer', {
default: 0,
})
public likedCount: number;
@Column('integer', {
default: 0,
})
public accessCount: number;
@Column('varchar', {
length: 128, default: 'public',
})
public visibility: WorldRoomVisibility;
@Column('jsonb', {
default: {},
})
public def: Record<string, any>;
constructor(data: Partial<MiWorldRoom>) {
if (data == null) return;
for (const [k, v] of Object.entries(data)) {
(this as any)[k] = v;
}
}
}
+7 -1
View File
@@ -23,7 +23,7 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiChannel } from '@/models/Channel.js';
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
import { MiChannelMuting } from "@/models/ChannelMuting.js";
import { MiChannelMuting } from '@/models/ChannelMuting.js';
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
@@ -84,6 +84,8 @@ import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserPublickey } from '@/models/UserPublickey.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import { MiWebhook } from '@/models/Webhook.js';
import { MiWorldRoom } from '@/models/WorldRoom.js';
import { MiWorldAvatar } from '@/models/WorldAvatar.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
export interface MiRepository<T extends ObjectLiteral> {
@@ -173,6 +175,8 @@ export {
MiChatApproval,
MiBubbleGameRecord,
MiReversiGame,
MiWorldRoom,
MiWorldAvatar,
};
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
@@ -253,3 +257,5 @@ export type ChatRoomInvitationsRepository = Repository<MiChatRoomInvitation> & M
export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<MiChatApproval>;
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
export type WorldRoomsRepository = Repository<MiWorldRoom> & MiRepository<MiWorldRoom>;
export type WorldAvatarsRepository = Repository<MiWorldAvatar> & MiRepository<MiWorldAvatar>;
@@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const packedWorldAvatarLiteSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
def: {
type: 'object',
optional: false, nullable: false,
},
},
} as const;
export const packedWorldAvatarDetailedSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
name: {
type: 'string',
optional: false, nullable: false,
},
def: {
type: 'object',
optional: false, nullable: false,
},
active: {
type: 'boolean',
optional: false, nullable: false,
},
},
} as const;
@@ -0,0 +1,95 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const packedWorldRoomLiteSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
name: {
type: 'string',
optional: false, nullable: false,
},
description: {
type: 'string',
optional: false, nullable: false,
},
},
} as const;
export const packedWorldRoomDetailedSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
name: {
type: 'string',
optional: false, nullable: false,
},
description: {
type: 'string',
optional: false, nullable: false,
},
def: {
type: 'object',
optional: false, nullable: false,
},
attachedFiles: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'DriveFile',
},
},
},
} as const;
+4
View File
@@ -87,6 +87,8 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiSystemAccount } from '@/models/SystemAccount.js';
import { MiWorldRoom } from '@/models/WorldRoom.js';
import { MiWorldAvatar } from '@/models/WorldAvatar.js';
pg.types.setTypeParser(20, Number);
@@ -254,6 +256,8 @@ export const entities = [
MiChatApproval,
MiBubbleGameRecord,
MiReversiGame,
MiWorldRoom,
MiWorldAvatar,
...charts,
];
@@ -11,7 +11,7 @@ import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { runQueueJobWithTraceContext } from './queue-job-runner.js';
import { runQueueJob } from './queue-job-runner.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
@@ -159,8 +159,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
}
// 以下の各 Worker は job.data に保存された enqueue 元の trace context を復元し、
// ジョブの実処理全体を Link または parent の worker span で囲む。
// 以下の各 Worker はジョブの実処理全体を worker span で囲む。
//#region system
{
const processer = (job: Bull.Job) => {
@@ -180,10 +179,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('system');
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
return runQueueJobWithTraceContext(
return runQueueJob(
this.telemetryService,
'Queue: System: ' + job.name,
job.data,
() => processer(job) as Promise<void>,
err => {
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
@@ -235,10 +233,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('db');
this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => {
return runQueueJobWithTraceContext(
return runQueueJob(
this.telemetryService,
'Queue: DB: ' + job.name,
job.data,
() => processer(job),
err => {
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
@@ -266,10 +263,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('deliver');
this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => {
return runQueueJobWithTraceContext(
return runQueueJob(
this.telemetryService,
'Queue: Deliver',
job.data,
() => this.deliverProcessorService.process(job),
err => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) });
@@ -305,10 +301,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('inbox');
this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => {
return runQueueJobWithTraceContext(
return runQueueJob(
this.telemetryService,
'Queue: Inbox',
job.data,
() => this.inboxProcessorService.process(job),
err => {
const activityId = job.data.activity ? job.data.activity.id : 'none';
@@ -345,10 +340,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('user-webhook');
this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => {
return runQueueJobWithTraceContext(
return runQueueJob(
this.telemetryService,
'Queue: UserWebhookDeliver',
job.data,
() => this.userWebhookDeliverProcessorService.process(job),
err => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) });
@@ -384,10 +378,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('system-webhook');
this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => {
return runQueueJobWithTraceContext(
return runQueueJob(
this.telemetryService,
'Queue: SystemWebhookDeliver',
job.data,
() => this.systemWebhookDeliverProcessorService.process(job),
err => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) });
@@ -432,10 +425,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('relationship');
this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => {
return runQueueJobWithTraceContext(
return runQueueJob(
this.telemetryService,
'Queue: Relationship: ' + job.name,
job.data,
() => processer(job),
err => {
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
@@ -475,10 +467,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('objectStorage');
this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => {
return runQueueJobWithTraceContext(
return runQueueJob(
this.telemetryService,
'Queue: ObjectStorage: ' + job.name,
job.data,
() => processer(job) as Promise<void>,
err => {
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
@@ -507,10 +498,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('ended-poll-notification');
this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => {
return runQueueJobWithTraceContext(
return runQueueJob(
this.telemetryService,
'Queue: EndedPollNotification',
job.data,
() => this.endedPollNotificationProcessorService.process(job),
err => {
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
@@ -532,10 +522,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
const logger = this.logger.createSubLogger('post-scheduled-note');
this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, (job) => {
return runQueueJobWithTraceContext(
return runQueueJob(
this.telemetryService,
'Queue: PostScheduledNote',
job.data,
() => this.postScheduledNoteProcessorService.process(job),
err => {
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
@@ -5,17 +5,16 @@
import type { TelemetryService } from '@/core/telemetry/TelemetryService.js';
type QueueTelemetryService = Pick<TelemetryService, 'startSpanWithTraceContext'>;
type QueueTelemetryService = Pick<TelemetryService, 'startSpan'>;
/** QueueのprocessorをTrace Context付きで実行し、失敗処理をSpan内で行います。 */
export function runQueueJobWithTraceContext<T>(
/** Queueのprocessorを実行し、失敗処理をSpan内で行います。 */
export function runQueueJob<T>(
telemetryService: QueueTelemetryService,
spanName: string,
jobData: object,
processJob: () => T | Promise<T>,
onError: (error: Error) => void,
): Promise<T> {
return telemetryService.startSpanWithTraceContext(spanName, jobData, async (): Promise<T> => {
return telemetryService.startSpan(spanName, async (): Promise<T> => {
try {
return await processJob();
} catch (error) {
+3 -1
View File
@@ -6,6 +6,7 @@
import { Module } from '@nestjs/common';
import { EndpointsModule } from '@/server/api/EndpointsModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import MainStreamConnection from '@/server/api/stream/Connection.js';
import { ApiCallService } from './api/ApiCallService.js';
import { FileServerService } from './FileServerService.js';
import { HealthServerService } from './HealthServerService.js';
@@ -30,7 +31,6 @@ import { UrlPreviewService } from './web/UrlPreviewService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import MainStreamConnection from '@/server/api/stream/Connection.js';
import { MainChannel } from './api/stream/channels/main.js';
import { AdminChannel } from './api/stream/channels/admin.js';
import { AntennaChannel } from './api/stream/channels/antenna.js';
@@ -49,6 +49,7 @@ import { ChatUserChannel } from './api/stream/channels/chat-user.js';
import { ChatRoomChannel } from './api/stream/channels/chat-room.js';
import { ReversiChannel } from './api/stream/channels/reversi.js';
import { ReversiGameChannel } from './api/stream/channels/reversi-game.js';
import { WorldRoomChannel } from './api/stream/channels/world-room.js';
import { NoteStreamingHidingService } from './api/stream/NoteStreamingHidingService.js';
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
@@ -99,6 +100,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
QueueStatsChannel,
ServerStatsChannel,
UserListChannel,
WorldRoomChannel,
NoteStreamingHidingService,
OpenApiServerService,
OAuth2ProviderService,
@@ -32,7 +32,6 @@ import { HealthServerService } from './HealthServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { registerHttpServerInstrumentation } from './http-server-instrumentation.js';
import { registerHttpAccessLog } from './http-access-log.js';
const _dirname = fileURLToPath(new URL('.', import.meta.url));
@@ -82,7 +81,6 @@ export class ServerService implements OnApplicationShutdown {
logger: false,
});
this.#fastify = fastify;
await registerHttpServerInstrumentation(fastify, this.config);
registerHttpAccessLog(fastify);
// HSTS
@@ -447,4 +447,14 @@ export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitati
export * as 'chat/rooms/invitations/outbox' from './endpoints/chat/rooms/invitations/outbox.js';
export * as 'chat/history' from './endpoints/chat/history.js';
export * as 'chat/read-all' from './endpoints/chat/read-all.js';
export * as 'world/rooms/create' from './endpoints/world/rooms/create.js';
export * as 'world/rooms/update' from './endpoints/world/rooms/update.js';
export * as 'world/rooms/delete' from './endpoints/world/rooms/delete.js';
export * as 'world/rooms/list-by-user' from './endpoints/world/rooms/list-by-user.js';
export * as 'world/rooms/show' from './endpoints/world/rooms/show.js';
export * as 'world/avatars/create' from './endpoints/world/avatars/create.js';
export * as 'world/avatars/update' from './endpoints/world/avatars/update.js';
export * as 'world/avatars/delete' from './endpoints/world/avatars/delete.js';
export * as 'world/avatars/list' from './endpoints/world/avatars/list.js';
export * as 'world/avatars/show' from './endpoints/world/avatars/show.js';
export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js';
@@ -10,6 +10,7 @@ import { ApiError } from '@/server/api/error.js';
import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { RoleService } from '@/core/RoleService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
@@ -25,10 +26,10 @@ export const meta = {
code: 'NO_SUCH_USER',
id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d',
},
cannotResetPasswordOfRootUser: {
message: 'Cannot reset password of the root user.',
code: 'CANNOT_RESET_PASSWORD_OF_ROOT_USER',
id: 'f28fc207-42ca-44c7-a577-44b4f0ec5999',
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d',
},
},
@@ -66,6 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -75,8 +77,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchUser);
}
if (this.serverSettings.rootUserId === user.id) {
throw new ApiError(meta.errors.cannotResetPasswordOfRootUser);
if (await this.roleService.isAdministrator(user) && me.id !== user.id) {
throw new ApiError(meta.errors.accessDenied);
}
const passwd = secureRndstr(8);
@@ -11,6 +11,7 @@ import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import type { UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
@@ -26,6 +27,11 @@ export const meta = {
code: 'NO_SUCH_USER',
id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d',
},
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d',
},
},
} as const;
@@ -46,6 +52,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -55,6 +62,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchUser);
}
if (await this.roleService.isAdministrator(user) && me.id !== user.id) {
throw new ApiError(meta.errors.accessDenied);
}
await this.db.transaction(async (transactionalEntityManager) => {
// パスキーを全て削除
await transactionalEntityManager.delete(MiUserSecurityKey, { userId: user.id });
@@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -25,6 +26,11 @@ export const meta = {
code: 'NO_SUCH_CHANNEL',
id: 'c0031718-d573-4e85-928e-10039f1fbb68',
},
alreadyFollowing: {
message: 'You are already following that channel.',
code: 'ALREADY_FOLLOWING',
id: '7db31665-651e-40c1-8e6e-28e9ad829a2d',
},
},
} as const;
@@ -52,7 +58,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchChannel);
}
await this.channelFollowingService.follow(me, channel);
try {
await this.channelFollowingService.follow(me, channel);
} catch (e) {
if (e instanceof IdentifiableError) {
if (e.id === '6e335e39-0203-4418-a936-b3f2dc987845') throw new ApiError(meta.errors.alreadyFollowing);
}
throw e;
}
});
}
}
@@ -7,8 +7,11 @@ import Parser from 'rss-parser';
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { ApiError } from '../error.js';
const rssParser = new Parser();
const MAX_URL_LENGTH = 8192;
const MAX_RESPONSE_SIZE = 1024 * 1024;
const MAX_CONCURRENT_REQUESTS = 32;
export const meta = {
tags: ['meta'],
@@ -17,6 +20,34 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 3,
limit: {
duration: 60 * 1000,
max: 300,
},
errors: {
invalidUrl: {
message: 'Invalid URL.',
code: 'INVALID_URL',
id: '89b7ee05-ccfc-4bdd-9b13-61172fd1e06c',
httpStatusCode: 400,
},
fetchRssFailed: {
message: 'Failed to fetch RSS.',
code: 'FETCH_RSS_FAILED',
id: '8db5d3d8-31d7-452f-b0cc-ca3b8925de12',
kind: 'server',
httpStatusCode: 422,
},
fetchRssUnavailable: {
message: 'RSS fetching is temporarily unavailable.',
code: 'FETCH_RSS_UNAVAILABLE',
id: '91e6ff44-c63f-4725-9ad0-b7a40d7f7655',
kind: 'server',
httpStatusCode: 503,
},
},
res: {
type: 'object',
properties: {
@@ -215,21 +246,84 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
private readonly inFlightRequests = new Map<string, Promise<Awaited<ReturnType<Parser['parseString']>>>>();
private activeRequestCount = 0;
constructor(
private httpRequestService: HttpRequestService,
) {
super(meta, paramDef, async (ps, me) => {
const res = await this.httpRequestService.send(ps.url, {
method: 'GET',
headers: {
Accept: 'application/rss+xml, */*',
},
timeout: 5000,
});
super(meta, paramDef, async (ps) => {
const url = this.normalizeUrl(ps.url);
const inFlightRequest = this.inFlightRequests.get(url);
if (inFlightRequest != null) {
return await inFlightRequest;
}
const text = await res.text();
if (this.activeRequestCount >= MAX_CONCURRENT_REQUESTS) {
throw new ApiError(meta.errors.fetchRssUnavailable);
}
return rssParser.parseString(text);
this.activeRequestCount++;
const request = this.fetchRss(url)
.catch(() => {
throw new ApiError(meta.errors.fetchRssFailed);
})
.finally(() => {
this.inFlightRequests.delete(url);
this.activeRequestCount--;
});
this.inFlightRequests.set(url, request);
return await request;
});
}
private normalizeUrl(input: string): string {
if (input.length === 0 || input.length > MAX_URL_LENGTH) {
throw new ApiError(meta.errors.invalidUrl);
}
let url: URL;
try {
url = new URL(input);
} catch {
throw new ApiError(meta.errors.invalidUrl);
}
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
url.username !== '' ||
url.password !== ''
) {
throw new ApiError(meta.errors.invalidUrl);
}
url.hash = '';
return url.href;
}
private async fetchRss(url: string): Promise<Awaited<ReturnType<Parser['parseString']>>> {
const res = await this.httpRequestService.send(url, {
method: 'GET',
headers: {
Accept: 'application/rss+xml, */*',
},
timeout: 5000,
size: MAX_RESPONSE_SIZE,
});
const finalUrl = new URL(res.url);
if (finalUrl.protocol !== 'http:' && finalUrl.protocol !== 'https:') {
throw new Error('Invalid final URL protocol');
}
const text = await res.text();
const rssParser = new Parser({
xml2js: {
async: true,
},
});
return await rssParser.parseString(text);
}
}
@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
export const meta = {
tags: ['worldAvatar'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:worldAvatar',
limit: {
duration: ms('1day'),
max: 10,
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldAvatarDetailed',
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', maxLength: 256 },
def: { type: 'object', additionalProperties: true },
},
required: ['name', 'def'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldAvatarService: WorldAvatarService,
private worldAvatarEntityService: WorldAvatarEntityService,
) {
super(meta, paramDef, async (ps, me) => {
// TODO: validate avatar
const avatar = await this.worldAvatarService.create(me, {
name: ps.name,
def: ps.def,
});
return await this.worldAvatarEntityService.packDetailed(avatar);
});
}
}
@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['worldAvatar'],
requireCredential: true,
kind: 'write:worldAvatar',
errors: {
noSuchAvatar: {
message: 'No such avatar.',
code: 'NO_SUCH_ROOM',
id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4c',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
avatarId: { type: 'string', format: 'misskey:id' },
},
required: ['avatarId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldAvatarService: WorldAvatarService,
) {
super(meta, paramDef, async (ps, me) => {
const avatar = await this.worldAvatarService.findMyAvatarById(me.id, ps.avatarId);
if (avatar == null) {
throw new ApiError(meta.errors.noSuchAvatar);
}
await this.worldAvatarService.delete(avatar, me);
});
}
}
@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
import { ApiError } from '@/server/api/error.js';
import { IdService } from '@/core/IdService.js';
export const meta = {
tags: ['worldAvatar'],
requireCredential: true,
kind: 'read:worldAvatar',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldAvatarDetailed',
},
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldAvatarEntityService: WorldAvatarEntityService,
private worldAvatarService: WorldAvatarService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
const avatars = await this.worldAvatarService.getMyAvatarsWithPagination(me.id, ps.limit, sinceId, untilId);
return this.worldAvatarEntityService.packDetailedMany(avatars, me);
});
}
}
@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { ApiError } from '@/server/api/error.js';
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
export const meta = {
tags: ['worldAvatar'],
requireCredential: true,
kind: 'read:worldAvatar',
res: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldAvatarDetailed',
},
errors: {
noSuchAvatar: {
message: 'No such avatar.',
code: 'NO_SUCH_ROOM',
id: '857ae02f-8759-4d20-9adb-6e95fffe4fd8',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
avatarId: { type: 'string', format: 'misskey:id' },
},
required: ['avatarId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldAvatarService: WorldAvatarService,
private worldAvatarEntityService: WorldAvatarEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const avatar = await this.worldAvatarService.findAvatarById(ps.avatarId);
if (avatar == null) {
throw new ApiError(meta.errors.noSuchAvatar);
}
if (avatar.userId !== me.id) {
throw new ApiError(meta.errors.noSuchAvatar);
}
return this.worldAvatarEntityService.packDetailed(avatar, me);
});
}
}
@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['worldAvatar'],
requireCredential: true,
kind: 'write:worldAvatar',
res: {
},
errors: {
noSuchAvatar: {
message: 'No such avatar.',
code: 'NO_SUCH_ROOM',
id: 'fcdb0f92-bda6-47f9-bd05-343e0e020933',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
avatarId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', maxLength: 256 },
def: { type: 'object', additionalProperties: true },
active: { type: 'boolean' },
},
required: ['avatarId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldAvatarService: WorldAvatarService,
) {
super(meta, paramDef, async (ps, me) => {
const avatar = await this.worldAvatarService.findMyAvatarById(me.id, ps.avatarId);
if (avatar == null) {
throw new ApiError(meta.errors.noSuchAvatar);
}
// TODO: validate avatar
await this.worldAvatarService.update(avatar, {
name: ps.name,
def: ps.def,
active: ps.active,
});
});
}
}
@@ -0,0 +1,67 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { WorldRoomEntityService } from '@/core/entities/WorldRoomEntityService.js';
export const meta = {
tags: ['worldRoom'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:worldRoom',
limit: {
duration: ms('1day'),
max: 10,
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldRoomDetailed',
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', maxLength: 256 },
description: { type: 'string', maxLength: 1024 },
visibility: { type: 'string', enum: ['public', 'private'] },
def: { type: 'object', additionalProperties: true },
},
required: ['name', 'visibility', 'def'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldRoomService: WorldRoomService,
private worldRoomEntityService: WorldRoomEntityService,
) {
super(meta, paramDef, async (ps, me) => {
// TODO: validate room
const room = await this.worldRoomService.create(me, {
name: ps.name,
description: ps.description ?? '',
visibility: ps.visibility,
def: ps.def,
});
return await this.worldRoomEntityService.packDetailed(room);
});
}
}
@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['worldRoom'],
requireCredential: true,
kind: 'write:worldRoom',
errors: {
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4c',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roomId: { type: 'string', format: 'misskey:id' },
},
required: ['roomId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldRoomService: WorldRoomService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.worldRoomService.findMyRoomById(me.id, ps.roomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
await this.worldRoomService.delete(room, me);
});
}
}
@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { WorldRoomEntityService } from '@/core/entities/WorldRoomEntityService.js';
import { ApiError } from '@/server/api/error.js';
import { IdService } from '@/core/IdService.js';
export const meta = {
tags: ['worldRoom'],
requireCredential: true,
kind: 'read:worldRoom',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldRoomLite',
},
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
},
required: ['userId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldRoomEntityService: WorldRoomEntityService,
private worldRoomService: WorldRoomService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
const rooms = await this.worldRoomService.getRoomsOfUserWithPagination(ps.userId, ps.userId === me.id, ps.limit, sinceId, untilId);
return this.worldRoomEntityService.packLiteMany(rooms, me);
});
}
}
@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { ApiError } from '@/server/api/error.js';
import { WorldRoomEntityService } from '@/core/entities/WorldRoomEntityService.js';
export const meta = {
tags: ['worldRoom'],
requireCredential: true,
kind: 'read:worldRoom',
res: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldRoomDetailed',
},
errors: {
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: '857ae02f-8759-4d20-9adb-6e95fffe4fd8',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roomId: { type: 'string', format: 'misskey:id' },
},
required: ['roomId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldRoomService: WorldRoomService,
private worldRoomEntityService: WorldRoomEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.worldRoomService.findRoomById(ps.roomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
if (room.userId !== me.id && room.visibility === 'private') {
throw new ApiError(meta.errors.noSuchRoom);
}
return this.worldRoomEntityService.packDetailed(room, me);
});
}
}
@@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['worldRoom'],
requireCredential: true,
kind: 'write:worldRoom',
res: {
},
errors: {
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: 'fcdb0f92-bda6-47f9-bd05-343e0e020933',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roomId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', maxLength: 256 },
description: { type: 'string', maxLength: 1024 },
visibility: { type: 'string', enum: ['public', 'private'] },
def: { type: 'object', additionalProperties: true },
},
required: ['roomId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldRoomService: WorldRoomService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.worldRoomService.findMyRoomById(me.id, ps.roomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
// TODO: validate room
await this.worldRoomService.update(room, {
name: ps.name,
description: ps.description,
visibility: ps.visibility,
def: ps.def,
});
});
}
}
@@ -35,6 +35,7 @@ import { ChatUserChannel } from '@/server/api/stream/channels/chat-user.js';
import { ChatRoomChannel } from '@/server/api/stream/channels/chat-room.js';
import { ReversiChannel } from '@/server/api/stream/channels/reversi.js';
import { ReversiGameChannel } from '@/server/api/stream/channels/reversi-game.js';
import { WorldRoomChannel } from '@/server/api/stream/channels/world-room.js';
import type { ChannelRequest } from './channel.js';
import type { ChannelConstructor } from './channel.js';
import type Channel from './channel.js';
@@ -338,6 +339,7 @@ export default class Connection {
case 'chatRoom': return ChatRoomChannel;
case 'reversi': return ReversiChannel;
case 'reversiGame': return ReversiGameChannel;
case 'worldRoom': return WorldRoomChannel;
default:
throw new Error(`no such channel: ${name}`);
@@ -0,0 +1,115 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { WorldRoomMultiplayService } from '@/core/WorldRoomMultiplayService.js';
import Channel, { type ChannelRequest } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class WorldRoomChannel extends Channel {
public readonly chName = 'worldRoom';
public static shouldShare = false;
public static requireCredential = true as const;
public static kind = 'read:worldRoom';
private roomId: string;
private intervalId: NodeJS.Timeout;
private isEntered = false;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private worldRoomService: WorldRoomService,
private worldRoomMultiplayService: WorldRoomMultiplayService,
) {
super(request);
}
@bindThis
public async init(params: JsonObject): Promise<boolean> {
if (typeof params.roomId !== 'string') return false;
if (!this.user) return false;
this.roomId = params.roomId;
const room = await this.worldRoomService.findRoomById(this.roomId);
if (room == null) return false;
try {
await this.enter();
} catch (err) {
return false;
}
this.subscriber.on(`worldRoomStream:${this.roomId}`, this.onEvent);
return true;
}
@bindThis
private async enter() {
if (this.isEntered) return;
await this.worldRoomMultiplayService.enter(this.user!.id, this.roomId);
this.isEntered = true;
this.send('entered', {
playerProfiles: await this.worldRoomMultiplayService.getPlayerProfiles(this.roomId, this.user!.id),
});
this.intervalId = setInterval(async () => {
const states = await this.worldRoomMultiplayService.getPlayerStatesAndHeatbeat(this.user!.id, this.roomId);
delete states[this.user!.id];
this.send('sync', states);
}, 100);
}
@bindThis
private async onEvent(data: GlobalEvents['worldRoom']['payload']) {
switch (data.type) {
case 'enter': {
if (data.body.user.id === this.user!.id) return; // 自分の入室は無視
this.send('playerEntered', {
id: data.body.user.id,
profile: this.worldRoomMultiplayService.packPlayerProfile(data.body.user, data.body.avatar),
});
break;
}
case 'left': {
if (data.body.userId === this.user!.id) return; // 自分の退室は無視
this.send('playerLeft', {
id: data.body.userId,
});
break;
}
}
}
@bindThis
public onMessage(type: string, body: any) {
switch (type) {
case 'update':
if (this.roomId && this.isEntered) {
this.worldRoomMultiplayService.updatePlayerState(this.user!.id, this.roomId, body);
}
break;
}
}
@bindThis
public dispose() {
this.subscriber.off(`worldRoomStream:${this.roomId}`, this.onEvent);
clearInterval(this.intervalId);
this.worldRoomMultiplayService.left(this.user!.id, this.roomId);
}
}
@@ -123,7 +123,7 @@ export function registerHttpAccessLog(fastify: FastifyInstance, manager: LogMana
const states = new WeakMap<object, AccessRequestState>();
// HTTP計装の後に登録し、リクエスト開始時のactiveなTrace Contextを保存します。
// リクエスト開始時のactiveなTrace Contextを保存します。
fastify.addHook('onRequest', (request, _reply, done) => {
states.set(request, {
traceContext: manager.getActiveTraceContext(),
@@ -1,35 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
import type { FastifyInstance } from 'fastify';
type TelemetryConfig = Pick<Config, 'otelForBackend' | 'sentryForBackend'>;
export function shouldRegisterHttpServerInstrumentation(config: TelemetryConfig): boolean {
// Sentryもリクエストspanを作成するため、両方を登録すると重複して出力される。
return config.otelForBackend != null && config.sentryForBackend == null;
}
/**
* ActivityPubや
* well-knownを含む全HTTP受信経路を1つのroot spanとして計測する
*/
export async function registerHttpServerInstrumentation(fastify: FastifyInstance, config: TelemetryConfig): Promise<void> {
if (!shouldRegisterHttpServerInstrumentation(config)) return;
const { FastifyOtelInstrumentation } = await import('@fastify/otel');
const instrumentation = new FastifyOtelInstrumentation({
requestHook: (span, request) => {
const route = request.routeOptions.url;
if (route != null) {
// デフォルトだとトレース名が「request」で固定されてしまうため、判別がつかなくなる。
// ルート名をspan名に設定することで、トレースビューでルートごとの処理時間を確認できるようになる。
span.updateName(`${request.method} ${route}`);
}
},
});
await fastify.register(instrumentation.plugin());
}
+36
View File
@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, test } from 'vitest';
import { api, castAsError, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
describe('Channel', () => {
let alice: misskey.entities.SignupResponse;
beforeAll(async () => {
alice = await signup({ username: 'alice' });
});
describe('Follow', () => {
let channel: misskey.entities.ChannelsCreateResponse;
beforeAll(async () => {
const res = await api('channels/create', { name: 'follow-test-channel' }, alice);
channel = res.body;
});
test('フォローしているチャンネルを再度フォローするとALREADY_FOLLOWINGエラーになる', async () => {
const res1 = await api('channels/follow', { channelId: channel.id }, alice);
assert.strictEqual(res1.status, 204);
const res2 = await api('channels/follow', { channelId: channel.id }, alice);
assert.strictEqual(res2.status, 400);
assert.strictEqual(castAsError(res2.body as any).error.code, 'ALREADY_FOLLOWING');
});
});
});
@@ -1,88 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { randomUUID } from 'node:crypto';
import { describe, expect, test } from 'vitest';
import { Queue, Worker } from 'bullmq';
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { loadConfig } from '@/config.js';
import { installRedisInstrumentation } from '@/core/telemetry/redis-instrumentation.js';
const config = loadConfig();
describe('Redis telemetry instrumentation', () => {
test('records Redis spans below HTTP and BullMQ worker spans without Redis arguments', async () => {
const exporter = new InMemorySpanExporter();
const provider = new NodeTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
provider.register();
const tracer = provider.getTracer('telemetry-redis-instrumentation-test');
const uninstall = installRedisInstrumentation(tracer, SpanKind.CLIENT, SpanStatusCode.ERROR, {
captureCommandSpans: true,
});
const queueName = `telemetry-${randomUUID()}`;
const prefix = `telemetry-${randomUUID()}`;
const connection = {
host: config.redis.host,
port: config.redis.port,
...(config.redis.password != null ? { password: config.redis.password } : {}),
};
const queue = new Queue(queueName, { connection, prefix });
let worker: Worker | undefined;
let httpSpanId: string | undefined;
let jobSpanId: string | undefined;
const secret = `secret-${randomUUID()}`;
try {
const processed = new Promise<void>((resolve, reject) => {
worker = new Worker(queueName, async job => {
return await tracer.startActiveSpan('Queue: telemetry test', async jobSpan => {
jobSpanId = jobSpan.spanContext().spanId;
try {
// updateData uses BullMQ's worker-side ioredis client.
await job.updateData({ secret });
return 'ok';
} finally {
jobSpan.end();
}
});
}, { connection, prefix });
worker.once('completed', () => resolve());
worker.once('failed', (_job, error) => reject(error));
});
await tracer.startActiveSpan('HTTP POST /telemetry-test', async httpSpan => {
httpSpanId = httpSpan.spanContext().spanId;
try {
// Queue#add uses BullMQ's producer-side ioredis client.
await queue.add('probe', { secret });
} finally {
httpSpan.end();
}
});
await processed;
await provider.forceFlush();
const redisSpans = exporter.getFinishedSpans().filter(span => span.attributes['db.system.name'] === 'redis');
expect(redisSpans.some(span => span.parentSpanContext?.spanId === httpSpanId)).toBe(true);
expect(redisSpans.some(span => span.parentSpanContext?.spanId === jobSpanId)).toBe(true);
for (const span of redisSpans) {
expect(span.attributes).not.toHaveProperty('db.statement');
expect(span.attributes).not.toHaveProperty('db.query.text');
expect(Object.values(span.attributes)).not.toContain(secret);
}
} finally {
await worker?.close();
await queue.obliterate({ force: true });
await queue.close();
uninstall();
await provider.shutdown();
}
}, 30000);
});
@@ -1,379 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { SpanStatusCode } from '@opentelemetry/api';
import { defaultResource, detectResources, envDetector, resourceFromAttributes } from '@opentelemetry/resources';
import { ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base';
import { ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import type { Context, SpanContext } from '@opentelemetry/api';
import { OpenTelemetryAdapter, createResource, createSampler, getMisskeyProcessRole } from '@/core/telemetry/adapters/OpenTelemetryAdapter.js';
const mocks = vi.hoisted(() => {
return {
envOption: {
disableClustering: false,
onlyServer: false,
onlyQueue: false,
},
isPrimary: false,
};
});
vi.mock('@/env.js', () => ({
envOption: mocks.envOption,
}));
vi.mock('node:cluster', () => ({
default: {
get isPrimary() {
return mocks.isPrimary;
},
},
}));
const samplerDeps = {
ParentBasedSampler,
TraceIdRatioBasedSampler,
};
describe('OpenTelemetryAdapter', () => {
test('wraps async work in an active span and ends it after success', async () => {
const span = {
end: vi.fn(),
recordException: vi.fn(),
setStatus: vi.fn(),
};
const tracer = {
startActiveSpan: vi.fn(async (_name: string, fn: (spanArg: any) => Promise<string>) => fn(span)),
} as any;
const provider = {
shutdown: vi.fn(),
};
const adapter = new OpenTelemetryAdapter({
tracer,
provider,
getActiveSpan: () => undefined,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 10,
});
await expect(adapter.startSpan('API: test', async () => 'ok')).resolves.toBe('ok');
expect(tracer.startActiveSpan).toHaveBeenCalledWith('API: test', expect.any(Function));
expect(span.recordException).not.toHaveBeenCalled();
expect(span.setStatus).not.toHaveBeenCalled();
expect(span.end).toHaveBeenCalledTimes(1);
});
test('records thrown errors on the active span before rethrowing', async () => {
const error = new Error('boom');
const span = {
end: vi.fn(),
recordException: vi.fn(),
setStatus: vi.fn(),
};
const tracer = {
startActiveSpan: vi.fn(async (_name: string, fn: (spanArg: any) => Promise<void>) => fn(span)),
} as any;
const adapter = new OpenTelemetryAdapter({
tracer,
provider: { shutdown: vi.fn() },
getActiveSpan: () => undefined,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 10,
});
await expect(adapter.startSpan('Queue: test', async () => {
throw error;
})).rejects.toThrow(error);
expect(span.recordException).toHaveBeenCalledWith(error);
expect(span.setStatus).toHaveBeenCalledWith({
code: SpanStatusCode.ERROR,
message: error.message,
});
expect(span.end).toHaveBeenCalledTimes(1);
});
test('creates a root worker span linked to the enqueue span by default', () => {
const span = {
end: vi.fn(),
recordException: vi.fn(),
setStatus: vi.fn(),
};
const rootContext = {} as Context;
const extractedContext = {} as Context;
const sourceSpanContext = {
traceId: '0123456789abcdef0123456789abcdef',
spanId: '0123456789abcdef',
traceFlags: 1,
isRemote: true,
} as SpanContext;
const propagation = {
isPropagationApi: true,
inject: vi.fn(),
extract(this: { isPropagationApi: boolean }) {
if (!this.isPropagationApi) throw new Error('lost propagation API receiver');
return extractedContext;
},
};
const tracer = {
startActiveSpan: vi.fn((_name: string, _options: unknown, _context: unknown, fn: (spanArg: typeof span) => string) => fn(span)),
} as any;
const adapter = new OpenTelemetryAdapter({
tracer,
provider: { shutdown: vi.fn() },
getActiveSpan: () => undefined,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 10,
queueTraceContext: {
tracer,
propagation: propagation as any,
trace: { getSpanContext: () => sourceSpanContext },
getActiveContext: () => rootContext,
rootContext,
mode: 'link',
spanStatusCodeError: SpanStatusCode.ERROR,
},
});
expect(adapter.startSpanWithTraceContext('Queue: Deliver', {
__misskeyTraceContext: {
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
},
}, () => 'ok')).toBe('ok');
expect(tracer.startActiveSpan).toHaveBeenCalledWith('Queue: Deliver', {
root: true,
links: [{ context: sourceSpanContext }],
}, rootContext, expect.any(Function));
expect(span.end).toHaveBeenCalledTimes(1);
});
test('returns the active span context for log enrichment', () => {
const adapter = new OpenTelemetryAdapter({
tracer: { startActiveSpan: vi.fn() },
provider: { shutdown: vi.fn() },
getActiveSpan: () => ({
spanContext: () => ({
traceId: '0123456789abcdef0123456789abcdef',
spanId: '0123456789abcdef',
traceFlags: 0,
}),
} as any),
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 10,
});
expect(adapter.getActiveTraceContext()).toEqual({
traceId: '0123456789abcdef0123456789abcdef',
spanId: '0123456789abcdef',
traceFlags: 0,
});
});
test('bridges captureMessage to the active span when one exists', () => {
const activeSpan = {
recordException: vi.fn(),
setStatus: vi.fn(),
};
const adapter = new OpenTelemetryAdapter({
tracer: { startActiveSpan: vi.fn() },
provider: { shutdown: vi.fn() },
getActiveSpan: () => activeSpan as any,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 10,
});
adapter.captureMessage('Queue failed', {
level: 'error',
extra: { queue: 'deliver' },
});
expect(activeSpan.recordException).toHaveBeenCalledWith(expect.objectContaining({
message: 'Queue failed',
}));
expect(activeSpan.setStatus).toHaveBeenCalledWith({
code: SpanStatusCode.ERROR,
message: 'Queue failed',
});
});
test('times out shutdown instead of waiting forever', async () => {
vi.useFakeTimers();
const adapter = new OpenTelemetryAdapter({
tracer: { startActiveSpan: vi.fn() },
provider: { shutdown: vi.fn(() => new Promise<void>(() => {})) },
getActiveSpan: () => undefined,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 50,
});
const shutdown = adapter.shutdown();
await vi.advanceTimersByTimeAsync(50);
await expect(shutdown).resolves.toBeUndefined();
vi.useRealTimers();
});
test('clears the shutdown timeout timer once provider.shutdown() resolves first', async () => {
vi.useFakeTimers();
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
const adapter = new OpenTelemetryAdapter({
tracer: { startActiveSpan: vi.fn() },
provider: { shutdown: vi.fn().mockResolvedValue(undefined) },
getActiveSpan: () => undefined,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 5000,
});
await adapter.shutdown();
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
vi.useRealTimers();
});
test('captureMessage starts a standalone span to report the error when there is no active span', () => {
const reportSpan = {
end: vi.fn(),
recordException: vi.fn(),
setStatus: vi.fn(),
};
const tracer = {
startActiveSpan: vi.fn((_name: string, fn: (spanArg: typeof reportSpan) => void) => fn(reportSpan)),
};
const adapter = new OpenTelemetryAdapter({
tracer: tracer as any,
provider: { shutdown: vi.fn() },
getActiveSpan: () => undefined,
spanStatusCodeError: SpanStatusCode.ERROR,
shutdownTimeout: 10,
});
adapter.captureMessage('Queue: Deliver failed', {
level: 'error',
extra: { queue: 'deliver' },
});
expect(tracer.startActiveSpan).toHaveBeenCalledWith('captureMessage', expect.any(Function));
expect(reportSpan.recordException).toHaveBeenCalledWith(expect.objectContaining({
message: 'Queue: Deliver failed',
}));
expect(reportSpan.setStatus).toHaveBeenCalledWith({
code: SpanStatusCode.ERROR,
message: 'Queue: Deliver failed',
});
expect(reportSpan.end).toHaveBeenCalledTimes(1);
});
});
describe('createSampler', () => {
test('accepts sample rates within [0, 1]', () => {
expect(() => createSampler(0, samplerDeps)).not.toThrow();
expect(() => createSampler(0.5, samplerDeps)).not.toThrow();
expect(() => createSampler(1, samplerDeps)).not.toThrow();
});
test('rejects sample rates outside [0, 1]', () => {
expect(() => createSampler(-0.1, samplerDeps)).toThrow();
expect(() => createSampler(1.1, samplerDeps)).toThrow();
});
test('rejects NaN instead of silently disabling sampling', () => {
expect(() => createSampler(Number.NaN, samplerDeps)).toThrow();
});
test('rejects non-number values that pass through YAML as strings', () => {
expect(() => createSampler('0.5' as unknown as number, samplerDeps)).toThrow();
});
});
describe('createResource', () => {
test('lets explicit config override OTEL resource env, and env override Misskey defaults', () => {
const previousServiceName = process.env['OTEL_SERVICE_NAME'];
const previousResourceAttributes = process.env['OTEL_RESOURCE_ATTRIBUTES'];
process.env['OTEL_SERVICE_NAME'] = 'env-service';
process.env['OTEL_RESOURCE_ATTRIBUTES'] = [
'deployment.environment=staging',
'misskey.process.role=env-role',
'service.instance.id=env-instance',
'env.only=value',
].join(',');
try {
const resource = createResource({
serviceVersion: '2026.1.0',
resourceAttributes: {
[ATTR_SERVICE_NAME]: 'config-service',
'deployment.environment': 'production',
'config.only': 'value',
},
}, {
defaultResource,
resourceFromAttributes,
detectResources,
envDetector,
serviceNameAttribute: ATTR_SERVICE_NAME,
serviceInstanceIdAttribute: ATTR_SERVICE_INSTANCE_ID,
serviceVersionAttribute: ATTR_SERVICE_VERSION,
serviceVersion: '2026.1.0',
});
expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('config-service');
expect(resource.attributes[ATTR_SERVICE_INSTANCE_ID]).toBe('env-instance');
expect(resource.attributes[ATTR_SERVICE_VERSION]).toBe('2026.1.0');
expect(resource.attributes['deployment.environment']).toBe('production');
expect(resource.attributes['misskey.process.role']).toBe('env-role');
expect(resource.attributes['env.only']).toBe('value');
expect(resource.attributes['config.only']).toBe('value');
} finally {
if (previousServiceName == null) {
delete process.env['OTEL_SERVICE_NAME'];
} else {
process.env['OTEL_SERVICE_NAME'] = previousServiceName;
}
if (previousResourceAttributes == null) {
delete process.env['OTEL_RESOURCE_ATTRIBUTES'];
} else {
process.env['OTEL_RESOURCE_ATTRIBUTES'] = previousResourceAttributes;
}
}
});
});
describe('getMisskeyProcessRole', () => {
beforeEach(() => {
mocks.envOption.disableClustering = false;
mocks.envOption.onlyServer = false;
mocks.envOption.onlyQueue = false;
mocks.isPrimary = false;
});
test('labels non-clustered onlyServer as primary-server', () => {
mocks.envOption.disableClustering = true;
mocks.envOption.onlyServer = true;
expect(getMisskeyProcessRole()).toBe('primary-server');
});
test('labels clustered primary with onlyServer as fork-only', () => {
mocks.isPrimary = true;
mocks.envOption.onlyServer = true;
expect(getMisskeyProcessRole()).toBe('fork-only');
});
test('labels clustered worker running the HTTP server (onlyServer) as worker-server, not worker-queue', () => {
mocks.isPrimary = false;
mocks.envOption.onlyServer = true;
expect(getMisskeyProcessRole()).toBe('worker-server');
});
test('labels clustered worker without onlyServer as worker-queue', () => {
mocks.isPrimary = false;
mocks.envOption.onlyServer = false;
expect(getMisskeyProcessRole()).toBe('worker-queue');
});
});
@@ -4,7 +4,7 @@
*/
import { describe, expect, test, vi } from 'vitest';
import { SentryTelemetryAdapter, buildSentryIntegrations, buildSentryNodeOptions, buildSentryOtlpInitOptions } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js';
import { SentryTelemetryAdapter, buildSentryIntegrations, buildSentryNodeOptions } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js';
type TestIntegration = Parameters<ReturnType<typeof buildSentryIntegrations>>[0][number];
@@ -35,9 +35,7 @@ describe('SentryTelemetryAdapter', () => {
nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'),
});
const result = integrations([
testIntegration('Http'),
]);
const result = integrations([testIntegration('Http')]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']);
});
@@ -50,9 +48,7 @@ describe('SentryTelemetryAdapter', () => {
warn,
});
const result = integrations([
testIntegration('Http'),
]);
const result = integrations([testIntegration('Http')]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http']);
expect(warn).toHaveBeenCalledWith('Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: Unknown');
@@ -77,88 +73,6 @@ describe('SentryTelemetryAdapter', () => {
expect(options.tracePropagationTargets).toEqual(['^https://internal\\.example/']);
});
test('builds Sentry options that export spans to both Sentry and OTLP', () => {
const existingProcessor = { name: 'existingProcessor' };
const otlpProcessor = { name: 'otlpProcessor' };
const result = buildSentryOtlpInitOptions({
sentryConfig: {
enableNodeProfiling: false,
disabledIntegrations: ['Redis'],
options: {
openTelemetrySpanProcessors: [existingProcessor as any],
tracesSampleRate: 0.25,
},
},
otelConfig: { serviceVersion: '2026.1.0' },
otlpProcessor,
});
expect(result.tracesSampleRate).toBe(0.25);
expect(result.openTelemetrySpanProcessors).toEqual([existingProcessor, otlpProcessor]);
// OTel併存時もremoteへtrace headerを漏らさないデフォルトはSentry単体時と揃える。
expect(result.tracePropagationTargets).toEqual([]);
expect((result.integrations as any)([
testIntegration('Http'),
testIntegration('Redis'),
testIntegration('Postgres'),
]).map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'Postgres']);
});
test('does not disable Sentry trace propagation when explicitly enabled for OTel coexistence', () => {
const result = buildSentryOtlpInitOptions({
sentryConfig: {
enableNodeProfiling: false,
options: {},
},
otelConfig: {
serviceVersion: '2026.1.0',
propagateTraceToRemote: true,
},
otlpProcessor: { name: 'otlpProcessor' },
});
expect(result.tracePropagationTargets).toBeUndefined();
});
test('honors explicit tracePropagationTargets for OTel coexistence even without propagateTraceToRemote', () => {
const result = buildSentryOtlpInitOptions({
sentryConfig: {
enableNodeProfiling: false,
options: {
tracePropagationTargets: ['^https://internal\\.example/'],
},
},
otelConfig: { serviceVersion: '2026.1.0' },
otlpProcessor: { name: 'otlpProcessor' },
});
expect(result.tracePropagationTargets).toEqual(['^https://internal\\.example/']);
});
test('warns when OTel-only options are ignored in Sentry coexistence mode', () => {
const warn = vi.fn();
buildSentryOtlpInitOptions({
sentryConfig: {
enableNodeProfiling: false,
options: {},
},
otelConfig: {
serviceVersion: '2026.1.0',
sampleRate: 0.25,
resourceAttributes: {
'deployment.environment': 'production',
},
},
otlpProcessor: { name: 'otlpProcessor' },
warn,
});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('otelForBackend.sampleRate is ignored'));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('otelForBackend.resourceAttributes is ignored'));
});
});
describe('SentryTelemetryAdapter trace context', () => {
@@ -220,69 +134,3 @@ describe('SentryTelemetryAdapter.shutdown', () => {
vi.doUnmock('@sentry/profiling-node');
});
});
describe('SentryTelemetryAdapter.createWithOtlpExport', () => {
test('registers the OTel diag logger before creating the OTLP exporter', async () => {
const init = vi.fn();
const close = vi.fn();
const setLogger = vi.fn();
const nodeProfilingIntegration = vi.fn();
const BatchSpanProcessor = vi.fn(function (this: { exporter: unknown }, exporter: unknown) {
this.exporter = exporter;
});
const OTLPTraceExporter = vi.fn(function (this: { options: unknown }, options: unknown) {
this.options = options;
});
vi.doMock('@sentry/node', () => ({
init,
close,
}));
vi.doMock('@sentry/profiling-node', () => ({
nodeProfilingIntegration,
}));
vi.doMock('@opentelemetry/api', () => ({
context: { active: vi.fn() },
diag: { setLogger },
DiagLogLevel: { WARN: 50 },
propagation: { inject: vi.fn(), extract: vi.fn() },
ROOT_CONTEXT: {},
SpanStatusCode: { ERROR: 2 },
trace: { getTracer: vi.fn(), getSpanContext: vi.fn() },
}));
vi.doMock('@opentelemetry/sdk-trace-base', () => ({
BatchSpanProcessor,
}));
vi.doMock('@opentelemetry/exporter-trace-otlp-proto', () => ({
OTLPTraceExporter,
}));
await SentryTelemetryAdapter.createWithOtlpExport({
enableNodeProfiling: false,
options: {},
}, {
serviceVersion: '2026.1.0',
endpoint: 'http://collector:4318/v1/traces',
});
expect(setLogger).toHaveBeenCalledWith(expect.objectContaining({
error: expect.any(Function),
warn: expect.any(Function),
}), {
logLevel: 50,
suppressOverrideMessage: true,
});
expect(OTLPTraceExporter).toHaveBeenCalledWith({
url: 'http://collector:4318/v1/traces',
});
expect(init).toHaveBeenCalledWith(expect.objectContaining({
openTelemetrySpanProcessors: [expect.any(Object)],
}));
vi.doUnmock('@sentry/node');
vi.doUnmock('@sentry/profiling-node');
vi.doUnmock('@opentelemetry/api');
vi.doUnmock('@opentelemetry/sdk-trace-base');
vi.doUnmock('@opentelemetry/exporter-trace-otlp-proto');
});
});
@@ -1,108 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
import { createHttpClientInstrumentation } from '@/core/telemetry/http-client-instrumentation.js';
function request() {
return {
method: 'POST',
protocol: 'https:',
path: '/inbox?token=secret',
host: 'remote.example',
getHeader: vi.fn((name: string) => name === 'host' ? 'user:password@remote.example:8443' : undefined),
};
}
describe('http-client-instrumentation', () => {
test('creates and completes a sanitized CLIENT span from diagnostics channels', () => {
const listeners = new Map<string, (message: unknown) => void>();
const span = {
end: vi.fn(),
recordException: vi.fn(),
setAttribute: vi.fn(),
setStatus: vi.fn(),
};
const tracer = { startSpan: vi.fn(() => span) } as any;
const unsubscribe = createHttpClientInstrumentation({
tracer,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
subscribe: (name, listener) => {
listeners.set(name, listener);
return () => listeners.delete(name);
},
});
const clientRequest = request();
listeners.get('http.client.request.created')!({ request: clientRequest });
listeners.get('http.client.response.finish')!({
request: clientRequest,
response: { statusCode: 201, httpVersion: '1.1' },
});
expect(tracer.startSpan).toHaveBeenCalledWith('POST', {
kind: SpanKind.CLIENT,
attributes: {
'http.request.method': 'POST',
'url.full': 'https://remote.example:8443/inbox',
'server.address': 'remote.example',
'server.port': 8443,
},
});
expect(span.setAttribute).toHaveBeenCalledWith('http.response.status_code', 201);
expect(span.setAttribute).toHaveBeenCalledWith('network.protocol.version', '1.1');
expect(span.end).toHaveBeenCalledTimes(1);
unsubscribe();
expect(listeners).toHaveLength(0);
});
test('records a request error and ends the span once', () => {
const listeners = new Map<string, (message: unknown) => void>();
const span = { end: vi.fn(), recordException: vi.fn(), setAttribute: vi.fn(), setStatus: vi.fn() };
const error = Object.assign(new Error('connection refused'), { code: 'ECONNREFUSED' });
const clientRequest = request();
createHttpClientInstrumentation({
tracer: { startSpan: vi.fn(() => span) } as any,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
subscribe: (name, listener) => {
listeners.set(name, listener);
return () => listeners.delete(name);
},
});
listeners.get('http.client.request.created')!({ request: clientRequest });
listeners.get('http.client.request.error')!({ request: clientRequest, error });
listeners.get('http.client.response.finish')!({ request: clientRequest, response: { statusCode: 200 } });
expect(span.recordException).toHaveBeenCalledWith(error);
expect(span.setAttribute).toHaveBeenCalledWith('error.type', 'ECONNREFUSED');
expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR });
expect(span.end).toHaveBeenCalledTimes(1);
});
test('records the response status code as error.type for an error response', () => {
const listeners = new Map<string, (message: unknown) => void>();
const span = { end: vi.fn(), recordException: vi.fn(), setAttribute: vi.fn(), setStatus: vi.fn() };
const clientRequest = request();
createHttpClientInstrumentation({
tracer: { startSpan: vi.fn(() => span) } as any,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
subscribe: (name, listener) => {
listeners.set(name, listener);
return () => listeners.delete(name);
},
});
listeners.get('http.client.request.created')!({ request: clientRequest });
listeners.get('http.client.response.finish')!({ request: clientRequest, response: { statusCode: 502 } });
expect(span.setAttribute).toHaveBeenCalledWith('error.type', '502');
expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR });
});
});
@@ -1,57 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type * as Bull from 'bullmq';
import { instrumentQueue } from '@/core/telemetry/queue-instrumentation.js';
const mocks = vi.hoisted(() => ({
injectTraceContext: vi.fn((carrier: Record<string, string>) => {
carrier['traceparent'] = '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01';
}),
}));
vi.mock('@/core/telemetry/telemetry-registry.js', () => ({
injectTraceContext: mocks.injectTraceContext,
}));
describe('queue-instrumentation', () => {
beforeEach(() => {
mocks.injectTraceContext.mockClear();
});
test('injects the active trace context for add()', () => {
const add = vi.fn();
const queue = instrumentQueue({ add, addBulk: vi.fn() } as unknown as Bull.Queue<{ noteId: string }>);
const data = { noteId: '9d6b9a65-46c9-4e1b-a640-9589693893c9' };
queue.add('endedPollNotification', data);
expect(mocks.injectTraceContext).toHaveBeenCalledTimes(1);
expect(data).toMatchObject({
__misskeyTraceContext: {
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
},
});
expect(add).toHaveBeenCalledWith('endedPollNotification', data, undefined);
});
test('injects every job passed to addBulk()', () => {
const addBulk = vi.fn();
const queue = instrumentQueue({ add: vi.fn(), addBulk } as unknown as Bull.Queue<{ to: string }>);
const jobs = [
{ name: 'deliver', data: { to: 'https://remote.example/inbox' } },
{ name: 'deliver', data: { to: 'https://remote2.example/inbox' } },
];
queue.addBulk(jobs);
expect(mocks.injectTraceContext).toHaveBeenCalledTimes(2);
expect(jobs).toEqual(expect.arrayContaining([
expect.objectContaining({ data: expect.objectContaining({ __misskeyTraceContext: expect.any(Object) }) }),
]));
expect(addBulk).toHaveBeenCalledWith(jobs);
});
});
@@ -1,132 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import type { Context, SpanContext } from '@opentelemetry/api';
import { getQueueSpanContext, getQueueTraceContextMode, injectActiveTraceContext, injectQueueTraceContext } from '@/core/telemetry/queue-trace-context.js';
const rootContext = {} as Context;
const extractedContext = {} as Context;
const sourceSpanContext: SpanContext = {
traceId: '0123456789abcdef0123456789abcdef',
spanId: '0123456789abcdef',
traceFlags: 1,
isRemote: true,
};
function jobData() {
return {
name: 'deliver',
__misskeyTraceContext: {
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
},
};
}
describe('queue-trace-context', () => {
test('stores only a non-empty carrier in the job data', () => {
const data = { noteId: '9d6b9a65-46c9-4e1b-a640-9589693893c9' };
injectQueueTraceContext(data, carrier => {
carrier['traceparent'] = '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01';
});
expect(data).toMatchObject({
__misskeyTraceContext: {
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
},
});
});
test('does not store an empty carrier when no active trace exists', () => {
const data = { noteId: '9d6b9a65-46c9-4e1b-a640-9589693893c9' };
injectQueueTraceContext(data, () => {});
expect(data).not.toHaveProperty('__misskeyTraceContext');
});
test('ignores non-object job data', () => {
const inject = vi.fn();
injectQueueTraceContext(null, inject);
injectQueueTraceContext('not a job object', inject);
expect(inject).not.toHaveBeenCalled();
});
test('injects the active context with the configured propagator', () => {
const activeContext = {} as Context;
const carrier = {};
const inject = vi.fn();
injectActiveTraceContext({
tracer: { startActiveSpan: vi.fn() } as any,
propagation: { inject, extract: vi.fn() } as any,
trace: { getSpanContext: vi.fn() },
getActiveContext: () => activeContext,
rootContext,
mode: 'link',
spanStatusCodeError: 2 as any,
}, carrier);
expect(inject).toHaveBeenCalledWith(activeContext, carrier);
});
test('starts a new root trace with a link by default', () => {
const extract = vi.fn(() => extractedContext);
const getSpanContext = vi.fn(() => sourceSpanContext);
const result = getQueueSpanContext(jobData(), {
rootContext,
propagation: { inject: vi.fn(), extract },
trace: { getSpanContext },
mode: 'link',
});
expect(extract).toHaveBeenCalledWith(rootContext, jobData().__misskeyTraceContext);
expect(result).toEqual({
options: {
root: true,
links: [{ context: sourceSpanContext }],
},
parentContext: rootContext,
});
});
test('uses the extracted context as the parent when parent mode is selected', () => {
const result = getQueueSpanContext(jobData(), {
rootContext,
propagation: { inject: vi.fn(), extract: () => extractedContext },
trace: { getSpanContext: () => sourceSpanContext },
mode: 'parent',
});
expect(result).toEqual({
options: {},
parentContext: extractedContext,
});
});
test('ignores malformed or missing carriers', () => {
const extract = vi.fn(() => extractedContext);
const deps = {
rootContext,
propagation: { inject: vi.fn(), extract },
trace: { getSpanContext: () => sourceSpanContext },
mode: 'link' as const,
};
expect(getQueueSpanContext({}, deps)).toBeUndefined();
expect(getQueueSpanContext({ __misskeyTraceContext: { traceparent: 1 } }, deps)).toBeUndefined();
expect(extract).not.toHaveBeenCalled();
});
test('defaults to link mode and rejects invalid configuration', () => {
expect(getQueueTraceContextMode(undefined)).toBe('link');
expect(getQueueTraceContextMode('parent')).toBe('parent');
expect(() => getQueueTraceContextMode('children')).toThrow('otelForBackend.jobTraceContextMode');
});
});
@@ -4,13 +4,13 @@
*/
import { describe, expect, test, vi } from 'vitest';
import { runQueueJobWithTraceContext } from '@/queue/queue-job-runner.js';
import { runQueueJob } from '@/queue/queue-job-runner.js';
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
describe('runQueueJobWithTraceContext', () => {
describe('runQueueJob', () => {
test('returns the processor result without invoking the error handler', async () => {
let spanActive = false;
const startSpanWithTraceContext = vi.fn(<T>(_name: string, _jobData: object, fn: () => T): T => {
const startSpan = vi.fn(<T>(_name: string, fn: () => T): T => {
spanActive = true;
const result = fn();
if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T;
@@ -18,11 +18,11 @@ describe('runQueueJobWithTraceContext', () => {
return result;
});
const telemetryService = {
startSpanWithTraceContext,
startSpan,
} as unknown as TelemetryService;
const onError = vi.fn();
await expect(runQueueJobWithTraceContext(telemetryService, 'Queue: test', {}, () => 'ok', onError)).resolves.toBe('ok');
await expect(runQueueJob(telemetryService, 'Queue: test', () => 'ok', onError)).resolves.toBe('ok');
expect(onError).not.toHaveBeenCalled();
expect(spanActive).toBe(false);
@@ -30,7 +30,7 @@ describe('runQueueJobWithTraceContext', () => {
test('handles failures while the processor span is active and rethrows the original error', async () => {
let spanActive = false;
const startSpanWithTraceContext = vi.fn(<T>(_name: string, _jobData: object, fn: () => T): T => {
const startSpan = vi.fn(<T>(_name: string, fn: () => T): T => {
spanActive = true;
const result = fn();
if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T;
@@ -38,7 +38,7 @@ describe('runQueueJobWithTraceContext', () => {
return result;
});
const telemetryService = {
startSpanWithTraceContext,
startSpan,
} as unknown as TelemetryService;
const onError = vi.fn((error: Error) => {
expect(spanActive).toBe(true);
@@ -46,7 +46,7 @@ describe('runQueueJobWithTraceContext', () => {
});
const originalError = new Error('failed');
await expect(runQueueJobWithTraceContext(telemetryService, 'Queue: test', {}, async () => {
await expect(runQueueJob(telemetryService, 'Queue: test', async () => {
throw originalError;
}, onError)).rejects.toBe(originalError);
@@ -0,0 +1,192 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import FetchRssEndpoint, { meta } from '@/server/api/endpoints/fetch-rss.js';
import { ApiError } from '@/server/api/error.js';
import type { Mocked } from 'vitest';
import type { Response } from 'node-fetch';
const rssParserMocks = vi.hoisted(() => ({
constructor: vi.fn(),
parseString: vi.fn(),
}));
vi.mock('rss-parser', () => ({
default: class {
constructor(options: unknown) {
rssParserMocks.constructor(options);
}
public parseString(input: string) {
return rssParserMocks.parseString(input);
}
},
}));
const RSS = '<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title></channel></rss>';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function response(url: string, text = RSS): Response {
return {
url,
text: vi.fn().mockResolvedValue(text),
} as unknown as Response;
}
describe('fetch-rss endpoint', () => {
let httpRequestService: Mocked<HttpRequestService>;
let endpoint: FetchRssEndpoint;
beforeEach(() => {
rssParserMocks.constructor.mockReset();
rssParserMocks.parseString.mockReset();
rssParserMocks.parseString.mockResolvedValue({ items: [] });
httpRequestService = {
send: vi.fn(),
} as unknown as Mocked<HttpRequestService>;
endpoint = new FetchRssEndpoint(httpRequestService);
});
async function exec(url: string) {
return await endpoint.exec({ url }, null, null);
}
async function expectApiError(promise: Promise<unknown>, code: string, status: number) {
await expect(promise).rejects.toMatchObject({
code,
httpStatusCode: status,
info: undefined,
});
}
test.each([
'',
'not a URL',
'ftp://example.com/feed.xml',
`https://example.com/${'a'.repeat(8192)}`,
])('rejects invalid URL: %s', async (url) => {
await expectApiError(exec(url), 'INVALID_URL', 400);
expect(httpRequestService.send).not.toHaveBeenCalled();
});
test.each([
'https://user@example.com/feed.xml',
'https://user:password@example.com/feed.xml',
])('rejects URL containing credentials: %s', async (url) => {
await expectApiError(exec(url), 'INVALID_URL', 400);
expect(httpRequestService.send).not.toHaveBeenCalled();
});
test('does not expose details from internal errors', async () => {
httpRequestService.send.mockRejectedValue(new Error('secret upstream details'));
const promise = exec('https://example.com/feed.xml');
await expectApiError(promise, 'FETCH_RSS_FAILED', 422);
await expect(promise).rejects.not.toMatchObject({
message: expect.stringContaining('secret upstream details'),
});
});
test('passes the 1 MiB response limit to HttpRequestService', async () => {
httpRequestService.send.mockResolvedValue(response('https://example.com/feed.xml'));
await exec('https://example.com/feed.xml');
expect(httpRequestService.send).toHaveBeenCalledWith('https://example.com/feed.xml', expect.objectContaining({
size: 1024 * 1024,
}));
});
test('creates an asynchronous RSS parser for every request', async () => {
httpRequestService.send
.mockResolvedValueOnce(response('https://example.com/first.xml'))
.mockResolvedValueOnce(response('https://example.com/second.xml'));
await exec('https://example.com/first.xml');
await exec('https://example.com/second.xml');
expect(rssParserMocks.constructor).toHaveBeenCalledTimes(2);
expect(rssParserMocks.constructor).toHaveBeenNthCalledWith(1, { xml2js: { async: true } });
expect(rssParserMocks.constructor).toHaveBeenNthCalledWith(2, { xml2js: { async: true } });
});
test('rejects a non-HTTP final URL without exposing it', async () => {
httpRequestService.send.mockResolvedValue(response('file:///secret/feed.xml'));
await expectApiError(exec('https://example.com/feed.xml'), 'FETCH_RSS_FAILED', 422);
});
test('shares an in-flight request for the same normalized URL', async () => {
const pending = deferred<Response>();
httpRequestService.send.mockReturnValue(pending.promise);
const first = exec('HTTPS://EXAMPLE.COM:443/feed.xml#first');
const second = exec('https://example.com/feed.xml#second');
expect(httpRequestService.send).toHaveBeenCalledTimes(1);
pending.resolve(response('https://example.com/feed.xml'));
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
expect(httpRequestService.send).toHaveBeenCalledTimes(1);
});
test('limits concurrent requests for different URLs to 32', async () => {
const pending = deferred<Response>();
httpRequestService.send.mockReturnValue(pending.promise);
const requests = Array.from({ length: 32 }, (_, i) => exec(`https://example.com/${i}.xml`));
expect(httpRequestService.send).toHaveBeenCalledTimes(32);
await expectApiError(exec('https://example.com/overflow.xml'), 'FETCH_RSS_UNAVAILABLE', 503);
expect(httpRequestService.send).toHaveBeenCalledTimes(32);
pending.resolve(response('https://example.com/feed.xml'));
await Promise.all(requests);
httpRequestService.send.mockResolvedValue(response('https://example.com/after.xml'));
await expect(exec('https://example.com/after.xml')).resolves.toBeDefined();
});
test('cleans up the in-flight request and concurrency slot after success', async () => {
httpRequestService.send.mockResolvedValue(response('https://example.com/feed.xml'));
await exec('https://example.com/feed.xml');
await exec('https://example.com/feed.xml');
expect(httpRequestService.send).toHaveBeenCalledTimes(2);
});
test('cleans up the in-flight request and concurrency slot after failure', async () => {
httpRequestService.send.mockRejectedValue(new Error('upstream failed'));
const requests = Array.from({ length: 32 }, (_, i) => exec(`https://example.com/${i}.xml`));
await Promise.allSettled(requests);
httpRequestService.send.mockResolvedValue(response('https://example.com/0.xml'));
await expect(exec('https://example.com/0.xml')).resolves.toBeDefined();
expect(httpRequestService.send).toHaveBeenCalledTimes(33);
});
test('has the expected rate limit metadata', () => {
expect(meta.limit).toEqual({
duration: 60 * 1000,
max: 300,
});
});
test('uses only the declared structured API errors', () => {
expect(new ApiError(meta.errors.invalidUrl)).toMatchObject({ code: 'INVALID_URL', httpStatusCode: 400 });
expect(new ApiError(meta.errors.fetchRssFailed)).toMatchObject({ code: 'FETCH_RSS_FAILED', httpStatusCode: 422 });
expect(new ApiError(meta.errors.fetchRssUnavailable)).toMatchObject({ code: 'FETCH_RSS_UNAVAILABLE', httpStatusCode: 503 });
});
});
@@ -1,53 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { shouldRegisterHttpServerInstrumentation, registerHttpServerInstrumentation } from '@/server/http-server-instrumentation.js';
const mocks = vi.hoisted(() => ({
plugin: vi.fn(),
instrumentation: vi.fn(),
}));
vi.mock('@fastify/otel', () => ({
FastifyOtelInstrumentation: class {
public plugin = mocks.plugin;
public constructor(options: unknown) {
mocks.instrumentation(options);
}
},
}));
describe('http-server-instrumentation', () => {
test('registers Fastify instrumentation when only OpenTelemetry is configured', async () => {
const plugin = vi.fn();
const fastify = { register: vi.fn().mockResolvedValue(undefined) };
mocks.plugin.mockReturnValue(plugin);
await registerHttpServerInstrumentation(fastify as any, { otelForBackend: {} } as any);
expect(mocks.instrumentation).toHaveBeenCalledTimes(1);
expect(fastify.register).toHaveBeenCalledWith(plugin);
const requestHook = mocks.instrumentation.mock.calls[0][0].requestHook;
const span = { updateName: vi.fn() };
requestHook(span, { method: 'POST', routeOptions: { url: '/notes/create' } });
expect(span.updateName).toHaveBeenCalledWith('POST /notes/create');
});
test('does not register duplicate request instrumentation with Sentry', async () => {
const fastify = { register: vi.fn() };
await registerHttpServerInstrumentation(fastify as any, { otelForBackend: {}, sentryForBackend: {} } as any);
expect(fastify.register).not.toHaveBeenCalled();
expect(shouldRegisterHttpServerInstrumentation({ otelForBackend: {}, sentryForBackend: {} } as any)).toBe(false);
});
test('does not register instrumentation without OpenTelemetry', () => {
expect(shouldRegisterHttpServerInstrumentation({} as any)).toBe(false);
});
});
@@ -1,108 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { installDatabaseInstrumentation, installInstrumentation } from '@/core/telemetry/database-instrumentation.js';
describe('database-instrumentation', () => {
test('does not install PostgreSQL instrumentation when disabled', async () => {
const uninstall = await installDatabaseInstrumentation({} as any, {
capturePgSpans: false,
capturePgStatement: false,
capturePgConnectionSpans: false,
});
expect(uninstall).toBeTypeOf('function');
expect(() => uninstall()).not.toThrow();
});
test('registers pg instrumentation with the active provider', () => {
const provider = {};
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
const config = vi.fn();
const PgInstrumentation = class {
public constructor(options: unknown) {
config(options);
return pg as any;
}
};
const uninstall = installInstrumentation(provider as any, {
PgInstrumentation: PgInstrumentation as any,
}, {
capturePgStatement: false,
capturePgConnectionSpans: false,
});
expect(pg.setTracerProvider).toHaveBeenCalledWith(provider);
expect(pg.enable).toHaveBeenCalledOnce();
expect(config).toHaveBeenCalledWith(expect.objectContaining({
enhancedDatabaseReporting: false,
requireParentSpan: true,
ignoreConnectSpans: true,
requestHook: expect.any(Function),
}));
const span = { setAttribute: vi.fn() };
(config.mock.calls[0][0] as { requestHook: (span: any) => void }).requestHook(span);
expect(span.setAttribute).toHaveBeenCalledWith('db.statement', '[REDACTED]');
expect(span.setAttribute).toHaveBeenCalledWith('db.query.text', '[REDACTED]');
uninstall();
expect(pg.disable).toHaveBeenCalledOnce();
});
test('keeps SQL statement attributes when explicitly enabled', () => {
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
const config = vi.fn();
installInstrumentation({} as any, {
PgInstrumentation: class {
public constructor(options: unknown) {
config(options);
return pg as any;
}
} as any,
}, {
capturePgStatement: true,
capturePgConnectionSpans: false,
});
expect(config.mock.calls[0][0]).not.toHaveProperty('requestHook');
});
test('enables connection spans when explicitly configured', () => {
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
const config = vi.fn();
installInstrumentation({} as any, {
PgInstrumentation: class {
public constructor(options: unknown) {
config(options);
return pg as any;
}
} as any,
}, {
capturePgStatement: false,
capturePgConnectionSpans: true,
});
expect(config).toHaveBeenCalledWith(expect.objectContaining({
ignoreConnectSpans: false,
}));
});
test('cleans up both instrumentations when initialization fails', () => {
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
pg.enable.mockImplementation(() => { throw new Error('failed'); });
expect(() => installInstrumentation({} as any, {
PgInstrumentation: class { public constructor() { return pg as any; } } as any,
})).toThrow('failed');
expect(pg.disable).toHaveBeenCalledOnce();
});
});
@@ -1,148 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
import { createRedisInstrumentation } from '@/core/telemetry/redis-instrumentation.js';
describe('redis-instrumentation', () => {
test('creates and completes a span for an ioredis command', () => {
let subscribers: any;
const unsubscribe = vi.fn();
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
const tracer = { startSpan: vi.fn(() => span) };
const uninstall = createRedisInstrumentation({
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe }),
tracer: tracer as any,
getActiveSpan: () => ({}) as any,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}, { captureCommandSpans: true });
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 };
subscribers.start(command);
subscribers.asyncEnd(command);
expect(tracer.startSpan).toHaveBeenCalledWith('get', expect.objectContaining({
kind: SpanKind.CLIENT,
attributes: expect.objectContaining({
'db.system.name': 'redis',
'db.operation.name': 'get',
'server.address': 'redis',
'server.port': 6379,
}),
}));
expect(span.end).toHaveBeenCalledOnce();
uninstall();
expect(unsubscribe).toHaveBeenCalledWith(subscribers);
});
test('records rejected Redis commands as errors', () => {
let subscribers: any;
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
createRedisInstrumentation({
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
tracer: { startSpan: vi.fn(() => span) } as any,
getActiveSpan: () => ({}) as any,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}, { captureCommandSpans: true });
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: undefined };
const error = Object.assign(new Error('ERR Redis failed'), { code: 'ERR' });
subscribers.start(command);
Object.assign(command, { error });
subscribers.error(command);
subscribers.asyncEnd(command);
expect(span.recordException).toHaveBeenCalledWith(error);
expect(span.recordException).toHaveBeenCalledOnce();
expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, message: 'ERR Redis failed' });
expect(span.setAttribute).toHaveBeenCalledWith('error.type', 'ERR');
expect(span.setAttribute).toHaveBeenCalledWith('db.response.status_code', 'ERR');
expect(span.end).toHaveBeenCalledOnce();
});
test('does not create a root span when no parent span is active', () => {
let subscribers: any;
const tracer = { startSpan: vi.fn() };
createRedisInstrumentation({
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
tracer: tracer as any,
getActiveSpan: () => undefined,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}, { captureCommandSpans: true });
subscribers.start({ command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 });
expect(tracer.startSpan).not.toHaveBeenCalled();
});
test('creates a root span when explicitly enabled', () => {
let subscribers: any;
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
const tracer = { startSpan: vi.fn(() => span) };
createRedisInstrumentation({
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
tracer: tracer as any,
getActiveSpan: () => undefined,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}, { captureCommandSpans: true, requireParentSpan: false });
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 };
subscribers.start(command);
subscribers.asyncEnd(command);
expect(tracer.startSpan).toHaveBeenCalledOnce();
expect(span.end).toHaveBeenCalledOnce();
});
test('records connection spans only when explicitly enabled', () => {
const subscribers = new Map<string, any>();
const unsubscribe = vi.fn();
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
const tracer = { startSpan: vi.fn(() => span) };
const uninstall = createRedisInstrumentation({
tracingChannel: (name) => ({ subscribe: (value) => { subscribers.set(name, value); }, unsubscribe }),
tracer: tracer as any,
getActiveSpan: () => undefined,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
}, { captureConnectionSpans: true });
const connection = { serverAddress: 'redis', serverPort: 6379 };
subscribers.get('ioredis:connect').start(connection);
subscribers.get('ioredis:connect').asyncEnd(connection);
expect(tracer.startSpan).toHaveBeenCalledWith('connect', expect.objectContaining({
kind: SpanKind.CLIENT,
attributes: expect.objectContaining({
'db.operation.name': 'connect',
'server.address': 'redis',
'server.port': 6379,
}),
}));
expect(span.end).toHaveBeenCalledOnce();
uninstall();
expect(unsubscribe).toHaveBeenCalledWith(subscribers.get('ioredis:connect'));
});
test('does not subscribe to Redis command diagnostics unless explicitly enabled', () => {
const tracingChannel = vi.fn();
createRedisInstrumentation({
tracingChannel,
tracer: { startSpan: vi.fn() } as any,
getActiveSpan: () => undefined,
spanKindClient: SpanKind.CLIENT,
spanStatusCodeError: SpanStatusCode.ERROR,
});
expect(tracingChannel).not.toHaveBeenCalled();
});
});
@@ -9,8 +9,6 @@ import type { Config } from '@/config.js';
const mocks = vi.hoisted(() => {
return {
sentryCreate: vi.fn(),
sentryCreateWithOtlpExport: vi.fn(),
otelCreate: vi.fn(),
setLogTraceContextProvider: vi.fn(),
};
});
@@ -22,13 +20,6 @@ vi.mock('@/logging/logging-runtime.js', () => ({
vi.mock('@/core/telemetry/adapters/SentryTelemetryAdapter.js', () => ({
SentryTelemetryAdapter: {
create: mocks.sentryCreate,
createWithOtlpExport: mocks.sentryCreateWithOtlpExport,
},
}));
vi.mock('@/core/telemetry/adapters/OpenTelemetryAdapter.js', () => ({
OpenTelemetryAdapter: {
create: mocks.otelCreate,
},
}));
@@ -43,44 +34,37 @@ describe('telemetry-registry', () => {
beforeEach(() => {
vi.resetModules();
mocks.sentryCreate.mockReset();
mocks.sentryCreateWithOtlpExport.mockReset();
mocks.otelCreate.mockReset();
mocks.setLogTraceContextProvider.mockReset();
mocks.sentryCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() });
mocks.sentryCreateWithOtlpExport.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() });
mocks.otelCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() });
});
test('uses OpenTelemetryAdapter when only otelForBackend is configured', async () => {
test('does not initialize an adapter when Sentry is not configured', async () => {
const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
await initTelemetry(config({ otelForBackend }));
await initTelemetry(config({}));
expect(mocks.otelCreate).toHaveBeenCalledWith({
...otelForBackend,
serviceVersion: '2026.1.0',
});
expect(mocks.sentryCreate).not.toHaveBeenCalled();
expect(mocks.sentryCreateWithOtlpExport).not.toHaveBeenCalled();
expect(mocks.setLogTraceContextProvider).not.toHaveBeenCalled();
});
test('registers the adapter trace context provider after telemetry initialization', async () => {
test('initializes Sentry and registers its trace context provider', async () => {
const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
const sentryForBackend = { options: {}, enableNodeProfiling: false };
const getActiveTraceContext = vi.fn(() => ({
traceId: '0123456789abcdef0123456789abcdef',
spanId: '0123456789abcdef',
traceFlags: 0,
}));
mocks.otelCreate.mockResolvedValue({
mocks.sentryCreate.mockResolvedValue({
shutdown: vi.fn(),
captureMessage: vi.fn(),
startSpan: vi.fn(),
getActiveTraceContext,
});
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } }));
await initTelemetry(config({ sentryForBackend }));
expect(mocks.sentryCreate).toHaveBeenCalledWith(sentryForBackend);
expect(mocks.setLogTraceContextProvider).toHaveBeenCalledWith(expect.any(Function));
const provider = mocks.setLogTraceContextProvider.mock.calls[0][0] as () => unknown;
expect(provider()).toEqual({
@@ -91,21 +75,6 @@ describe('telemetry-registry', () => {
expect(getActiveTraceContext).toHaveBeenCalledOnce();
});
test('adds OTLP export to the Sentry provider when both Sentry and OTel are configured', async () => {
const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
const sentryForBackend = { options: {}, enableNodeProfiling: false };
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
await initTelemetry(config({ sentryForBackend, otelForBackend }));
expect(mocks.sentryCreateWithOtlpExport).toHaveBeenCalledWith(sentryForBackend, {
...otelForBackend,
serviceVersion: '2026.1.0',
});
expect(mocks.sentryCreate).not.toHaveBeenCalled();
expect(mocks.otelCreate).not.toHaveBeenCalled();
});
test('startSpan runs fn directly when no adapter is registered', async () => {
const { startSpan } = await import('@/core/telemetry/telemetry-registry.js');
@@ -114,67 +83,32 @@ describe('telemetry-registry', () => {
expect(fn).toHaveBeenCalledTimes(1);
});
test('startSpan delegates directly to the single registered adapter without extra wrapping', async () => {
test('startSpan delegates to the Sentry adapter', async () => {
const { initTelemetry, startSpan } = await import('@/core/telemetry/telemetry-registry.js');
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
const adapterStartSpan = vi.fn((_name: string, fn: () => string) => fn());
mocks.otelCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: adapterStartSpan });
mocks.sentryCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: adapterStartSpan });
await initTelemetry(config({ otelForBackend }));
await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } }));
const fn = vi.fn().mockReturnValue('result');
expect(startSpan('test', fn)).toBe('result');
expect(adapterStartSpan).toHaveBeenCalledWith('test', fn);
});
test('startSpan wraps work through multiple registered adapters in order for future adapter combinations', async () => {
const { initTelemetry, startSpan } = await import('@/core/telemetry/telemetry-registry.js');
const calls: string[] = [];
mocks.sentryCreate.mockResolvedValue({
shutdown: vi.fn(),
captureMessage: vi.fn(),
startSpan: vi.fn((_name: string, fn: () => string) => {
calls.push('sentry:start');
const result = fn();
calls.push('sentry:end');
return result;
}),
});
mocks.otelCreate.mockResolvedValue({
shutdown: vi.fn(),
captureMessage: vi.fn(),
startSpan: vi.fn((_name: string, fn: () => string) => {
calls.push('otel:start');
const result = fn();
calls.push('otel:end');
return result;
}),
});
await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } }));
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } }));
const fn = vi.fn(() => {
calls.push('work');
return 'result';
});
expect(startSpan('test', fn)).toBe('result');
expect(calls).toEqual(['sentry:start', 'otel:start', 'work', 'otel:end', 'sentry:end']);
});
test('shutdownTelemetry waits for every adapter even when one shutdown rejects', async () => {
test('shutdownTelemetry waits for all registered adapters even when one rejects', async () => {
const { initTelemetry, shutdownTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
const sentryShutdown = vi.fn().mockRejectedValue(new Error('sentry failed'));
const otelShutdown = vi.fn().mockResolvedValue(undefined);
mocks.sentryCreate.mockResolvedValue({ shutdown: sentryShutdown, captureMessage: vi.fn(), startSpan: vi.fn() });
mocks.otelCreate.mockResolvedValue({ shutdown: otelShutdown, captureMessage: vi.fn(), startSpan: vi.fn() });
const firstShutdown = vi.fn().mockRejectedValue(new Error('first failed'));
const secondShutdown = vi.fn().mockResolvedValue(undefined);
mocks.sentryCreate
.mockResolvedValueOnce({ shutdown: firstShutdown, captureMessage: vi.fn(), startSpan: vi.fn() })
.mockResolvedValueOnce({ shutdown: secondShutdown, captureMessage: vi.fn(), startSpan: vi.fn() });
await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } }));
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } }));
const sentryForBackend = { options: {}, enableNodeProfiling: false };
await initTelemetry(config({ sentryForBackend }));
await initTelemetry(config({ sentryForBackend }));
await expect(shutdownTelemetry()).resolves.toBeUndefined();
expect(sentryShutdown).toHaveBeenCalledTimes(1);
expect(otelShutdown).toHaveBeenCalledTimes(1);
expect(firstShutdown).toHaveBeenCalledTimes(1);
expect(secondShutdown).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,5 @@
# frontend用Misskey Worldエンジン
エンジンはWeb Worker内で動作し、ほぼすべてのMisskey Webの機能は使えないため、意図しないそれらへの参照/依存が原理的に発生しないように別パッケージとする
ただしヘッドレス動作することは(今のところ)意図していない
@@ -0,0 +1,28 @@
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../shared/eslint.config.js';
// eslint-disable-next-line import/no-default-export
export default [
...sharedConfig,
{
ignores: [
'**/node_modules',
'built',
'coverage',
'jest.config.ts',
'test',
'test-d',
],
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
},
];
@@ -0,0 +1,34 @@
{
"type": "module",
"name": "frontend-misskey-world-engine",
"private": true,
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"typecheck": "tsgo --noEmit",
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/seedrandom": "3.0.8",
"@typescript-eslint/eslint-plugin": "8.59.2",
"@typescript-eslint/parser": "8.59.2",
"esbuild": "0.28.0",
"execa": "9.6.1",
"nodemon": "3.1.14",
"throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6"
},
"files": [
"built"
],
"dependencies": {
"@babylonjs/core": "9.18.0",
"@babylonjs/inspector": "9.18.0",
"@babylonjs/loaders": "9.18.0",
"@babylonjs/materials": "9.18.0",
"@types/throttle-debounce": "5.0.2",
"eventemitter3": "5.0.4",
"seedrandom": "3.0.5",
"tinycolor2": "1.6.0",
"hls.js": "1.6.16"
}
}
@@ -0,0 +1,237 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import EventEmitter from 'eventemitter3';
import { PlayerContainer, type PlayerProfile, type PlayerState } from './PlayerContainer.js';
const IN_WEB_WORKER = typeof window === 'undefined';
export type EngineBaseEvents = {
'loadingProgress': (ctx: { progress: number }) => void;
'contextlost': (ctx: { reason: string; message: string; }) => void;
};
export abstract class EngineBase<EVs extends EngineBaseEvents> extends EventEmitter<{
'ev': (ctx: { type: keyof EVs; ctx: Parameters<EVs[keyof EVs]>[0] }) => void;
}> {
declare _eventTypes?: EVs;
protected engine: BABYLON.WebGPUEngine;
public scene: BABYLON.Scene;
abstract sr: BABYLON.SnapshotRenderingHelper;
abstract lightContainer: BABYLON.ClusteredLightContainer;
abstract getEnvMap(): BABYLON.CubeTexture | null;
protected fps: number | null = null;
protected disposed = false;
protected playerProfiles: Record<string, PlayerProfile> = {};
protected playerContainers: PlayerContainer[] = [];
protected showUsernameOnAvatar: boolean;
protected show2dAvatarOnAvatar: boolean;
public inputs: EventEmitter<{
'click': (event: { x: number; y: number; }) => void;
'keydown': (event: { code: string; shiftKey: boolean; }) => void;
'keyup': (event: { code: string; shiftKey: boolean; }) => void;
'wheel': (event: { deltaY: number; }) => void;
'zoom': (event: { delta: number; }) => void;
'pointer': (event: { x: number; y: number; }) => void;
}> = new EventEmitter();
constructor(options: {
engine: BABYLON.WebGPUEngine;
fps: number | null;
}) {
super();
this.fps = options.fps;
this.engine = options.engine;
// doNotHandleContextLostがtrueだとそもそも呼ばれない
//babylonEngine.onContextLostObservable.add(() => {
// os.alert({
// type: 'error',
// title: i18n.ts.somethingHappened,
// text: i18n.ts._miWorld.crushed_description,
// });
//});
this.engine._device.lost.then((info) => { // TODO: babylonEngineの内部プロパティに依存しない方法をforumで聞く
this.ev('contextlost', { reason: info.reason, message: info.message }); // transferableじゃないデータが含まれている可能性も考慮してinfoそのままは送らない
});
this.scene = new BABYLON.Scene(this.engine);
}
private currentRafId: number | null = null;
protected startRenderLoop() {
if (this.fps == null) {
this.engine.runRenderLoop(() => {
this.scene.render();
});
} else {
let then = 0;
const interval = 1000 / this.fps;
const renderLoop = (timeStamp: number) => {
if (this.disposed) return;
// workerで実行される可能性がある
this.currentRafId = requestAnimationFrame(renderLoop);
const delta = timeStamp - then;
if (delta <= interval) return;
then = timeStamp - (delta % interval);
this.engine.beginFrame();
this.scene.render();
this.engine.endFrame();
};
// workerで実行される可能性がある
this.currentRafId = requestAnimationFrame(renderLoop);
}
}
public pauseRender() { // TODO: srと同じく参照カウント方式にした方が便利そう
this.engine.stopRenderLoop();
if (this.currentRafId != null) {
// workerで実行される可能性がある
cancelAnimationFrame(this.currentRafId);
this.currentRafId = null;
}
}
public resumeRender() {
this.startRenderLoop();
}
public abstract init(): Promise<void>;
protected ev<K extends keyof EVs>(type: K, ctx: Parameters<EVs[K]>[0]) {
this.emit('ev', { type, ctx });
}
public async takeScreenshot() {
return await BABYLON.Tools.CreateScreenshotAsync(this.engine, this.scene.activeCamera!, { precision: 1 });
}
public abstract resize(): void;
public updatePlayerProfiles(profiles: Record<string, PlayerProfile>) {
this.playerProfiles = profiles;
for (const playerContainer of this.playerContainers) {
if (this.playerProfiles[playerContainer.id] == null) {
this.sr.disableSnapshotRendering();
playerContainer.destroy();
this.sr.enableSnapshotRendering();
}
}
this.playerContainers = this.playerContainers.filter(p => this.playerProfiles[p.id] != null);
}
public updatePlayerStates(states: Record<string, PlayerState>) {
for (const [k, v] of Object.entries(this.playerProfiles)) {
const playerContainer = this.playerContainers.find(p => p.id === k);
if (playerContainer == null) {
const p = new PlayerContainer({
id: k,
profile: v,
state: states[k],
scene: this.scene,
sr: this.sr,
showUsername: this.showUsernameOnAvatar,
show2dAvatar: this.show2dAvatarOnAvatar,
});
// TODO: loadFurnitureのものとある程度共通化
p.registerMeshes = (meshes) => {
for (const mesh of meshes) {
mesh.receiveShadows = false;
mesh.metadata = { isPlayer: true, playerId: k };
//if (mesh.material) (mesh.material as BABYLON.PBRMaterial).ambientColor = new BABYLON.Color3(0.2, 0.2, 0.2);
if (mesh.material) {
if (mesh.material instanceof BABYLON.MultiMaterial) {
for (const subMat of mesh.material.subMaterials) {
if ((subMat as BABYLON.PBRMaterial).subSurface.isRefractionEnabled) {
(subMat as BABYLON.PBRMaterial).subSurface.isRefractionEnabled = false; // 有効にするとドローコールが激増する
(subMat as BABYLON.PBRMaterial).transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND;
(subMat as BABYLON.PBRMaterial).alpha = 0.5;
(subMat as BABYLON.PBRMaterial).metallic = 1;
}
(subMat as BABYLON.PBRMaterial).reflectionTexture = this.getEnvMap();
if ((subMat as BABYLON.PBRMaterial).metadata == null) (subMat as BABYLON.PBRMaterial).metadata = {};
(subMat as BABYLON.PBRMaterial).metadata.useEnvMap = true;
(subMat as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
(subMat as BABYLON.PBRMaterial).anisotropy.isEnabled = false; // なんかきれいにレンダリングされないため
}
} else {
if ((mesh.material as BABYLON.PBRMaterial).subSurface.isRefractionEnabled) {
(mesh.material as BABYLON.PBRMaterial).subSurface.isRefractionEnabled = false; // 有効にするとドローコールが激増する
(mesh.material as BABYLON.PBRMaterial).transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND;
(mesh.material as BABYLON.PBRMaterial).alpha = 0.5;
(mesh.material as BABYLON.PBRMaterial).metallic = 1;
}
(mesh.material as BABYLON.PBRMaterial).reflectionTexture = this.getEnvMap();
if ((mesh.material as BABYLON.PBRMaterial).metadata == null) (mesh.material as BABYLON.PBRMaterial).metadata = {};
(mesh.material as BABYLON.PBRMaterial).metadata.useEnvMap = true;
(mesh.material as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
(mesh.material as BABYLON.PBRMaterial).anisotropy.isEnabled = false; // なんかきれいにレンダリングされないため
}
}
if (!this.scene.meshes.includes(mesh)) this.scene.addMesh(mesh);
}
};
p.loadAvatar().then(() => {
this.sr.disableSnapshotRendering();
this.sr.enableSnapshotRendering();
});
this.playerContainers.push(p);
} else {
if (states[k] != null) {
playerContainer.applyState(states[k]);
}
}
}
}
public clearPlayers() {
this.sr.disableSnapshotRendering();
for (const playerContainer of this.playerContainers) {
playerContainer.destroy();
}
this.sr.enableSnapshotRendering();
this.playerContainers = [];
}
public updateAvatarDisplayOptions(options: { showUsername: boolean; show2dAvatar: boolean }) {
this.showUsernameOnAvatar = options.showUsername;
this.show2dAvatarOnAvatar = options.show2dAvatar;
this.sr.disableSnapshotRendering();
for (const playerContainer of this.playerContainers) {
playerContainer.updateUserInfoDisplayOptions(options);
}
this.sr.enableSnapshotRendering();
}
public destroy() {
this.engine.stopRenderLoop();
if (this.currentRafId != null) {
// workerで実行される可能性がある
cancelAnimationFrame(this.currentRafId);
this.currentRafId = null;
}
for (const playerContainer of this.playerContainers) {
playerContainer.destroy();
}
this.engine.dispose();
this.scene.dispose();
this.disposed = true;
}
}
@@ -0,0 +1,171 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { randomRange, Timer } from './utility.js';
import type { EngineBase } from './EngineBase.js';
export class Firework {
private engine: EngineBase<any>;
private timer: Timer = new Timer();
private texturePatterns = [
{ path: '/client-assets/world/other/firework/flare-glow-1.png', color: new BABYLON.Color3(0.0, 1.0, 0.0) },
{ path: '/client-assets/world/other/firework/flare-glow-2.png', color: new BABYLON.Color3(0.0, 1.0, 1.0) },
{ path: '/client-assets/world/other/firework/flare-glow-3.png', color: new BABYLON.Color3(0.0, 0.0, 1.0) },
{ path: '/client-assets/world/other/firework/flare-glow-4.png', color: new BABYLON.Color3(1.0, 0.0, 1.0) },
{ path: '/client-assets/world/other/firework/flare-glow-5.png', color: new BABYLON.Color3(1.0, 0.0, 0.0) },
{ path: '/client-assets/world/other/firework/flare-glow-6.png', color: new BABYLON.Color3(1.0, 0.5, 0.0) },
{ path: '/client-assets/world/other/firework/flare-glow-7.png', color: new BABYLON.Color3(1.0, 1.0, 0.0) },
{ path: '/client-assets/world/other/firework/flare-glow-8.png', color: new BABYLON.Color3(0.5, 1.0, 0.0) },
];
constructor(engine: EngineBase<any>) {
this.engine = engine;
}
public launch(options: {
position: [number, number, number];
}) {
this.engine.sr.disableSnapshotRendering();
const texturePattern = this.texturePatterns[Math.floor(Math.random() * this.texturePatterns.length)];
const texture = new BABYLON.Texture(texturePattern.path, this.engine.scene);
const textureScaleFactor = 3;
//const emitter = new BABYLON.TransformNode('emitter', this.engine.scene);
const emitter = BABYLON.MeshBuilder.CreateBox('emitter', { size: cm(10) }, this.engine.scene);
emitter.position = new BABYLON.Vector3(options.position[0], options.position[1], options.position[2]);
const ps = new BABYLON.ParticleSystem('', 32, this.engine.scene);
ps.particleTexture = texture;
ps.emitter = emitter;
ps.minEmitPower = cm(100);
ps.maxEmitPower = cm(500);
ps.minLifeTime = 0.5;
ps.maxLifeTime = 1;
ps.minSize = cm(3) * textureScaleFactor;
ps.maxSize = cm(30) * textureScaleFactor;
ps.addDragGradient(0, 0.1);
ps.addDragGradient(1, 0.8);
//ps.direction1 = new BABYLON.Vector3(0, 1, 0);
//ps.direction2 = new BABYLON.Vector3(0, 1, 0);
ps.emitRate = 30;
ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;
//ps.color1 = new BABYLON.Color4(1, 1, 1, 0.3);
//ps.color2 = new BABYLON.Color4(1, 1, 1, 0.2);
ps.colorDead = new BABYLON.Color4(1, 1, 1, 0);
ps.start();
this.engine.sr.fixParticleSystem(ps);
const launchAnim = new BABYLON.Animation(
'',
'position',
60,
BABYLON.Animation.ANIMATIONTYPE_VECTOR3,
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT,
);
launchAnim.setKeys([
{ frame: 0, value: new BABYLON.Vector3(options.position[0], options.position[1], options.position[2]) },
{ frame: 60, value: new BABYLON.Vector3(options.position[0], options.position[1] + cm(randomRange(2000, 6000)), options.position[2]) },
]);
emitter.animations.push(launchAnim);
const animating = Promise.withResolvers<void>();
this.engine.scene.beginAnimation(emitter, 0, 60, false, 1, () => { animating.resolve(); });
animating.promise.then(() => {
ps.stop();
this.explode({
position: [emitter.position.x, emitter.position.y, emitter.position.z],
texture,
textureScaleFactor,
color: texturePattern.color,
callback: () => { // explode途中でSRの状態を切り替えるとパーティクルが消える現象があるため、explodeが終了してから片づける
this.engine.sr.disableSnapshotRendering();
ps.dispose();
emitter.dispose();
this.engine.sr.enableSnapshotRendering();
},
});
});
this.engine.sr.enableSnapshotRendering();
}
public explode(options: {
position: [number, number, number];
texture: BABYLON.Texture;
textureScaleFactor: number;
color: BABYLON.Color3;
callback: () => void;
}) {
this.engine.sr.disableSnapshotRendering();
const pos = new BABYLON.Vector3(options.position[0], options.position[1], options.position[2]);
const light = new BABYLON.PointLight('', pos, this.engine.scene, true);
light.range = cm(10000);
light.radius = cm(50);
light.intensity = 100000 * WORLD_SCALE * WORLD_SCALE;
light.diffuse = options.color;
this.engine.lightContainer.addLight(light);
const lightAnim = new BABYLON.Animation(
'',
'intensity',
120,
BABYLON.Animation.ANIMATIONTYPE_FLOAT,
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT,
);
lightAnim.setKeys([
{ frame: 0, value: 100000 * WORLD_SCALE * WORLD_SCALE },
{ frame: 120, value: 0 },
]);
light.animations.push(lightAnim);
this.engine.scene.beginAnimation(light, 0, 120, false, 1);
const ps = new BABYLON.ParticleSystem('', 128, this.engine.scene);
ps.particleTexture = options.texture;
ps.emitter = pos;
ps.minEmitPower = cm(3700);
ps.maxEmitPower = cm(4000);
ps.minLifeTime = 0.8;
ps.maxLifeTime = 1;
ps.addDragGradient(0, 0.1);
ps.addDragGradient(1, 0.8);
ps.gravity = new BABYLON.Vector3(0, -50, 0).scale(WORLD_SCALE);
ps.minSize = cm(50) * options.textureScaleFactor;
ps.maxSize = cm(50) * options.textureScaleFactor;
const sphereEmitter = ps.createSphereEmitter(cm(1));
//ps.direction1 = new BABYLON.Vector3(0, 1, 0);
//ps.direction2 = new BABYLON.Vector3(0, 1, 0);
ps.manualEmitCount = 100;
ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_MULTIPLYADD;
ps.addColorGradient(0.0, new BABYLON.Color4(1, 1, 1, 1));
ps.addColorGradient(0.7, new BABYLON.Color4(1, 1, 1, 1));
ps.addColorGradient(1.0, new BABYLON.Color4(1, 1, 1, 0));
ps.start();
this.engine.sr.fixParticleSystem(ps);
this.timer.setTimeout(() => {
this.engine.sr.disableSnapshotRendering();
ps.dispose();
light.dispose();
this.engine.lightContainer.removeLight(light);
this.engine.scene.removeLight(light); // lc使用時はsceneには追加してないはずだが、これがないとクラッシュする babylonのバグ?
this.engine.sr.enableSnapshotRendering();
options.callback();
}, 2000);
this.engine.sr.enableSnapshotRendering();
}
public dispose() {
this.timer.dispose();
}
}
@@ -0,0 +1,335 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { AccessoryContainer } from './avatars/AccessoryContainer.js';
import { getAccessoryDef } from './avatars/accessory-defs.js';
import { createTextMesh, Timer } from './utility.js';
import type { WorldAvatar } from 'misskey-world/src/types.js';
export type PlayerProfile = {
user: {
name: string;
username: string;
avatarUrl: string;
} | null;
avatar: WorldAvatar;
};
export type PlayerState = {
position: [number, number, number],
rotation: [number, number, number],
sit?: string; // id
};
const DEFAULT_FACE_PARTS_EYES = {
'_none_': null,
'a': '/client-assets/world/avatars/eyes-a.png',
'b': '/client-assets/world/avatars/eyes-b.png',
'c': '/client-assets/world/avatars/eyes-c.png',
'd': '/client-assets/world/avatars/eyes-d.png',
'e': '/client-assets/world/avatars/eyes-e.png',
'f': '/client-assets/world/avatars/eyes-f.png',
'g': '/client-assets/world/avatars/eyes-g.png',
};
const DEFAULT_FACE_PARTS_MOUTH = {
'_none_': null,
'a': '/client-assets/world/avatars/mouth-a.png',
'b': '/client-assets/world/avatars/mouth-b.png',
'c': '/client-assets/world/avatars/mouth-c.png',
'd': '/client-assets/world/avatars/mouth-d.png',
'e': '/client-assets/world/avatars/mouth-e.png',
'f': '/client-assets/world/avatars/mouth-f.png',
'g': '/client-assets/world/avatars/mouth-g.png',
'h': '/client-assets/world/avatars/mouth-h.png',
'i': '/client-assets/world/avatars/mouth-i.png',
};
let usernameLabelMaterial: BABYLON.StandardMaterial | null = null;
export class PlayerContainer {
public id: string;
private profile: PlayerProfile;
public root: BABYLON.TransformNode;
private subRootContainerForAnim: BABYLON.TransformNode;
private subRoot: BABYLON.TransformNode;
private modelRoot: BABYLON.TransformNode | null = null;
private sr: BABYLON.SnapshotRenderingHelper;
private scene: BABYLON.Scene;
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
private animationObserver: BABYLON.Observer<BABYLON.Scene> | null = null;
private accessoryContainers: AccessoryContainer[] = [];
private timer: Timer = new Timer();
private showUsername: boolean;
private show2dAvatar: boolean;
private usernameLabelMesh: BABYLON.Mesh | null = null;
private twodAvatarMesh: BABYLON.Mesh | null = null;
constructor(params: { id: string; profile: PlayerProfile; state: PlayerState | null; sr: BABYLON.SnapshotRenderingHelper; scene: BABYLON.Scene; showUsername: boolean; show2dAvatar: boolean; }) {
this.id = params.id;
this.profile = params.profile;
this.sr = params.sr;
this.scene = params.scene;
this.root = new BABYLON.TransformNode(`player:${this.id}`, params.scene);
this.root.rotationQuaternion = null;
this.subRootContainerForAnim = new BABYLON.TransformNode(`player:${this.id}:subRootContainerForAnim`, params.scene);
this.subRootContainerForAnim.parent = this.root;
this.subRoot = new BABYLON.TransformNode(`player:${this.id}:subRoot`, params.scene);
this.subRoot.parent = this.subRootContainerForAnim;
this.showUsername = params.showUsername;
this.show2dAvatar = params.show2dAvatar;
this.applyInfoMesh();
if (params.state) this.applyState(params.state, true);
}
public async loadAvatar() {
const filePath = '/client-assets/world/avatars/default.glb';
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
// babylonによって自動で追加される右手系変換用ノード
const modelRootMesh = loaderResult.meshes[0] as BABYLON.Mesh;
// meshじゃなくtransform nodeにしてパフォーマンス向上
this.modelRoot = new BABYLON.TransformNode('__root__', this.scene);
this.modelRoot.parent = this.subRoot;
this.modelRoot.scaling.x = -1;
this.modelRoot.scaling = this.modelRoot.scaling.scale(WORLD_SCALE);// cmをmに
for (const m of modelRootMesh.getChildren()) {
if (m.parent === modelRootMesh) {
m.parent = this.modelRoot;
}
}
modelRootMesh.dispose();
const eyesBlinkTexture = new BABYLON.Texture('/client-assets/world/avatars/eyes-blink.png', this.scene, false, false);
eyesBlinkTexture.hasAlpha = true;
let eyesTex: BABYLON.Texture | null = null;
if (this.profile.avatar.eyes.type in DEFAULT_FACE_PARTS_EYES) {
const eyesTexPath = DEFAULT_FACE_PARTS_EYES[this.profile.avatar.eyes.type];
if (eyesTexPath) {
eyesTex = new BABYLON.Texture(eyesTexPath, this.scene, false, false);
eyesTex.hasAlpha = true;
}
}
let mouthTex: BABYLON.Texture | null = null;
if (this.profile.avatar.mouth.type in DEFAULT_FACE_PARTS_MOUTH) {
const mouthTexPath = DEFAULT_FACE_PARTS_MOUTH[this.profile.avatar.mouth.type];
if (mouthTexPath) {
mouthTex = new BABYLON.Texture(mouthTexPath, this.scene, false, false);
mouthTex.hasAlpha = true;
}
}
for (const mesh of this.modelRoot.getChildMeshes()) {
if (mesh.name.includes('__BODY__')) {
mesh.material.albedoColor = new BABYLON.Color3(this.profile.avatar.body.color[0], this.profile.avatar.body.color[1], this.profile.avatar.body.color[2]);
}
if (mesh.name.includes('__EYES__')) {
const mat = new BABYLON.PBRMaterial('', this.scene);
mat.albedoColor = new BABYLON.Color3(this.profile.avatar.eyes.color[0], this.profile.avatar.eyes.color[1], this.profile.avatar.eyes.color[2]);
mat.albedoTexture = eyesTex;
mat.roughness = 1;
mat.metallic = 0;
mesh.material = mat;
// TODO: SRを無効にせずに表現する方法を考える
const blink = () => {
if (mesh.isDisposed()) return;
this.sr.disableSnapshotRendering();
mat.albedoTexture = eyesBlinkTexture;
this.sr.enableSnapshotRendering();
this.timer.setTimeout(() => {
this.sr.disableSnapshotRendering();
mat.albedoTexture = eyesTex;
this.sr.enableSnapshotRendering();
this.timer.setTimeout(() => {
blink();
}, Math.random() * 10000);
}, 100);
};
this.timer.setTimeout(() => {
blink();
}, Math.random() * 10000);
}
if (mesh.name.includes('__MOUTH__')) {
if (mouthTex != null) {
const mat = new BABYLON.PBRMaterial('', this.scene);
mat.albedoColor = new BABYLON.Color3(this.profile.avatar.mouth.color[0], this.profile.avatar.mouth.color[1], this.profile.avatar.mouth.color[2]);
mat.albedoTexture = mouthTex;
mat.roughness = 1;
mat.metallic = 0;
mesh.material = mat;
} else {
mesh.isVisible = false;
}
}
}
this.registerMeshes(this.modelRoot.getChildMeshes());
this.accessoryContainers = await Promise.all(this.profile.avatar.accessories.map(ac => this.loadAccessory({
type: ac.type,
id: ac.id,
position: new BABYLON.Vector3(0, cm(19), 0),
rotation: new BABYLON.Vector3(0, 0, 0),
options: ac.options,
})));
const anim = new BABYLON.Animation('', 'position.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: cm(0) },
{ frame: 30, value: cm(-2) },
{ frame: 60, value: cm(0) },
{ frame: 90, value: cm(2) },
{ frame: 120, value: cm(0) },
]);
this.subRootContainerForAnim.animations = [anim];
this.animationObserver = this.scene.onAfterAnimationsObservable.add(() => {
this.sr.updateMesh(this.subRootContainerForAnim.getChildMeshes(), false);
});
this.scene.beginAnimation(this.subRootContainerForAnim, 0, 120, true);
}
private async loadAccessory(args: {
type: string;
id: string;
position: BABYLON.Vector3;
rotation: BABYLON.Vector3;
options: Record<string, unknown>;
}) {
const def = getAccessoryDef(args.type);
const container = new AccessoryContainer({
id: args.id,
type: args.type,
position: args.position.clone(),
rotation: args.rotation.clone(),
options: args.options,
sr: this.sr,
getIsSrReady: () => true,
lightContainer: this.lightContainer,
graphicsQuality: this.graphicsQuality,
scene: this.scene,
});
container.registerMeshes = (meshes) => {
this.registerMeshes(meshes);
};
await container.load();
container.root.parent = this.subRoot;
return container;
}
private applyInfoMesh() {
if (this.showUsername ) {
if (this.usernameLabelMesh == null) {
if (usernameLabelMaterial == null) {
const usernameLabelTex = new BABYLON.Texture('/client-assets/world/chars-black.png', this.scene, false, false);
usernameLabelMaterial = new BABYLON.StandardMaterial('usernameLabelMaterial', this.scene);
usernameLabelMaterial.roughness = 1;
usernameLabelMaterial.diffuseColor = new BABYLON.Color3(1, 1, 1);
usernameLabelMaterial.diffuseTexture = usernameLabelTex;
usernameLabelMaterial.emissiveColor = new BABYLON.Color3(1, 1, 1);
usernameLabelMaterial.emissiveTexture = usernameLabelTex;
usernameLabelMaterial.disableLighting = true;
}
this.usernameLabelMesh = createTextMesh(this.profile.user?.username ?? '(anonymous)', {
size: cm(5),
material: usernameLabelMaterial,
});
this.usernameLabelMesh.parent = this.subRoot;
this.usernameLabelMesh.position.y = cm(40);
this.usernameLabelMesh.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;
this.scene.addMesh(this.usernameLabelMesh);
}
} else {
if (this.usernameLabelMesh != null) {
this.usernameLabelMesh.dispose();
this.scene.removeMesh(this.usernameLabelMesh);
this.usernameLabelMesh = null;
}
}
if (this.show2dAvatar && this.profile.user?.avatarUrl != null) {
if (this.twodAvatarMesh == null) {
const twodAvatarTex = new BABYLON.Texture(this.profile.user.avatarUrl, this.scene, false, true);
const twodAvatarMat = new BABYLON.StandardMaterial('twodAvatarMat', this.scene);
twodAvatarMat.roughness = 1;
twodAvatarMat.diffuseColor = new BABYLON.Color3(0.5, 0.5, 0.5);
twodAvatarMat.diffuseTexture = twodAvatarTex;
twodAvatarMat.emissiveColor = new BABYLON.Color3(0.5, 0.5, 0.5);
twodAvatarMat.emissiveTexture = twodAvatarTex;
twodAvatarMat.disableLighting = true;
twodAvatarMat.backFaceCulling = false;
this.twodAvatarMesh = BABYLON.MeshBuilder.CreatePlane('twodAvatar', { size: cm(10) }, this.scene);
this.twodAvatarMesh.material = twodAvatarMat;
this.twodAvatarMesh.parent = this.subRoot;
this.twodAvatarMesh.position.y = cm(40) + cm(7.5);
this.twodAvatarMesh.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;
this.scene.addMesh(this.twodAvatarMesh);
}
} else {
if (this.twodAvatarMesh != null) {
this.twodAvatarMesh.dispose(false, true);
this.scene.removeMesh(this.twodAvatarMesh);
this.twodAvatarMesh = null;
}
}
}
public updateUserInfoDisplayOptions(options: { showUsername: boolean; show2dAvatar: boolean; }) {
this.showUsername = options.showUsername;
this.show2dAvatar = options.show2dAvatar;
this.applyInfoMesh();
}
public applyState(state: PlayerState, forInit = false) {
this.root.position.set(...state.position);
this.subRoot.rotation.set(...state.rotation);
if (!forInit) {
const meshes = this.root.getChildMeshes();
if (meshes.length > 0) this.sr.updateMesh(meshes);
}
}
public destroy() {
this.timer.dispose();
if (this.animationObserver != null) {
this.scene.onAfterAnimationsObservable.remove(this.animationObserver);
}
for (const ac of this.accessoryContainers) {
ac.destroy();
}
this.accessoryContainers = [];
if (this.usernameLabelMesh != null) {
this.usernameLabelMesh.dispose();
this.scene.removeMesh(this.usernameLabelMesh);
this.usernameLabelMesh = null;
}
if (this.twodAvatarMesh != null) {
this.twodAvatarMesh.dispose(false, true);
this.scene.removeMesh(this.twodAvatarMesh);
this.twodAvatarMesh = null;
}
this.root.dispose();
}
}
@@ -0,0 +1,208 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { ArcRotateCameraManualInput, getMeshesBoundingBox, GRAPHICS_QUALITY } from './utility.js';
import { PlayerContainer, type PlayerProfile } from './PlayerContainer.js';
import { EngineBase } from './EngineBase.js';
import { deepClone } from './clone.js';
import type { WorldAvatar } from 'misskey-world/src/types.js';
export class AvatarPreviewEngine extends EngineBase<{ // PlayerPreviewEngineに改名した方がいいかもしれない
'loadingProgress': (ctx: { progress: number }) => void;
'contextlost': (ctx: { reason: string; message: string; }) => void;
}> {
private sr: BABYLON.SnapshotRenderingHelper;
private shadowGenerator: BABYLON.ShadowGenerator;
private camera: BABYLON.ArcRotateCamera;
private avatarOptions: WorldAvatar | null = null;
private playerContainer: PlayerContainer | null = null;
private envMapIndoor: BABYLON.CubeTexture;
private roomLight: BABYLON.SpotLight;
private pipeline: BABYLON.DefaultRenderingPipeline;
private graphicsQuality: number;
private profile: PlayerProfile;
constructor(profile: PlayerProfile, options: {
engine: BABYLON.WebGPUEngine;
graphicsQuality: number;
fps: number | null;
}) {
super({
engine: options.engine,
fps: options.fps,
});
registerBuiltInLoaders();
this.graphicsQuality = options.graphicsQuality;
this.profile = deepClone(profile);
this.scene.autoClear = false;
this.scene.skipPointerMovePicking = true;
this.scene.skipFrustumClipping = true; // snapshot renderingでは全てのメッシュがアクティブになっている必要があるため
this.scene.clearColor = new BABYLON.Color4(0.01, 0.01, 0.01, 1);
this.sr = new BABYLON.SnapshotRenderingHelper(this.scene);
this.camera = new BABYLON.ArcRotateCamera('camera', Math.PI / 2, Math.PI / 2.5, cm(300), new BABYLON.Vector3(0, cm(90), 0), this.scene);
this.camera.minZ = cm(1);
this.camera.maxZ = cm(100000);
this.camera.fov = 0.5;
this.camera.lowerRadiusLimit = cm(50);
this.camera.upperRadiusLimit = cm(1000);
this.camera.inputs.clear();
this.camera.inputs.add(new ArcRotateCameraManualInput(this.scene, {
rotationSensitivity: 0.0005,
}));
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.scene);
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(500), cm(500), cm(500));
this.envMapIndoor.level = 0.6;
this.roomLight = new BABYLON.SpotLight('roomLight', new BABYLON.Vector3(cm(50), cm(249), cm(50)), new BABYLON.Vector3(0, -1, 0), 16, 8, this.scene);
this.roomLight.diffuse = new BABYLON.Color3(1.0, 0.9, 0.8);
this.roomLight.shadowMinZ = cm(10);
this.roomLight.shadowMaxZ = cm(500);
this.roomLight.radius = cm(30);
this.roomLight.intensity = 15 * WORLD_SCALE * WORLD_SCALE;
this.shadowGenerator = new BABYLON.ShadowGenerator(2048, this.roomLight);
this.shadowGenerator.forceBackFacesOnly = true;
this.shadowGenerator.bias = 0.0001;
this.shadowGenerator.usePercentageCloserFiltering = true;
this.shadowGenerator.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
this.shadowGenerator.getShadowMap().refreshRate = 60;
const gl = new BABYLON.GlowLayer('glow', this.scene, {
blurKernelSize: 64,
});
gl.intensity = 0.5;
this.scene.setRenderingAutoClearDepthStencil(gl.renderingGroupId, false);
this.sr.updateMeshesForEffectLayer(gl);
this.pipeline = new BABYLON.DefaultRenderingPipeline('default', true, this.scene);
this.pipeline.samples = 4;
if (this.graphicsQuality >= GRAPHICS_QUALITY.HIGH) {
this.pipeline.bloomEnabled = true;
this.pipeline.bloomThreshold = 0.95;
this.pipeline.bloomWeight = 0.1;
this.pipeline.bloomKernel = 256;
this.pipeline.bloomScale = 2;
}
this.pipeline.sharpenEnabled = true;
this.pipeline.sharpen.edgeAmount = 0.5;
}
public async init() {
this.startRenderLoop();
await this.scene.whenReadyAsync();
this.sr.enableSnapshotRendering();
this.inputs.on('wheel', (ev) => {
this.camera.fov += ev.deltaY * 0.0005;
this.camera.fov = Math.max(0.25, Math.min(0.5, this.camera.fov));
});
this.inputs.on('zoom', (ev) => {
this.camera.fov += -ev.delta * 0.0015;
this.camera.fov = Math.max(0.25, Math.min(0.5, this.camera.fov));
});
this.inputs.on('pointer', (ev) => {
(this.camera.inputs.attached.manual as ArcRotateCameraManualInput).setRotationVector({ x: ev.x, y: ev.y });
});
await this.load();
}
private async load() {
this.sr.disableSnapshotRendering();
this.playerContainer = new PlayerContainer({
id: '',
profile: this.profile,
state: {
position: [0, 0, 0],
rotation: [0, 0, 0],
},
sr: this.sr,
scene: this.scene,
showUsername: false,
show2dAvatar: false,
});
this.playerContainer.registerMeshes = (meshes) => {
for (const mesh of meshes) {
mesh.receiveShadows = true;
this.shadowGenerator.addShadowCaster(mesh);
if (mesh.material) {
if (mesh.material instanceof BABYLON.MultiMaterial) {
for (const subMat of mesh.material.subMaterials) {
(subMat as BABYLON.PBRMaterial).reflectionTexture = this.envMapIndoor;
(subMat as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
(subMat as BABYLON.PBRMaterial).anisotropy.isEnabled = false; // なんかきれいにレンダリングされないため
}
} else {
(mesh.material as BABYLON.PBRMaterial).reflectionTexture = this.envMapIndoor;
(mesh.material as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
(mesh.material as BABYLON.PBRMaterial).anisotropy.isEnabled = false; // なんかきれいにレンダリングされないため
}
}
if (!this.scene.meshes.includes(mesh)) this.scene.addMesh(mesh);
}
};
await this.playerContainer.loadAvatar();
const boundingInfo = getMeshesBoundingBox(this.playerContainer.root.getChildMeshes().filter(m => m.isEnabled() && m.isVisible), true);
this.camera.setTarget(new BABYLON.Vector3(0, boundingInfo.centerWorld.y, 0));
// zoom to fit
const size = boundingInfo.extendSize;
const distance = Math.max(size.x, size.y, size.z) * 2;
this.camera.radius = distance * 5;
this.sr.enableSnapshotRendering();
}
public clearPlayer() {
this.sr.disableSnapshotRendering();
if (this.playerContainer != null) {
this.playerContainer.destroy();
this.playerContainer = null;
}
this.sr.enableSnapshotRendering();
}
public async updateAvatar(value: WorldAvatar) {
this.profile.avatar = value;
this.clearPlayer();
await this.load();
}
public resize() {
// 一旦snapshot renderingを無効にしておかないとエラーが出る(babylonのバグ?)
// ~~...が、一旦無効にしたらしたで複数のマテリアルがそれぞれ入れ替わる(?)という謎の現象が発生するためコメントアウトしとく(エラー出てもレンダリングが止まったりするわけでもないし)~~
// ↑追記: engine.resizeした後に一瞬待つことで回避できることが判明
this.sr.disableSnapshotRendering();
this.engine.resize(true);
// workerで実行される可能性がある
setTimeout(() => {
this.sr.enableSnapshotRendering();
}, 1);
}
public destroy() {
super.destroy();
this.playerContainer?.destroy();
}
}
@@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { AvatarPreviewEngine } from './avatarPreviewEngine.js';
import { registerBabylonRuntime } from './babylonRuntime.js';
import type { PlayerProfile } from './PlayerContainer.js';
registerBabylonRuntime();
export async function createAvatarPreviewEngine(params: {
canvas: HTMLCanvasElement; options: { graphicsQuality: number; resolution: number; fps: number | null }; profile: PlayerProfile;
}) {
const babylonEngine = new BABYLON.WebGPUEngine(params.canvas, { doNotHandleContextLost: true, powerPreference: 'low-power', antialias: true });
babylonEngine.compatibilityMode = false;
babylonEngine.enableOfflineSupport = false;
await babylonEngine.initAsync();
if (params.options.resolution === 2) babylonEngine.setHardwareScalingLevel(0.5);
if (params.options.resolution === 0.5) babylonEngine.setHardwareScalingLevel(2);
const engine = new AvatarPreviewEngine(params.profile, {
engine: babylonEngine,
...params.options,
});
return engine;
}
@@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { AvatarPreviewEngine } from './avatarPreviewEngine.js';
import { registerBabylonRuntime } from './babylonRuntime.js';
import type { PlayerProfile } from './PlayerContainer.js';
registerBabylonRuntime();
let engine: AvatarPreviewEngine | null = null;
let canvas: OffscreenCanvas | null = null;
// TODO: 他のWorkerと実装を共通化
onmessage = async (event) => {
//console.log('Worker received message:', event.data);
switch (event.data?.type) {
case 'init': {
const profile = event.data.profile as PlayerProfile;
canvas = event.data.canvas as OffscreenCanvas;
const babylonEngine = new BABYLON.WebGPUEngine(canvas, { doNotHandleContextLost: true, powerPreference: 'low-power', antialias: true });
babylonEngine.compatibilityMode = false;
babylonEngine.enableOfflineSupport = false;
await babylonEngine.initAsync();
if (event.data.options.resolution === 2) babylonEngine.setHardwareScalingLevel(0.5);
if (event.data.options.resolution === 0.5) babylonEngine.setHardwareScalingLevel(2);
engine = new AvatarPreviewEngine(profile, {
engine: babylonEngine,
...event.data.options,
});
engine.on('ev', ({ type, ctx }) => {
self.postMessage({ type: 'ev', ev: { type, ctx } });
});
await engine.init();
self.postMessage({ type: 'inited' });
break;
}
case 'resize': {
canvas.width = event.data.width;
canvas.height = event.data.height;
if (engine != null) engine.resize();
break;
}
case 'input:keydown': {
if (engine == null) break;
engine.inputs.emit('keydown', event.data.ev);
break;
}
case 'input:keyup': {
if (engine == null) break;
engine.inputs.emit('keyup', event.data.ev);
break;
}
case 'input:click': {
if (engine == null) break;
engine.inputs.emit('click', event.data.ev);
break;
}
case 'input:wheel': {
if (engine == null) break;
engine.inputs.emit('wheel', event.data.ev);
break;
}
case 'input:zoom': {
if (engine == null) break;
engine.inputs.emit('zoom', event.data.ev);
break;
}
case 'input:pointer': {
if (engine == null) break;
engine.inputs.emit('pointer', event.data.ev);
break;
}
case 'call': {
if (engine == null) {
console.error('Failed to call: Engine is not initialized yet!!!');
break;
}
const res = engine[event.data.fn](...(event.data.args ?? []));
if (event.data.needReturnValue) {
if (res instanceof Promise) {
res.then((r) => {
self.postMessage({ type: 'return', id: event.data.id, value: r });
});
} else {
self.postMessage({ type: 'return', id: event.data.id, value: res });
}
}
break;
}
case 'set': {
if (engine == null) {
console.error('Failed to set: Engine is not initialized yet!!!');
break;
}
engine[event.data.key] = event.data.value;
break;
}
default: {
console.warn('Unrecognized message type:', event.data?.type);
}
}
};
@@ -0,0 +1,142 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { camelToKebab, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { ModelExplorer, scaleMorph, Timer } from '../utility.js';
import { convertRawOptions, type ConvertedOptions, type RawOptions } from '../mono.js';
import { getAccessoryDef } from './accessory-defs.js';
import type { AvatarAccessoryInstance } from './accessory.js';
export class AccessoryContainer {
public id: string;
public type: string;
private options: ConvertedOptions;
public root: BABYLON.TransformNode;
private subRoot: BABYLON.TransformNode | null = null;
public instance: AvatarAccessoryInstance | null = null;
public model: ModelExplorer | null = null;
private scene: BABYLON.Scene;
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
private sr: BABYLON.SnapshotRenderingHelper;
private getIsSrReady: () => boolean;
private lightContainer: BABYLON.ClusteredLightContainer;
private graphicsQuality: number;
private timer: Timer = new Timer();
constructor(args: {
id: string;
type: string;
options: RawOptions;
position: BABYLON.Vector3;
rotation: BABYLON.Vector3;
sr: BABYLON.SnapshotRenderingHelper;
getIsSrReady: () => boolean;
lightContainer: BABYLON.ClusteredLightContainer;
scene: BABYLON.Scene;
graphicsQuality: number;
}) {
this.id = args.id;
this.type = args.type;
const def = getAccessoryDef(this.type);
this.options = convertRawOptions(def.options.schema, args.options, { files: [] });
this.sr = args.sr;
this.getIsSrReady = args.getIsSrReady;
this.lightContainer = args.lightContainer;
this.scene = args.scene;
this.graphicsQuality = args.graphicsQuality;
this.root = new BABYLON.TransformNode(`accessory_${args.id}_${args.type}`, this.scene);
this.root.position = args.position;
this.root.rotation = args.rotation;
}
public async load() {
const def = getAccessoryDef(this.type);
const filePath = def.path != null ? `/client-assets/world/objects/${def.path(this.options)}.glb` : `/client-assets/world/objects/${camelToKebab(this.type)}/${camelToKebab(this.type)}.glb`;
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
// babylonによって自動で追加される右手系変換用ノード
const subRootMesh = loaderResult.meshes[0] as BABYLON.Mesh;
// meshじゃなくtransform nodeにしてパフォーマンス向上
this.subRoot = new BABYLON.TransformNode('__root__', this.scene);
this.subRoot.parent = this.root;
this.subRoot.scaling.x = -1;
this.subRoot.scaling = this.subRoot.scaling.scale(WORLD_SCALE);// cmをmに
for (const m of subRootMesh.getChildren()) {
if (m.parent === subRootMesh) {
m.parent = this.subRoot;
}
}
subRootMesh.dispose();
this.registerMeshes(this.subRoot.getChildMeshes());
this.model = new ModelExplorer(this.subRoot);
this.instance = await def.createInstance({
scene: this.scene,
sr: {
updateMesh: (mesh) => {
if (!this.getIsSrReady()) return;
this.sr.updateMesh(mesh);
},
reset: () => {
if (!this.getIsSrReady()) return;
this.sr.disableSnapshotRendering();
this.sr.enableSnapshotRendering();
},
fixParticleSystem: (ps) => this.sr.fixParticleSystem(ps),
},
lc: this.lightContainer,
root: this.root,
options: this.options,
model: this.model!,
timer: this.timer,
graphicsQuality: this.graphicsQuality,
reloadModel: () => {
this.reload();
},
});
}
public async reload() {
this.timer.dispose();
this.instance?.dispose?.();
this.instance = null;
this.model = null;
this.subRoot?.dispose();
this.root.removeChild(this.subRoot);
this.scene.removeTransformNode(this.subRoot);
this.timer = new Timer();
await this.load();
this.sr.disableSnapshotRendering();
this.sr.enableSnapshotRendering();
}
public optionsUpdated(options: Record<string, unknown>, key: string, value: any) {
if (this.instance == null) return;
this.options[key] = options[key]; // 参照を切れさせないようにプロパティ個別にmutate
this.sr.disableSnapshotRendering();
this.instance.onOptionsUpdated?.([key, this.options[key]]);
this.sr.enableSnapshotRendering();
}
public destroy() {
this.sr.disableSnapshotRendering();
this.timer.dispose();
this.instance?.dispose?.();
this.subRoot.dispose();
this.root.dispose();
this.scene.removeTransformNode(this.root);
this.sr.enableSnapshotRendering();
}
}
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { bolt_schema } from 'misskey-world/src/avatars/accessories/bolt.schema.js';
import { defineAccessory } from '../accessory.js';
export const bolt = defineAccessory(bolt_schema, {
createInstance: ({ model, options }) => {
const material = model.findMaterial('__X_BOLT__');
const applyMat = () => {
material.albedoColor = new BABYLON.Color3(options.mat.color[0], options.mat.color[1], options.mat.color[2]);
material.roughness = options.mat.roughness;
material.metallic = options.mat.metallic;
};
applyMat();
return {
onOptionsUpdated: ([k, v]) => {
switch (k) {
case 'mat': applyMat(); break;
}
},
dispose: () => {
},
};
},
});
@@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { mikan_schema } from 'misskey-world/src/avatars/accessories/mikan.schema.js';
import { defineAccessory } from '../accessory.js';
export const mikan = defineAccessory(mikan_schema, {
createInstance: ({ scene, root, sr }) => {
return {
dispose: () => {
},
};
},
});
@@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm } from 'misskey-world/src/utility.js';
import { mug_schema } from 'misskey-world/src/avatars/accessories/mug.schema.js';
import { defineAccessory } from '../accessory.js';
export const mug = defineAccessory(mug_schema, {
createInstance: ({ options, scene, root, sr, model }) => {
const emitter = new BABYLON.TransformNode('emitter', scene);
emitter.parent = root;
emitter.position = new BABYLON.Vector3(0, cm(5), 0);
const ps = new BABYLON.ParticleSystem('steamParticleSystem', 8, scene);
ps.particleTexture = new BABYLON.Texture('/client-assets/world/objects/mug/steam.png');
ps.emitter = emitter;
ps.minEmitBox = new BABYLON.Vector3(cm(-1), 0, cm(-1));
ps.maxEmitBox = new BABYLON.Vector3(cm(1), 0, cm(1));
ps.minEmitPower = cm(10);
ps.maxEmitPower = cm(12);
ps.minLifeTime = 2;
ps.maxLifeTime = 3;
ps.addSizeGradient(0, cm(10), cm(12));
ps.addSizeGradient(1, cm(18), cm(20));
ps.direction1 = new BABYLON.Vector3(-0.3, 1, 0.3);
ps.direction2 = new BABYLON.Vector3(0.3, 1, -0.3);
ps.emitRate = 0.5;
ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;
ps.color1 = new BABYLON.Color4(1, 1, 1, 0.3);
ps.color2 = new BABYLON.Color4(1, 1, 1, 0.2);
ps.colorDead = new BABYLON.Color4(1, 1, 1, 0);
ps.preWarmCycles = Math.random() * 1000;
ps.start();
sr.fixParticleSystem(ps);
const bodyMaterial = model.findMaterial('__X_MUG__');
const applyBodyMat = () => {
bodyMaterial.albedoColor = new BABYLON.Color3(options.bodyMat.color[0], options.bodyMat.color[1], options.bodyMat.color[2]);
bodyMaterial.roughness = options.bodyMat.roughness;
bodyMaterial.metallic = options.bodyMat.metallic;
};
applyBodyMat();
const liquidMaterial = model.findMaterial('__X_LIQUID__');
const applyLiquidMat = () => {
liquidMaterial.albedoColor = new BABYLON.Color3(options.liquidMat.color[0], options.liquidMat.color[1], options.liquidMat.color[2]);
liquidMaterial.roughness = options.liquidMat.roughness;
liquidMaterial.metallic = options.liquidMat.metallic;
};
applyLiquidMat();
return {
onOptionsUpdated: ([k, v]) => {
switch (k) {
case 'bodyMat': applyBodyMat(); break;
case 'liquidMat': applyLiquidMat(); break;
}
},
dispose: () => {
ps.stop();
emitter.dispose();
},
};
},
});
@@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { mug } from './accessories/mug.js';
import { mikan } from './accessories/mikan.js';
import { bolt } from './accessories/bolt.js';
import type { AvatarAccessoryDef } from './accessory.js';
export const AVATAR_ACCESSORY_DEFS = [
mug,
mikan,
bolt,
] as AvatarAccessoryDef[];
export function getAccessoryDef(type: string): AvatarAccessoryDef {
const def = AVATAR_ACCESSORY_DEFS.find(x => x.id === type) as AvatarAccessoryDef | undefined;
if (def == null) {
throw new Error(`Unrecognized accessory type: ${type}`);
}
return def;
}
@@ -0,0 +1,46 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { ModelExplorer, type Timer } from '../utility.js';
import type { AccessorySchemaDef } from 'misskey-world/src/avatars/accessory.js';
import type { OptionsSchema } from 'misskey-world/src/mono.js';
import type { ConvertedOptions, GetConvertedOptionsSchemaValues } from '../mono.js';
export type AvatarAccessoryInstance<Options = any> = {
onOptionsUpdated?: <K extends keyof Options, V extends Options[K]>(kv: [K, V]) => void;
dispose: () => void;
};
export type SnapshotRenderingHelperWrapper = {
updateMesh: (meshes: BABYLON.Mesh[]) => void;
reset: () => void;
fixParticleSystem: (ps: BABYLON.ParticleSystem) => void;
};
export type AvatarAccessoryDef<Schema extends AccessorySchemaDef = AccessorySchemaDef> = Schema & {
path?: (options: string extends keyof Schema['options']['schema'] ? ConvertedOptions : Readonly<GetConvertedOptionsSchemaValues<Schema['options']['schema']>>) => string;
createInstance: (args: {
scene: BABYLON.Scene;
// TODO: snapshot renderingの関心を隠蔽した方が綺麗かもしれない
// 例えばmaterialUpdatedというメソッドを用意して内部的にresetを呼ぶなど
sr: SnapshotRenderingHelperWrapper;
lc: BABYLON.ClusteredLightContainer | null;
root: BABYLON.TransformNode;
options: string extends keyof Schema['options']['schema'] ? ConvertedOptions : Readonly<GetConvertedOptionsSchemaValues<Schema['options']['schema']>>;
model: ModelExplorer;
timer: Timer;
graphicsQuality: number;
reloadModel: () => void;
}) => AvatarAccessoryInstance<string extends keyof Schema['options']['schema'] ? ConvertedOptions : GetConvertedOptionsSchemaValues<Schema['options']['schema']>> | Promise<AvatarAccessoryInstance<Schema['options']['schema'] extends undefined ? ConvertedOptions : GetConvertedOptionsSchemaValues<Schema['options']['schema']>>>; // TODO: createInstanceをasyncにするのではなく、別にreadyみたいなものを返させる
};
export function defineAccessorySchema<const OpSc extends OptionsSchema>(def: AccessorySchemaDef<OpSc>): AccessorySchemaDef<OpSc> {
return def;
}
export function defineAccessory<const Schema extends AccessorySchemaDef<any>>(schema: Schema, def: Pick<AvatarAccessoryDef<Schema>, 'path' | 'createInstance'>): AvatarAccessoryDef<Schema> {
return { ...schema, ...def };
}
@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
export function registerBabylonRuntime(): void {
BABYLON.RegisterStandardEngineExtensions();
BABYLON.RegisterStandardWebGPUEngineExtensions();
BABYLON.RegisterAbstractEngineAlpha();
BABYLON.RegisterAbstractEngineTexture();
BABYLON.RegisterAbstractEngineCubeTexture();
BABYLON.RegisterAbstractEngineQuery();
BABYLON.RegisterAbstractEngineTextureSelector();
BABYLON.RegisterAbstractEngineTimeQuery();
BABYLON.RegisterAbstractEngineViews();
BABYLON.RegisterEnginesWebGPUExtensionsEngineRawTexture();
BABYLON.RegisterEnginesWebGPUExtensionsEngineReadTexture();
BABYLON.RegisterEnginesWebGPUExtensionsEngineCubeTexture();
BABYLON.RegisterEnginesWebGPUExtensionsEngineRenderTargetCube();
BABYLON.RegisterEnginesWebGPUExtensionsEngineQuery();
BABYLON.RegisterEnginesWebGPUExtensionsEngineDynamicTexture();
BABYLON.RegisterEnginesWebGPUExtensionsEngineVideoTexture();
BABYLON.RegisterBufferAlign();
BABYLON.RegisterCubeTexture();
BABYLON.RegisterStandardMaterial();
BABYLON.RegisterRay();
BABYLON.RegisterAnimation();
BABYLON.RegisterAnimatable();
BABYLON.RegisterCollisionCoordinator();
BABYLON.RegisterInstancedMesh();
BABYLON.RegisterPostProcessRenderPipelineManagerSceneComponent(
BABYLON.PostProcessRenderPipelineManager,
);
BABYLON.RegisterAbstractEngineLoadingScreen();
}
@@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// structredCloneが遅いため
// SEE: http://var.blog.jp/archives/86038606.html
// あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった
// https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045
export type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | { [key: number]: Cloneable } | { [key: symbol]: Cloneable } | Cloneable[];
export function deepClone<T extends Cloneable>(x: T): T {
if (typeof x === 'object') {
if (x === null) return x;
if (Array.isArray(x)) return x.map(deepClone) as T;
const obj = {} as Record<string | number | symbol, Cloneable>;
for (const [k, v] of Object.entries(x)) {
obj[k] = v === undefined ? undefined : deepClone(v);
}
return obj as T;
} else {
return x;
}
}

Some files were not shown because too many files have changed in this diff Show More