Compare commits

..
242 Commits
Author SHA1 Message Date
legop3 71ca9776cc sloptimization.. maybe not worth it 2026-05-11 12:55:05 -04:00
legop3 3de42412dd title chaneeee 2026-05-10 19:43:28 -04:00
legop3 7148e41828 ui changes tweaks and flings. new quickstart overlay to replace help overlay. 2026-05-10 19:41:24 -04:00
legop3 e58e5503e8 lockdown audit done 2026-05-10 01:55:32 -04:00
legop3 a497c8a630 plannings 2026-05-10 01:47:40 -04:00
legop3 0141af2ed6 plannings 2026-05-10 01:34:36 -04:00
legop3 05cfe42860 scrollbaring 2026-05-10 01:22:29 -04:00
legop3 4971edbb59 teeny scrollbars 2026-05-10 01:19:25 -04:00
legop3 b2cae9bfa4 working on mobile scrollbar garbaoeage 2026-05-10 01:16:14 -04:00
legop3 def38ad5a9 brain dumping 2026-05-09 02:09:18 -04:00
legop3 e8c4ffc543 pudate webui package.json thanks crer 2026-05-08 13:26:23 -04:00
legop3 33f1e66b62 neato lidar antispam 2026-05-05 22:45:08 -04:00
legop3 13885fff4c promp 2026-05-04 23:17:56 -04:00
legop3 21215260ce promp 2026-05-04 23:17:39 -04:00
legop3 a99102dc2a glados lol 2026-05-04 23:06:45 -04:00
legop3 257e4d037d give neato errors and status 2026-05-04 22:59:40 -04:00
legop3 85e42851f3 promp and temp 2026-05-04 22:48:18 -04:00
legop3 49987c445f promp 2026-05-04 13:21:23 -04:00
legop3 f4eab8776f remove tools from chat meta 2026-05-04 12:36:03 -04:00
legop3 eb73ff225c promp 2026-05-04 12:17:40 -04:00
legop3 42770fbfe6 promp 2026-05-04 03:54:16 -04:00
legop3 3a9015753c promp 2026-05-04 03:33:01 -04:00
legop3 0ea96613b0 more better panel stuff and promp 2026-05-04 02:50:32 -04:00
legop3 a66a68d7eb prompsmall 2026-05-04 02:38:43 -04:00
legop3 c8685e9e0e combine metadata/promp 2026-05-04 02:32:02 -04:00
legop3 8e49a106fb promp 2026-05-04 02:22:24 -04:00
legop3 1175ea7deb promp 2026-05-04 02:20:54 -04:00
legop3 37ab5ed278 promp 2026-05-04 02:13:45 -04:00
legop3 8a059a4642 promp 2026-05-04 02:07:36 -04:00
legop3 41aff63ae6 promp 2026-05-04 02:00:13 -04:00
legop3 52addabeed promp 2026-05-04 01:50:35 -04:00
legop3 a979e8c905 promp 2026-05-04 01:39:09 -04:00
legop3 26d59b6650 promp and memry 2026-05-04 01:33:09 -04:00
legop3 d887997a5a promp 2026-05-04 01:25:58 -04:00
legop3 cf7bb5c337 promp 2026-05-04 01:03:18 -04:00
legop3 9ebf86cdfa promp 2026-05-04 00:53:57 -04:00
legop3 9504306af6 promp 2026-05-04 00:37:56 -04:00
legop3 3487fd200c promp 2026-05-04 00:29:36 -04:00
legop3 15fedc7c77 overseer v2 6 (tool actions, text chat) 2026-05-04 00:14:32 -04:00
legop3 5e2c836cf2 overseer v2 5 (real tools) 2026-05-03 23:57:36 -04:00
legop3 7eb33c9af2 overseer v2 4 2026-05-03 23:41:08 -04:00
legop3 5b48b10971 overseer v2 3 2026-05-03 23:20:09 -04:00
legop3 e97b623b43 fix old service that apparently never stops 2026-05-03 23:04:27 -04:00
legop3 906368d45e overseer v2 2 2026-05-03 23:01:01 -04:00
legop3 850f89c8ed overseer v2 1 2026-05-03 22:51:50 -04:00
legop3 32e4afdbe8 Merge pull request #5 from legop3/webuiopt
Webuiopt
2026-05-03 22:07:43 -04:00
legop3 4003379dfb update title 2026-05-03 22:03:33 -04:00
legop3 837701eee8 deeper pass 2026-05-02 17:12:38 -04:00
legop3 2f570d92d7 hud stuff 2026-05-02 17:00:06 -04:00
legop3 ba4b6cfc78 pass 1 (broken) 2026-05-02 15:39:38 -04:00
legop3 53cf39e7fb webui optimization 2026-05-02 15:25:01 -04:00
legop3 e8f6fb9654 pverseer rambling at 430am 2026-05-02 04:35:20 -04:00
legop3 0f97382f42 prompand 2026-05-01 23:40:47 -04:00
legop3 03cd8c00dd lidar adjustmints 2026-05-01 23:19:22 -04:00
legop3 df7b933f41 joecraft 2026-05-01 19:52:53 -04:00
legop3 9c287f068a requet fix and ui change 2026-05-01 19:48:04 -04:00
legop3 6280724f6e nvl 2026-05-01 19:38:53 -04:00
legop3 4890b4e5c6 kjf 2026-05-01 19:37:38 -04:00
legop3 6de19c69df gah 2026-05-01 19:35:18 -04:00
legop3 76fc1c02d5 elidarea 2026-05-01 19:32:15 -04:00
legop3 b76405ed46 gh 2026-05-01 18:56:11 -04:00
legop3 2833175fca gah 2026-05-01 18:47:23 -04:00
legop3 fa981ef298 be webel 2026-05-01 18:41:10 -04:00
legop3 7180d3352d guh 2026-05-01 18:37:18 -04:00
legop3 458199ad11 lidar reasons 2026-05-01 18:26:16 -04:00
legop3 3bb16c140e too ocmplicatd 2026-05-01 18:17:56 -04:00
legop3 8550c85106 gah 2026-05-01 18:12:22 -04:00
legop3 244ff572c4 bolobob 2026-05-01 18:05:42 -04:00
legop3 aa530ef67d neatofix 2026-05-01 17:56:27 -04:00
legop3 e92d670f80 brainslug logfile 2026-05-01 17:45:41 -04:00
legop3 c37a7965ff scan after scan after scan 2026-05-01 17:34:48 -04:00
legop3 c01456409d uifix 2026-05-01 17:32:56 -04:00
legop3 b8bcd49473 stupid machine... 2026-05-01 17:29:49 -04:00
legop3 a8dd71193a lidar adjustmentses 2026-05-01 17:25:45 -04:00
legop3 53723e2f1e lidar fixar 2026-05-01 17:17:15 -04:00
legop3 e3e563feee neato lidara 2026-05-01 17:10:00 -04:00
legop3 6523ea7417 betterneato 2026-05-01 02:35:58 -04:00
legop3 02d5d6ca13 trying to 2026-05-01 01:50:35 -04:00
legop3 7e7eb97ff6 whue 2026-05-01 01:32:02 -04:00
legop3 b491d91ec5 community goal? never jnew her 2026-05-01 01:18:21 -04:00
legop3 eefe434607 disable chat_recent 2026-05-01 00:03:21 -04:00
legop3 b2a2296d98 fixes, changes, no more periodic sync. since theres enough activity at this point... 2026-04-30 13:18:19 -04:00
legop3 962d2ae61e oops forgot build 2026-04-30 04:54:55 -04:00
legop3 ff877f04cc making noise 2026-04-30 04:53:42 -04:00
legop3 963499fa8e risk of fling 2 2026-04-30 02:53:22 -04:00
legop3 0ae60ef4f9 discord unregressions 2026-04-30 01:53:11 -04:00
legop3 a3067a06d9 block lift ui during movement 2026-04-30 00:57:20 -04:00
legop3 09c7a0c813 idle and docked make button show 2026-04-29 23:48:17 -04:00
legop3 d7057289ab better improve 2026-04-29 22:47:55 -04:00
legop3 5654702e51 uhufgj 2026-04-29 19:59:59 -04:00
legop3 90e3c9873b gahhf 2026-04-29 19:53:05 -04:00
legop3 da56086b21 jfhdskljh 2026-04-29 19:48:22 -04:00
legop3 40e625793e loggings 2026-04-29 19:44:52 -04:00
legop3 97196ef202 loging 2026-04-29 19:38:33 -04:00
legop3 c31fffec20 glogar 2026-04-29 19:31:51 -04:00
legop3 9e24ed2ddf thelift 2026-04-29 19:25:31 -04:00
legop3 91a3272780 ohyeah 2026-04-29 19:22:21 -04:00
legop3 943fa11f7e the lift.... 2026-04-29 19:13:12 -04:00
legop3 9bf449ca19 awaa 2026-04-29 18:45:41 -04:00
legop3 01a8d044e7 regressing rn 2026-04-29 18:31:02 -04:00
legop3 fed0cab0b8 glorp 2026-04-29 15:54:55 -04:00
legop3 ef5c6fab89 i hate weather 2026-04-29 15:15:15 -04:00
legop3 fcfcdc907a var liberal 2026-04-29 14:55:42 -04:00
legop3 810c465040 stupid 2026-04-29 14:50:18 -04:00
legop3 9be91c22bd snaofhdskljfs 2026-04-29 14:47:17 -04:00
legop3 88acba5e1a ieuyryehj 2026-04-29 14:38:13 -04:00
legop3 d8a01f9b60 khdslfjyufe 2026-04-29 14:30:24 -04:00
legop3 f26735b9e5 flhj 2026-04-29 14:25:28 -04:00
legop3 2e140d1bd5 etkjrh 2026-04-29 14:22:44 -04:00
legop3 b716e40f53 logging 2026-04-29 14:16:38 -04:00
legop3 9b80b0d248 rover sapshot 2026-04-29 14:09:19 -04:00
legop3 5ebf5fa580 frame 2026-04-29 14:03:56 -04:00
legop3 0003338ecd room camera and rover snapshots 2026-04-29 13:58:04 -04:00
legop3 40e55240e4 chatservice 2026-04-29 13:45:19 -04:00
legop3 d7f34af66f replay beroke 2026-04-29 13:35:24 -04:00
legop3 9b9fa13eaf ugh 2026-04-29 13:23:23 -04:00
legop3 bc6770f407 replay 2026-04-29 13:21:44 -04:00
legop3 e2bd29e2c2 splite 2026-04-29 13:12:04 -04:00
legop3 2eafcce95b rah 2026-04-29 13:07:42 -04:00
legop3 c20c22eafc discord bot 2026-04-29 13:01:21 -04:00
legop3 ea3c030a21 homessis 2026-04-28 21:46:00 -04:00
legop3 a4400655c8 praccess 2026-04-28 21:40:05 -04:00
legop3 5036bbfa07 policy 2026-04-28 21:35:31 -04:00
legop3 85db918085 buttonbox 2026-04-28 21:12:16 -04:00
legop3 dba714cc42 af 2026-04-28 21:08:04 -04:00
legop3 ccd502c0f9 audio forward 2026-04-28 21:03:08 -04:00
legop3 d47face038 llm 2026-04-28 20:55:31 -04:00
legop3 b0f62e1cad commentary 2026-04-28 20:49:07 -04:00
legop3 053dfc6396 fixe 2026-04-28 20:39:45 -04:00
legop3 4a3ace007e rmanager 2026-04-28 20:32:15 -04:00
legop3 019147a6ce stupid 2026-04-28 20:12:39 -04:00
legop3 fc229fcf62 data fix 2026-04-28 20:05:56 -04:00
legop3 50f6569b22 mover ranager 2026-04-28 20:03:00 -04:00
legop3 8deee70d4a fixe 2026-04-28 19:58:12 -04:00
legop3 49f801330f gahew 2026-04-28 19:53:10 -04:00
legop3 f13b85109e comments and descriptions 2026-04-28 19:39:13 -04:00
legop3 07ad9ab253 goop converter 2026-04-28 19:31:02 -04:00
legop3 86eb7e628e mappings 2026-04-28 19:17:23 -04:00
legop3 c31408611e mobeil controls 2026-04-28 14:55:12 -04:00
legop3 bff79b5f8c gamepag mapping 2026-04-28 14:49:22 -04:00
legop3 3d809fd91c drivedock 2026-04-28 14:43:34 -04:00
legop3 7b0f7cbc56 admin panel 2026-04-28 14:38:10 -04:00
legop3 7e13d44458 vip audio card 2026-04-28 14:33:39 -04:00
legop3 4dc930f64b videotile 2026-04-28 14:24:58 -04:00
legop3 3a2b8b3557 videotile 2026-04-28 14:22:13 -04:00
legop3 1d6bc24067 spectatro 2026-04-28 14:17:36 -04:00
legop3 13e9ebc909 it begins 2026-04-28 14:02:14 -04:00
legop3 9b59718a37 refactor planning 2026-04-28 13:48:26 -04:00
legop3 1607968cee oips 2026-04-28 13:19:03 -04:00
legop3 b0cd29c075 optional sidebar 2026-04-28 13:13:42 -04:00
legop3 928c1112a1 woah 2026-04-28 04:52:05 -04:00
legop3 83d9cd51de fix wrap 2026-04-28 04:48:20 -04:00
legop3 aaeb173cea titlet 2026-04-28 04:45:07 -04:00
legop3 850b271154 7 2026-04-28 04:42:05 -04:00
legop3 b054b93c96 chatboxe 2026-04-28 04:36:22 -04:00
legop3 630e112733 prevchat 2026-04-28 04:31:21 -04:00
legop3 2439d5df19 gwah 2026-04-28 04:22:44 -04:00
legop3 4bd3094351 chart 2026-04-28 04:15:49 -04:00
legop3 0eb43738cb sidebar 2026-04-28 04:10:39 -04:00
legop3 a0ef334c8c ereplay 2026-04-28 03:56:18 -04:00
legop3 bef54d0f2e ereplay 2026-04-28 03:34:08 -04:00
legop3 c4a80202fc awa 2026-04-28 02:48:49 -04:00
legop3 16ae430090 ugh 2026-04-28 02:43:41 -04:00
legop3 ae24793057 slkfj 2026-04-28 02:38:01 -04:00
legop3 32faa5866d fkjhds 2026-04-28 02:31:52 -04:00
legop3 0e91f38f1e locationong 2026-04-28 02:22:48 -04:00
legop3 1ea5929f79 repalay 2026-04-28 02:17:25 -04:00
legop3 76e966abec wawaw 2026-04-28 02:08:32 -04:00
legop3 07b1e8bd86 gomer 2026-04-28 02:00:14 -04:00
legop3 bdbf3c0138 reapaltle 2026-04-28 01:52:25 -04:00
legop3 286012809f overseer changes 2026-04-27 20:39:53 -04:00
legop3 f743f767a9 awa 2026-04-26 23:45:08 -04:00
legop3 92fde05847 vip tab restyling 2026-04-26 23:23:52 -04:00
legop3 b92034ecab volkswagen 2026-04-26 23:01:36 -04:00
legop3 aa1a115203 neao 2026-04-26 22:57:05 -04:00
legop3 a5705ede2b button arrangement 2026-04-26 22:53:05 -04:00
legop3 ae6d2df0a2 new fefault tilt speed 2026-04-26 21:22:05 -04:00
legop3 7847f5e79c remove status line stuff 2026-04-26 21:17:59 -04:00
legop3 f565d886a8 oopsie 2026-04-26 21:11:25 -04:00
legop3 304c984423 neatp 2026-04-26 21:09:11 -04:00
legop3 c28cb4bb20 guhw 2026-04-26 21:02:30 -04:00
legop3 374d1448ac month 2026-04-26 20:58:05 -04:00
legop3 de3e32e0f0 neat 2026-04-26 20:43:46 -04:00
legop3 47dc4e06af neatoing 2026-04-26 20:40:02 -04:00
legop3 286cc1c62a neeato 2026-04-26 20:32:29 -04:00
legop3 e63eddc314 overcurrent fix? 2026-04-26 13:11:13 -04:00
legop3 ed22c5c393 swap promp 2026-04-26 12:51:14 -04:00
legop3 863c7daafb guh 2026-04-25 20:55:33 -04:00
legop3 3a5c14412d hydraulic brakes 2026-04-25 20:45:54 -04:00
legop3 41bbd7bd98 night vision fix 2026-04-25 20:35:53 -04:00
legop3 1359c701bb smol changess 2026-04-25 20:21:38 -04:00
legop3 e54b8e34ab deter 2026-04-24 21:44:26 -04:00
legop3 f23a0e15c3 npm run build 2026-04-24 20:37:40 -04:00
legop3 0c93aacdf6 button reward updates 2026-04-24 13:19:56 -04:00
legop3 d3f263c216 promp 2026-04-22 14:22:37 -04:00
legop3 9b732429a4 promp 2026-04-22 14:21:18 -04:00
legop3 ff3c5321df promp 2026-04-19 23:04:52 -04:00
legop3 8fd435b3b4 home assistant white light buttons 2026-04-19 21:34:55 -04:00
legop3 73524990af promp 2026-04-19 18:51:59 -04:00
legop3 b46108a750 promp 2026-04-19 16:36:18 -04:00
legop3 adb13fc69c event tweaking 2026-04-19 16:16:20 -04:00
legop3 aee4f61976 promp 2026-04-19 11:50:00 -04:00
legop3 5438873846 event popup!! 2026-04-19 02:56:13 -04:00
legop3 52352bd692 update some reward numbrs 2026-04-19 01:56:13 -04:00
legop3 e974f4f90f update darkness reward 2026-04-19 01:34:04 -04:00
legop3 1bdae74ee5 prmop 2026-04-19 00:43:17 -04:00
legop3 3baf928402 promp 2026-04-19 00:38:40 -04:00
legop3 ee08408ccd promp 2026-04-18 23:54:01 -04:00
legop3 3534c8ef14 fix 2026-04-18 23:30:11 -04:00
legop3 f4182073ee waui 2026-04-18 22:55:16 -04:00
legop3 cd70258a21 goals were toooo smalls 2026-04-18 19:42:12 -04:00
legop3 a4e4921931 no duplicate reward slots 2026-04-18 19:28:40 -04:00
legop3 b76c5010b4 upped the spams 2026-04-18 19:23:27 -04:00
legop3 e5e8e9ed0f ui tweaks 2026-04-18 19:16:46 -04:00
legop3 3513a11068 buttonboxrewardses 2026-04-18 19:11:26 -04:00
legop3 fe8d3df421 prebubtotn 2026-04-18 18:22:42 -04:00
legop3 388efd1f47 promp and buttonbox beginnings 2026-04-18 02:31:30 -04:00
legop3 71e2e18c13 new new new 2026-04-09 19:30:42 -04:00
legop3 6e764f70bc add mode buttons 2026-04-09 18:14:25 -04:00
legop3 214eb47d96 simplifications 2026-04-09 17:47:06 -04:00
legop3 49a50a97ed loggings 2026-04-09 16:57:42 -04:00
legop3 33900e79f6 switch to button 2026-04-09 16:43:46 -04:00
legop3 7aa648240c guh 2026-04-09 16:36:07 -04:00
legop3 353efeeffd add button support 2026-04-09 16:25:48 -04:00
legop3 8ed617305d switch human alert to button 2026-04-09 14:02:54 -04:00
legop3 5e428b278a gordon 2026-04-06 01:17:25 -04:00
legop3 985e63bab2 guhb 2026-04-06 01:01:49 -04:00
legop3 9a83433f08 installer fix 2026-04-06 00:16:30 -04:00
legop3 1e9a6b2e19 opencv 2026-04-06 00:12:00 -04:00
legop3 f72a1628aa gruh 2026-03-27 16:16:56 -04:00
legop3 85fcaedc6a roerd 2026-03-27 16:15:08 -04:00
legop3 c220eb4022 roverquesting 2026-03-27 16:08:04 -04:00
legop3 998933f4c2 ui block fix 2026-03-25 21:56:23 -04:00
legop3 904f28eeef option to disable bumpoff 2026-03-25 21:43:28 -04:00
legop3 d74879caa9 plam 2026-03-25 21:40:24 -04:00
legop3 b0a11541bd gahw 2026-03-25 20:46:51 -04:00
legop3 24d0242364 video invert optional 2026-03-25 16:23:28 -04:00
legop3 af7782a233 Merge pull request #4 from legop3/privaterover
Merge private rover stuff into main
2026-03-25 15:12:18 -04:00
legop3 b32cc7ef8f promp 2026-03-25 12:39:24 -04:00
legop3 766f7130e0 promp 2026-03-25 12:29:37 -04:00
legop3 3127e02f8c promp 2026-03-25 12:27:19 -04:00
legop3 895960867c promp 2026-03-25 01:08:58 -04:00
396 changed files with 22382 additions and 12314 deletions
+1 -1
View File
@@ -14,6 +14,6 @@ server/config.yaml
server/package-lock.json server/package-lock.json
package-lock.json package-lock.json
server/data/discord-guilds.json server/data/discord-guilds.json
server/data/community-goal.json server/data/global-objective.json
server/data/admin-reason.json server/data/admin-reason.json
server/data server/data
+6
View File
@@ -0,0 +1,6 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
config.h
+37
View File
@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
+46
View File
@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html
+15
View File
@@ -0,0 +1,15 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:seeed_xiao_esp32s3]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
+3
View File
@@ -0,0 +1,3 @@
#define WIFI_SSID "wifissid"
#define WIFI_PASSWORD "wifipassword"
#define SERVER_URL "http://192.168.0.86:8080/buttonbox/press"
+110
View File
@@ -0,0 +1,110 @@
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <config.h>
// button pin defs
const int buttonPins[] = {15, 16, 17, 18};
const int buttonCount = sizeof(buttonPins) / sizeof(buttonPins[0]);
const int beeperPin = 13;
// Using INPUT_PULLUP means idle = HIGH, pressed = LOW (button to GND).
const int buttonPressedState = LOW;
const unsigned long debounceMs = 40;
const int toneDurationMs = 90;
// One tone per button (1-4).
const int buttonTonesHz[buttonCount] = {262, 330, 392, 523};
int lastStableState[buttonCount];
int lastReading[buttonCount];
unsigned long lastDebounceTime[buttonCount];
void PlayButtonTone(int buttonNumber) {
if (buttonNumber < 1 || buttonNumber > buttonCount) {
return;
}
int frequencyHz = buttonTonesHz[buttonNumber - 1];
tone(beeperPin, frequencyHz, toneDurationMs);
}
// button request function
bool SendButtonPressRequest(int buttonNumber) {
Serial.println("attempting to send button press");
bool success = false;
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
http.begin(SERVER_URL);
http.addHeader("Content-Type", "text/plain");
int httpResponseCode = http.POST(String(buttonNumber));
if (httpResponseCode > 0) {
Serial.println("http response code: ");
Serial.print(httpResponseCode);
Serial.println(http.getString());
success = true;
} else {
Serial.println("HTTP ERROR!!! ");
Serial.print(httpResponseCode);
success = false;
}
http.end();
} else {
Serial.println("Not ocnnectec to wifi! cant send request!");
}
return success;
}
void setup() {
Serial.begin(115200);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(100);
Serial.print("wifi connecting... ");
}
Serial.println("WIFI CONNENCTED !!!! :3");
for (int i = 0; i < buttonCount; i++) {
pinMode(buttonPins[i], INPUT_PULLUP);
int initial = digitalRead(buttonPins[i]);
lastStableState[i] = initial;
lastReading[i] = initial;
lastDebounceTime[i] = 0;
}
pinMode(beeperPin, OUTPUT);
noTone(beeperPin);
}
void loop() {
unsigned long now = millis();
for (int i = 0; i < buttonCount; i++) {
int reading = digitalRead(buttonPins[i]);
if (reading != lastReading[i]) {
lastDebounceTime[i] = now;
}
if ((now - lastDebounceTime[i]) > debounceMs) {
if (reading != lastStableState[i]) {
lastStableState[i] = reading;
// Trigger once on press edge.
if (reading == buttonPressedState) {
if (SendButtonPressRequest(i + 1)) {
PlayButtonTone(i + 1);
}
}
}
}
lastReading[i] = reading;
}
delay(5);
}
+11
View File
@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+31 -19
View File
@@ -14,6 +14,8 @@ source "$ENV_FILE"
: "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${ENV_FILE}}" : "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${ENV_FILE}}"
PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-forward}" PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-forward}"
AUDIO_NORMALIZE_ENABLE="${AUDIO_NORMALIZE_ENABLE:-1}"
AUDIO_NORMALIZE_FILTER="${AUDIO_NORMALIZE_FILTER:-dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled}"
if [[ -n "${FFMPEG_BIN:-}" ]]; then if [[ -n "${FFMPEG_BIN:-}" ]]; then
FFMPEG_BIN_PATH="$FFMPEG_BIN" FFMPEG_BIN_PATH="$FFMPEG_BIN"
@@ -36,26 +38,36 @@ LAST_APLAY_STATUS="unknown"
run_pipeline() { run_pipeline() {
set +e set +e
"${FFMPEG_BIN_PATH}" \ local -a ffmpeg_args=(
-hide_banner \ -hide_banner
-loglevel warning \ -loglevel warning
-fflags nobuffer \ -fflags nobuffer
-flags low_delay \ -flags low_delay
-analyzeduration 200k \ -analyzeduration 200k
-probesize 32k \ -probesize 32k
-i "${AUDIO_FORWARD_URL}" \ -i "${AUDIO_FORWARD_URL}"
-vn \ -vn
-ac 1 \ )
-ar 16000 \
-f s16le \ if [[ "${AUDIO_NORMALIZE_ENABLE}" -ne 0 ]]; then
pipe:1 \ ffmpeg_args+=(-af "${AUDIO_NORMALIZE_FILTER}")
fi
ffmpeg_args+=(
-ac 1
-ar 16000
-f s16le
pipe:1
)
"${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}" \
| "${APLAY_BIN_PATH}" \ | "${APLAY_BIN_PATH}" \
-q \ -q \
-D "${PLAYBACK_DEVICE}" \ -D "${PLAYBACK_DEVICE}" \
-t raw \ -t raw \
-f S16_LE \ -f S16_LE \
-r 16000 \ -r 16000 \
-c 1 -c 1
local rc=$? local rc=$?
local -a statuses=("${PIPESTATUS[@]}") local -a statuses=("${PIPESTATUS[@]}")
LAST_FFMPEG_STATUS="${statuses[0]:-unknown}" LAST_FFMPEG_STATUS="${statuses[0]:-unknown}"
+6 -2
View File
@@ -71,10 +71,14 @@ VIDEO_WIDTH="640"
VIDEO_HEIGHT="480" VIDEO_HEIGHT="480"
VIDEO_FPS="30" VIDEO_FPS="30"
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}" VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
VIDEO_INVERT="${VIDEO_INVERT:-1}"
VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}" VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}"
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid) # Flip the camera 180deg by default; allow upright camera mounts via VIDEO_INVERT=0.
FLIP_ARGS=(--rotation 180) FLIP_ARGS=()
if [[ "${VIDEO_INVERT}" -ne 0 ]]; then
FLIP_ARGS=(--rotation 180)
fi
MODE_ARGS=() MODE_ARGS=()
if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then
+2 -3
View File
@@ -84,15 +84,13 @@ type MediaConfig struct {
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"` AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"` AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"`
PublishPort int `yaml:"publishPort" json:"-"` PublishPort int `yaml:"publishPort" json:"-"`
CameraInverted bool `yaml:"cameraInverted" json:"-"`
Manage bool `yaml:"manage"` Manage bool `yaml:"manage"`
ManageAudio bool `yaml:"manageAudio"` ManageAudio bool `yaml:"manageAudio"`
Service string `yaml:"service"` Service string `yaml:"service"`
AudioService string `yaml:"audioService"` AudioService string `yaml:"audioService"`
HealthURL string `yaml:"healthUrl"` HealthURL string `yaml:"healthUrl"`
HealthInterval Duration `yaml:"healthInterval"` HealthInterval Duration `yaml:"healthInterval"`
VideoWidth int `yaml:"videoWidth" json:"-"`
VideoHeight int `yaml:"videoHeight" json:"-"`
VideoFPS int `yaml:"videoFps" json:"-"`
VideoBitrate int `yaml:"videoBitrate" json:"-"` VideoBitrate int `yaml:"videoBitrate" json:"-"`
} }
@@ -178,6 +176,7 @@ func LoadConfig(path string) (*Config, error) {
}, },
Media: MediaConfig{ Media: MediaConfig{
PublishPort: 9000, PublishPort: 9000,
CameraInverted: true,
HealthInterval: Duration{Duration: 30 * time.Second}, HealthInterval: Duration{Duration: 30 * time.Second},
VideoBitrate: 2000000, VideoBitrate: 2000000,
}, },
+3 -11
View File
@@ -16,8 +16,8 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
if media.AudioPublishURL == "" && audio.CaptureEnabled { if media.AudioPublishURL == "" && audio.CaptureEnabled {
return fmt.Errorf("audio publishUrl missing") return fmt.Errorf("audio publishUrl missing")
} }
if media.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 { if media.VideoBitrate <= 0 {
return fmt.Errorf("invalid media dimensions/bitrate") return fmt.Errorf("invalid media bitrate")
} }
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
return err return err
@@ -30,16 +30,8 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
if media.AudioForwardURL != "" { if media.AudioForwardURL != "" {
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL) fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
} }
if media.VideoWidth > 0 {
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
}
if media.VideoHeight > 0 {
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
}
if media.VideoFPS > 0 {
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
}
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate) fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
fmt.Fprintf(&buf, "VIDEO_INVERT=%d\n", boolToInt(media.CameraInverted))
audioDevice := audio.CaptureDevice audioDevice := audio.CaptureDevice
if audioDevice == "" || audioDevice == "rovermic" { if audioDevice == "" || audioDevice == "rovermic" {
audioDevice = "hw:0,0" audioDevice = "hw:0,0"
BIN
View File
Binary file not shown.
+2
View File
@@ -19,6 +19,8 @@ media:
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
audioForwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316 audioForwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
publishPort: 9000 publishPort: 9000
# Default assumes camera is mounted upside down; set false for upright mounts.
cameraInverted: true
videoBitrate: 2000000 videoBitrate: 2000000
manage: true manage: true
service: video-publisher.service service: video-publisher.service
+587
View File
@@ -0,0 +1,587 @@
# Dead Code Audit (Static)
Generated: 2026-05-10T21:29:09-04:00
## A) High-confidence never used files
- server/src/services/overseerControlService/tools/chatSay.js (not registered in tools/index.js, no refs)
- server/prompts/commentary_system_backup_small.txt (no refs)
- server/prompts/commentary_system_backup_pre_lobotomy.txt (no refs)
- webui/src/App.css (not imported)
- webui/src/assets/react.svg (no refs)
- server/assets/test-audio.mp3 (no refs)
- webui/public/vite.svg (no refs from app/server)
- webui/dist/assets/index-APFDmmUd.js (build artifact, not runtime-wired)
- webui/dist/assets/index-DoNH_msu.css (build artifact, not runtime-wired)
- dist/dummy1.yml (no refs)
- dist/dummy2.yml (no refs)
- dist/dummy3.yml (no refs)
## B) Unused dependency candidates
- webui/package.json: react-joystick-component (no imports)
## C) Commented-out JSX/UI blocks (never rendered while commented)
webui/src/App.jsx:85: {/* <SessionSnapshot /> */}
webui/src/App.jsx:153: {/* {showTelemetry ? <TelemetryPanel /> : null} */}
webui/src/App.jsx:183: {/* <ControlSummary /> */}
webui/src/App.jsx:211: {/* <TelemetryPanel /> */}
webui/src/components/QuickstartOverlay/index.jsx:82: {/* <button type="button" onClick={onClose} className="button-dark px-1 py-0.25 text-[0.8rem]">
webui/src/components/QuickstartOverlay/index.jsx:84: </button> */}
webui/src/components/QuickstartOverlay/index.jsx:90: {/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */}
webui/src/components/QuickstartOverlay/index.jsx:116: {/* <button type="button" onClick={onOpenHelp} className="button-dark px-1 py-0.25">
webui/src/components/QuickstartOverlay/index.jsx:118: </button> */}
webui/src/components/DriveDockAction/index.jsx:261: {/* {!isMobile && expanded ? ( */}
webui/src/components/UserListPanel/index.jsx:263: {/* {isSelf && <span className="text-[0.7rem] text-white">YOU</span>} */}
webui/src/components/AuthPanel/index.jsx:48: {/* <div className="flex gap-0.5 text-sm">
webui/src/components/AuthPanel/index.jsx:55: </div> */}
webui/src/components/ModeGateOverlay/index.jsx:85: {/* {reasonUpdatedAt ? (
webui/src/components/ModeGateOverlay/index.jsx:89: ) : null} */}
webui/src/components/ModeGateOverlay/index.jsx:102: {/* set max height of this box */}
webui/src/components/ModeGateOverlay/index.jsx:108: {/* <p className="text-xs text-slate-500">
webui/src/components/ModeGateOverlay/index.jsx:111: </p> */}
webui/src/components/RoomCameraPanel/index.jsx:103: {/* <header className="space-y-0.5">
webui/src/components/RoomCameraPanel/index.jsx:106: </header> */}
webui/src/components/DriverVideoPanel/index.jsx:145: {/* colored button to visit the spectator page */}
webui/src/components/TelemetryPanel/index.jsx:43: {/* <div className="text-sm text-slate-400">
webui/src/components/TelemetryPanel/index.jsx:49: </div> */}
## D) Unused exports reported by knip (server)
Unused exports (66)
CHARGING_STATE src/helpers/sensorDecoder.js:224:3
pickRandomReward src/rewards/index.js:44:3
MAX_REASON_LENGTH src/services/adminReasonService/index.js:97:3
setAudioLevels src/services/audioLevelsService/index.js:153:3
pushLevelsToRover src/services/audioLevelsService/index.js:154:3
isAdmin src/services/authService/index.js:80:3
isLockdownAdmin src/services/authService/index.js:81:3
authenticate src/services/authService/index.js:82:3
isDuplicate src/services/chatService/contentFilters.js:57:3
handleIncoming src/services/chatService/index.js:32:3
buildTypingPayload src/services/chatService/index.js:35:3
rateBuckets src/services/chatService/state.js:44:3
buildRoverStatusSnapshot src/services/discordBotService/batteryEmbeds.js:132:3
eventBus src/services/eventBus/index.js:56:3
subscribeAll src/services/eventBus/index.js:59:3
MAX_GOAL_LENGTH src/services/globalObjectiveService/index.js:107:3
refreshIdleState src/services/idleService/index.js:86:3
DEFAULT_FREQUENCY_MS src/services/llmCommentaryService/constants.js:31:3
MIN_FREQUENCY_MS src/services/llmCommentaryService/constants.js:32:3
normalizeCommentary src/services/llmCommentaryService/formatters.js:175:3
enforceLockdown src/services/lockdownGuard/index.js:29:3
disconnectForLockdown src/services/lockdownGuard/index.js:30:3
parseOverseerOutput src/services/overseerControlService/runtimeHelpers.js:114:3
TOOL_DEFINITIONS src/services/overseerControlService/tools/index.js:87:3
getToolById src/services/overseerControlService/tools/index.js:91:3
getIdForSignature src/services/overseerControlService/tools/index.js:92:3
DM_APPROVE_EMOJI src/services/privateRoverAccessRequestService/index.js:25:3
DM_DENY_EMOJI src/services/privateRoverAccessRequestService/index.js:26:3
createRequest src/services/privateRoverAccessRequestService/index.js:29:3
hasClosedPrivateAccessForSocket src/services/privateRoverAccessRequestService/index.js:30:3
FFMPEG_BIN src/services/replayEngineV2/sources.js:66:3
upsertRover src/services/roverManager/index.js:234:3
removeRover src/services/roverManager/index.js:235:3
setPrivateOpen src/services/roverManager/index.js:237:3
setPrivateSafety src/services/roverManager/index.js:238:3
getRoster src/services/roverManager/index.js:239:3
getRosterForSocket src/services/roverManager/index.js:240:3
broadcastRoster src/services/roverManager/index.js:241:3
setNightVisionState src/services/roverManager/index.js:242:3
handleSensorFrame src/services/roverManager/index.js:243:3
requestControl src/services/roverManager/index.js:244:3
releaseControl src/services/roverManager/index.js:245:3
removeSocket src/services/roverManager/index.js:246:3
isDriver src/services/roverManager/index.js:247:3
canDrive src/services/roverManager/index.js:248:3
enableSpectator src/services/roverManager/index.js:249:3
disableSpectator src/services/roverManager/index.js:250:3
getRoversForSocket src/services/roverManager/index.js:253:3
getPrimaryRoverForSocket src/services/roverManager/index.js:254:3
canSeeRover src/services/roverManager/index.js:255:3
canRequestControl src/services/roverManager/index.js:256:3
applyPrivateDriveSafety src/services/roverManager/index.js:257:3
canReplayRoverId src/services/roverManager/index.js:258:3
computeBatteryDisplayPercent src/services/roverManager/mathUtils.js:136:3
buildSession src/services/sessionService/index.js:340:3
syncSocket src/services/sessionService/index.js:341:3
syncAll src/services/sessionService/index.js:342:3
driverAdded src/services/turnService/index.js:284:3
driverRemoved src/services/turnService/index.js:285:3
cleanupRover src/services/turnService/index.js:286:3
canDrive src/services/turnService/index.js:287:3
createSession src/services/videoSessions/index.js:68:3
getSession src/services/videoSessions/index.js:69:3
revokeSession src/services/videoSessions/index.js:70:3
revokeBySocket src/services/videoSessions/index.js:71:3
revokeWhere src/services/videoSessions/index.js:72:3
## E) Unused exports reported by knip (webui)
Unused exports (18)
deriveDriveDockState function src/components/DriveDockAction/index.jsx:9:17
HelpContentView function src/components/HelpContentView/index.jsx:179:17
default function src/components/UserListPanel/index.jsx:50:25
normalizeDriveVector function src/controls/controlMath.js:22:17
useOvercurrentLimiter src/controls/index.js:6:10
cloneProfile function src/controls/inputs/gamepadBindings.js:14:17
getGamepadHubState function src/controls/inputs/gamepadHub.js:109:17
deriveCodeForKey function src/controls/keymapUtils.js:36:17
createKeyToken function src/controls/keymapUtils.js:47:17
createCodeToken function src/controls/keymapUtils.js:52:17
DEFAULT_OVERCURRENT_LIMITS src/controls/overcurrentLimiter.js:12:14
HELP_LAYOUTS src/help/content.js:3:14
HELP_CONTENT src/help/content.js:7:14
default function src/hooks/useFullscreenPrompt.js:169:16
normalizeRoverColor function src/lib/roverColor.js:5:17
roverSwatchStyle function src/lib/roverColor.js:35:17
useSettings src/settings/index.js:3:28
useSettings function src/settings/SettingsProvider.jsx:58:17
Duplicate exports (2)
HelpContentView|default src/components/HelpContentView/index.jsx
useFullscreenPrompt|default src/hooks/useFullscreenPrompt.js
## F) Server State-Machine Contradictions (Proof-Based)
### F1) `lockdown-admin` role branches are unreachable in current role producer graph
**Why unreachable:**
- All server role writes are done through `setRole(socket, role)` in auth flows.
- Role assignments are only `user`, `spectator`, `admin`, `lockdown`.
- No assignment path sets `lockdown-admin`.
**Role producers (source of truth):**
- `server/src/services/authService/index.js` (`initialRole` user/spectator, login role admin/lockdown, role:set only user/spectator)
- `server/src/services/roleService/index.js` (just stores whatever caller sets; no separate producer)
- Searched for any `setRole(..., 'lockdown-admin')` / `socket.data.role = 'lockdown-admin'`: none
**Dead branches/cases under this graph:**
- `server/src/services/adminLogService/index.js`
- `server/src/services/llmCommentaryService/runtimeHelpers.js`
- `server/src/services/overseerControlService/runtimeHelpers.js`
- `server/src/services/verificationService/identity.js`
- `server/src/services/discordBotService/integrations/helpers.js`
- `server/src/services/replayEngineV2/sidebarRenderer.js`
- `webui/src/components/ModeGateOverlay/index.jsx`
- `webui/src/components/UserListPanel/index.jsx`
- `webui/src/components/ChatMessageRow/index.jsx`
- `webui/src/components/RoverQueuesPanel/index.jsx`
- `webui/src/components/RawUserPilePanel/index.jsx`
- `webui/src/components/AdminPanel/AdminPanelContent.jsx`
- `webui/src/controls/overcurrentLimiter.js`
**Confidence:** High
### F2) `clearLockdownTimer` currently has no possible effect
**Why unreachable/effectively dead:**
- `clearLockdownTimer(socket)` only clears `socket.data.lockdownTimer`.
- No code ever sets `socket.data.lockdownTimer` anywhere in repo.
- Therefore the condition is always false and this function is a no-op in current runtime.
**Evidence:**
- `server/src/services/lockdownGuard/index.js` (only reads/clears `lockdownTimer`)
- global search for `lockdownTimer` assignments: none
- caller: `server/src/services/authService/index.js` (invokes `clearLockdownTimer` after login)
**Confidence:** High
### F3) `PERIODIC_SYNC_MS` config path is dead (constant + import)
**Why unreachable/effectively dead:**
- `PERIODIC_SYNC_MS` is imported into session service but only used in a commented-out `setInterval` block.
- No runtime path consumes this value.
**Evidence:**
- `server/src/services/sessionService/constants.js` exports `PERIODIC_SYNC_MS`
- `server/src/services/sessionService/index.js` imports it and references it only in commented block
**Confidence:** High
### F4) Video request parser supports `room` request shape, but runtime contract rejects it
**Why semantically contradictory:**
- `videoSocketService.normalizeRequest()` accepts room request payloads (`roomCameraId` / `{type:'room'}`).
- The handler then always throws for `target.type === 'room'` with “Room cameras now use the snapshot feed”.
- So room-video request acceptance code is legacy compatibility surface with guaranteed failure.
**Evidence:**
- `server/src/services/videoSocketService/index.js`
**Note:** This is reachable only if a client attempts room video via `video:request`; it is not a successful runtime feature path.
**Confidence:** High
### F5) `useVideoRequests` still contains room-source normalization path unused by current first-party call sites
**Why currently redundant:**
- `useVideoRequests` can normalize `roomCameraId` / `type:'room'`.
- Current call sites pass rover-only entries:
- `webui/src/components/DriverVideoPanel/index.jsx`
- `webui/src/spectate/SpectatorApp/SpectatorContent.jsx`
- `webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx`
- This aligns with room camera delivery moving to snapshot socket feed.
**Evidence:**
- `webui/src/hooks/useVideoRequests.js`
- call-site inspection above
**Confidence:** Medium-High (internal app paths only; external/future caller may use room shape)
## G) Legacy Compatibility Surface (Single-Program Dead Ends)
### G1) Duplicate socket event listeners: only `session:*` names are used by this client
**Observation:** webui emits only `session:*` variants for control-role operations.
**WebUI emit calls:**
- `session:setRole`
- `session:requestControl`
- `session:releaseControl`
- `session:lockRover`
- `session:privateSafety:set`
- `session:subscribeAll`
(see `webui/src/context/SessionContext.jsx`)
**Server still listens to both old + namespaced aliases:**
- `requestControl` + `session:requestControl`
- `releaseControl` + `session:releaseControl`
- `lockRover` + `session:lockRover`
- `privateSafety:set` + `session:privateSafety:set`
- `subscribeAll` + `session:subscribeAll`
(see `server/src/services/roverManager/socketHandlers.js`)
- `role:set` + `session:setRole`
(see `server/src/services/authService/index.js`)
**Why dead under single-program assumption:**
- No first-party client emits old names; old listeners are compatibility-only.
**Confidence:** High
### G2) Server emits protocol events with no first-party subscribers
**Server emits:**
- `rovers` (on connect and roster updates)
- `auth:role`
- `mode`
- `controlGranted`
- `lockdown`
**Evidence of emitters:**
- `server/src/services/roverManager/socketHandlers.js` (`rovers`, `controlGranted`)
- `server/src/services/roverManager/rosterLifecycle.js` (`rovers`)
- `server/src/services/authService/index.js` (`auth:role`)
- `server/src/services/modeManager/index.js` (`mode`)
- `server/src/services/lockdownGuard/index.js` (`lockdown`)
**Client-side consumption check:**
- No `socket.on('rovers' | 'auth:role' | 'mode' | 'controlGranted' | 'lockdown')` anywhere in `webui/src`.
- Session-driven UI uses `session:sync` instead.
**Why dead under single-program assumption:**
- Event emissions exist for older/external clients only.
**Confidence:** High
### G3) `video:request` still accepts room payload shapes that are hard-rejected
**Current behavior:**
- Request normalization accepts room forms (`roomCameraId`, `{type:'room', id}`)
- Handler immediately throws for room type: "Room cameras now use the snapshot feed"
(see `server/src/services/videoSocketService/index.js`)
**Why compatibility-only:**
- Room WHEP path retained in request parsing despite product contract migrating to snapshot feed.
- First-party room camera path uses `roomCamera:*` snapshot sockets.
**Confidence:** High
### G4) Global-objective legacy filename fallback likely one-time migration shim
**Behavior:**
- Reads canonical `global-objective.json`, else attempts legacy `community-goal.json`.
(see `server/src/services/globalObjectiveService/index.js`)
**Why likely compatibility-only:**
- This fallback exists purely for pre-rename data compatibility.
- In a single coordinated deployment, once migrated, legacy read path is dead.
**Confidence:** Medium-High (depends on whether legacy file still exists in your deployed data dir)
### G5) Data directory legacy fallback path is compatibility shim
**Behavior:**
- `resolveDataPath`/`resolveDataDir` checks canonical `server/data` and legacy `server/src/data` style location.
(see `server/src/helpers/dataPaths.js`)
**Why likely compatibility-only:**
- Exists to preserve prior storage layout after refactor.
- If deployment has stabilized on canonical path or explicit `SERVER_DATA_DIR`, legacy branch never used.
**Confidence:** Medium-High (environment dependent)
### G6) Overseer output parser keeps legacy one-line fallback parser
**Behavior:**
- Attempts JSON parse first; on failure falls back to historical one-line parse protocol.
(see `server/src/services/overseerControlService/runtimeHelpers.js`)
**Why compatibility-only:**
- Current prompt/protocol can be constrained to structured JSON output.
- Fallback branch preserves old non-JSON output compatibility.
**Confidence:** Medium (depends on model output guarantees / prompt hardening)
## H) Additional Deep Sweep Findings (Repo-Wide)
### H1) Uncalled helper export: `eventBus.subscribeAll`
**Evidence:**
- Declared/exported in `server/src/services/eventBus/index.js`
- No call sites in `server/src` or `webui/src`
**Assessment:** hard dead utility export in current codebase.
**Confidence:** High
### H2) Uncalled helper export: `overseerControl.runtimeHelpers.parseOverseerOutput`
**Evidence:**
- Declared/exported in `server/src/services/overseerControlService/runtimeHelpers.js`
- `overseerControl/index.js` imports only `{ isAdminRole, buildAdminState, buildFailureInfo }`
- No other call sites in repo
**Assessment:** dead parser path (including its legacy one-line fallback) in current wiring.
**Confidence:** High
### H3) Uncalled helper export: `overseerControl.tools.getIdForSignature`
**Evidence:**
- Declared/exported in `server/src/services/overseerControlService/tools/index.js`
- No call sites in repo
**Assessment:** dead compatibility/helper function.
**Confidence:** High
### H4) `socket.emit('rovers', ...)` channel appears fully orphaned
**Evidence:**
- Emitted by server in rover manager connect/roster paths:
- `server/src/services/roverManager/socketHandlers.js`
- `server/src/services/roverManager/rosterLifecycle.js`
- No `socket.on('rovers', ...)` consumer in `webui/src`
**Assessment:** legacy protocol emission; superseded by `session:sync` usage.
**Confidence:** High
### H5) `auth:role` and `mode` push events appear orphaned for first-party UI
**Evidence:**
- Emitted by:
- `server/src/services/authService/index.js` (`auth:role`)
- `server/src/services/modeManager/index.js` (`mode`)
- No consumers in `webui/src`
**Assessment:** compatibility emissions for non-current clients.
**Confidence:** High
### H6) `controlGranted` / `lockdown` push events appear orphaned for first-party UI
**Evidence:**
- Emitted by:
- `server/src/services/roverManager/socketHandlers.js` (`controlGranted`)
- `server/src/services/lockdownGuard/index.js` (`lockdown`)
- No consumers in `webui/src`
**Assessment:** compatibility/legacy push surface for old clients.
**Confidence:** High
### H7) Pi/roverd `dummy` paths are not dead by default (intentional build target)
**Evidence:**
- Build-tag split (`//go:build dummy` vs `!dummy`) in serial/sensor/nightvision/camera_servo/brc modules
- `Makefile` has explicit `dummy` target: `go build -tags dummy ...`
**Assessment:** keep; this is an intentional alternate runtime, not dead code.
**Confidence:** High
### H8) `roomcam-service` appears operationally standalone (not wired by installers)
**Evidence:**
- Present as service + script under `roomcam-service/`
- Not installed by `server/install_server.sh` or `pi/install_roverd.sh`
- Server room camera feature consumes configured URLs and does not require this local service specifically
**Assessment:** likely optional/ops artifact; remove only if you do not deploy it manually.
**Confidence:** Medium
### H9) `dist/dummy{1,2,3}.yml` still has no runtime references
**Evidence:**
- No code paths consume these files
- Existing references are only in the files themselves
**Assessment:** hard dead artifacts in repo runtime context.
**Confidence:** High
### H10) `buttonbox/src/config example.h` is a template, not runtime code
**Evidence:**
- Firmware includes `<config.h>`; template file is named `config example.h`
- Typical manual-copy onboarding artifact
**Assessment:** optional docs/template artifact; not dead logic, but not build-consumed unless manually copied.
**Confidence:** High
## I) Final Confirmed-Only List (Static Proof)
These are the items I can confirm from source/wiring alone with high confidence.
### I1) Definitely uncalled functions/exports
- `server/src/services/eventBus/index.js``subscribeAll`
- `server/src/services/overseerControlService/runtimeHelpers.js``parseOverseerOutput`
- `server/src/services/overseerControlService/tools/index.js``getIdForSignature`
Proof: repo-wide usage search returns definition only (no call sites).
### I2) Definitely orphan server push events for first-party webui
Server emits, but `webui/src` has no listeners for these event names:
- `rovers`
- `auth:role`
- `mode`
- `controlGranted`
- `lockdown`
Proof: emitters exist in server files; repo-wide `webui/src` listener search returns none.
### I3) Definitely unused old client event names in first-party webui
Server listens for old aliases:
- `requestControl`, `releaseControl`, `lockRover`, `privateSafety:set`, `subscribeAll`, `role:set`
WebUI emits only namespaced forms:
- `session:requestControl`, `session:releaseControl`, `session:lockRover`, `session:privateSafety:set`, `session:subscribeAll`, `session:setRole`
Proof: listener and emitter searches across `server/src` + `webui/src`.
### I4) Definitely dead room branch in client video request helper (for first-party app)
- `webui/src/hooks/useVideoRequests.js` supports room entry shapes (`roomCameraId` / `type:'room'`).
- No first-party call site in `webui/src` constructs room entries.
Proof: `type:'room'` and `roomCameraId` appear only inside `useVideoRequests.js`.
### I5) Definitely dead file candidates (code/non-built artifacts)
- `server/src/services/overseerControlService/tools/chatSay.js` (not registered in tool definitions; no refs)
- `server/prompts/commentary_system_backup_small.txt` (no refs)
- `server/prompts/commentary_system_backup_pre_lobotomy.txt` (no refs)
- `webui/src/App.css` (not imported)
- `webui/src/assets/react.svg` (no refs)
- `server/assets/test-audio.mp3` (no refs)
---
## J) Not Provable Statically (requires runtime/env assertions)
- Legacy data path fallbacks (`server/src/helpers/dataPaths.js`) may be active depending on deployed filesystem and `SERVER_DATA_DIR`.
- `globalObjective` legacy filename fallback (`community-goal.json`) may still be used if old file exists and new file absent.
- `roomcam-service/*` may be manually used outside installer-managed workflows.
- Pi/roverd `dummy` build-tag code is intentional alternate target; not dead by default.
## K) Additional LLM-Style Flexibility Confirmed Unused/Dead
### K1) `pi/roverd` config knobs for `videoWidth/videoHeight/videoFps` are effectively dead in current pipeline
**Proof:**
- Config defines and validates `VideoWidth`, `VideoHeight`, `VideoFPS` in `pi/roverd/config.go`.
- `UpdatePublisherEnv` writes `VIDEO_WIDTH/VIDEO_HEIGHT/VIDEO_FPS` to env file in `pi/roverd/media_env.go`.
- `pi/bin/video-publisher.sh` does **not** read those env vars; it hardcodes:
- `VIDEO_WIDTH="640"`
- `VIDEO_HEIGHT="480"`
- `VIDEO_FPS="30"`
- Therefore these config values cannot affect runtime behavior as wired.
**Assessment:** dead configurability / fake knob.
**Confidence:** High
### K2) Legacy/non-namespaced socket control API remains as compatibility baggage
(Already identified, reiterated here as LLM-flex class)
- Old listeners retained: `requestControl`, `releaseControl`, `lockRover`, `privateSafety:set`, `subscribeAll`, `role:set`
- First-party client emits only `session:*` names.
**Assessment:** removable compatibility layer for your single-program model.
**Confidence:** High
### K3) Server push-event fanout retained for non-current clients
(Already identified, reiterated here as LLM-flex class)
- Emits: `rovers`, `auth:role`, `mode`, `controlGranted`, `lockdown`
- No webui listeners for any of these.
**Assessment:** compatibility broadcast surface with no first-party consumer.
**Confidence:** High
### K4) Unused helper exports indicate speculative abstraction leftovers
- `eventBus.subscribeAll`
- `overseerControl.runtimeHelpers.parseOverseerOutput`
- `overseerControl.tools.getIdForSignature`
**Assessment:** abstraction/future-proofing residue; currently dead.
**Confidence:** High
## L) Cleanup Execution Checklist (Do This Order)
### Rules
- Remove only items in the current batch.
- Run smoke checks after each batch before moving on.
- If smoke fails, revert that batch only and split it smaller.
### Smoke Check (run after each batch)
- Open web UI and connect at least one client.
- Verify role switch (`user`/`spectator`) still works.
- Verify request control / release control still works.
- Verify rover lock + private safety toggle still works.
- Verify mode switch still works.
- Verify spectator view still receives expected session state.
### Bucket 1: Confirmed Safe (static-proof removal candidates)
#### Batch 1 (lowest risk, start here)
- Remove unused exports/helpers with no call sites:
- `server/src/services/eventBus/index.js``subscribeAll`
- `server/src/services/overseerControlService/runtimeHelpers.js``parseOverseerOutput`
- `server/src/services/overseerControlService/tools/index.js``getIdForSignature`
- Remove dead file assets/prompts not referenced anywhere:
- `server/src/services/overseerControlService/tools/chatSay.js`
- `server/prompts/commentary_system_backup_small.txt`
- `server/prompts/commentary_system_backup_pre_lobotomy.txt`
- `webui/src/App.css`
- `webui/src/assets/react.svg`
- `server/assets/test-audio.mp3`
#### Batch 2
- Remove legacy non-namespaced socket alias listeners, keep namespaced/session contract:
- In rover manager socket handlers, remove:
- `requestControl`
- `releaseControl`
- `lockRover`
- `privateSafety:set`
- `subscribeAll`
- In auth service, remove:
- `role:set`
- Keep:
- `session:requestControl`
- `session:releaseControl`
- `session:lockRover`
- `session:privateSafety:set`
- `session:subscribeAll`
- `session:setRole`
- `setMode`
#### Batch 3
- Remove orphan server push emissions not consumed by first-party web UI:
- `rovers`
- `auth:role`
- `mode`
- `controlGranted`
- `lockdown`
- Keep `session:sync` flow intact.
#### Batch 4
- Remove dead room-shape branch in first-party client helper:
- `webui/src/hooks/useVideoRequests.js` room entry normalization paths (`roomCameraId` / `type:'room'`)
- Keep rover request path only.
#### Batch 5
- Remove fake video configurability in `pi/roverd` (choose exactly one direction):
- Option A: remove `videoWidth/videoHeight/videoFps` knobs and env writes entirely.
- Option B: wire `pi/bin/video-publisher.sh` to consume `VIDEO_WIDTH/VIDEO_HEIGHT/VIDEO_FPS`.
- Preferred for cleanup goal: Option A.
### Bucket 2: Needs Runtime Check (env/deploy dependent)
- `server/src/helpers/dataPaths.js` legacy path fallback branches.
- `server/src/services/globalObjectiveService/index.js` legacy `community-goal.json` fallback.
- `roomcam-service/*` only if confirmed unused in your deployment.
### Gate Before Bucket 2
- Confirm live environment values and on-disk data:
- `SERVER_DATA_DIR` usage status.
- Whether any deployment still has only legacy files/paths.
- Whether `roomcam-service` is started by any external supervisor.
### Done Criteria
- All Bucket 1 batches merged with smoke pass after each.
- Bucket 2 either removed with runtime proof, or explicitly kept with rationale.
- `rg` checks show no stale references to removed symbols/events/files.
+43
View File
@@ -0,0 +1,43 @@
# button box mechanic
- a box with 4 buttons which sends web requests to the server when a button is pressed
- each button has a counter in the server
- when the counter's goal is met, the reward happens
## rewards
- theres a list of rewards in the server
- rewards create chaos in different ways using the physical things that already exist
- rewards have actions and a counter goal set per reward to assign value to better rewards
- when a reward is met, the button gets a new goal, reset counter, and new reward
## UI
- a new full-width panel above the room cameras in the main tab
- divided into columns, one for each of the 4 buttons
- each columns shows:
- button number
- current count
- reward and reward number
- column flashes when button gets upped
- column plays a sound when button gets upped. same tone as the button.
- match styling and layout rules of the rest of the ui
### reward ideas
1. dock panic
force all online rovers to run seek-dock immediately, interrupting whatever it was doing and causing sudden behavior change
2. camera whiplash
apply a short burst of random camera servo nudges on all rovers so the view jerks around rapidly for a moment
3. light strobe
toggle all configured room controls on and off repeatedly for 10 seconds
4. ghost typing spam
emit fake typing events into chat so users see rapid typing indicators from fake/ghost senders without actual messages
5. darkness
turn room lights off for 1 minute and force rover night-vision/headlight state off for that same window, then restore normal state
6. discord stalker ping
send a chaos alert message to the configured discord general channel and ping the configured stalker role
7. rogue event spam
push a burst of fake alert events with random titles/colors into the web ui alert feed for jump-scare style noise
8. mode jam
switch server mode to admin for a short timed window, set admin reason to a chaos message at activation, then restore previous mode and previous admin reason
9. assignment roulette
forcibly release current user rover assignments/drivers and let assignment logic re-place users, causing sudden rover ownership reshuffle
10. chat spam
spam random letters in chat as an injected spectator user with a random letter name
+22
View File
@@ -0,0 +1,22 @@
# lift control
- control a lift up / down from the web UI
- for verified users only
- add new service in server for controlling the lift
- add new action in idle service, to raise the lift when idle triggers
- UI in VIP panel
- match UI styling of neato panel
- put below VIP panel
- have 2 buttons that get toggled between. down / up.
- lift is controlled through home assistant:
- lift shows up as two switches
- add spots for these 2 lift switches in server config
- one for up and one for down
- the way that they have to be operated is a little bit odd, examples:
- to raise the lift:
- turn down switch off
- wait 2 ish seconds
- turn up switch on
- to lower the lift:
- turn up switch off
- wait 2 ish seconds
- turn down switch on
+386
View File
@@ -0,0 +1,386 @@
# Overseer v2 Detailed Architecture
## Purpose
This document is the durable architecture spec for Overseer v2 so design intent is not lost across chats, sessions, or contributors.
Overseer v2 is a **new, separate service** focused on controlling the room/experience, not just commenting on rover activity.
## Non-Goals
- Do not replace or delete `llmCommentaryService`.
- Do not run two LLM overseer systems simultaneously in normal operation.
- Do not rely on giant unbounded context windows.
## Legacy Service Position
- Keep `llmCommentaryService` as a preserved legacy service.
- Keep it disable-able via config.
- New v2 service should be the primary runtime when enabled.
- Legacy service remains available for fallback/testing.
## Core Product Direction
Overseer v2 is "control-first":
- It can act on room systems and chat.
- Chat is one capability, not the whole job.
- It should feel ominous/playful and intentional, not spammy.
High-level behavior goals:
- Not overbearing.
- Does things when it can make moments interesting.
- Always responds when directly addressed.
- Does not always obey.
- Does not ramble.
## Primary Inputs Overseer Must Understand
Overseer must see all three lanes every decision cycle:
1. People activity
- Who is active now.
- Who is driving what.
- Activity bursts vs quiet periods.
- Direct addresses toward Overseer.
2. Conversation context
- **Actual recent chat conversation** (human + bot messages), not only tags.
- Last N human messages (minimum 5).
- Last 1-2 bot messages for anti-repeat context.
3. World/control state
- Rover states/events.
- Lift state/cooldowns.
- Neato state/cooldowns.
- Home Assistant entity states/availability.
- Safety/lock policy state.
## Runtime Model Strategy (Hardware-Aware)
Given modest hardware and ~30s generation on `mistral-small:24b` today:
- Use one LLM system only.
- Use deterministic program logic gate before LLM calls.
- Keep LLM calls sparse and meaningful.
- Keep context bounded and compact.
### Why Not Tiny-LLM Gate
Default gate should be deterministic logic, not another LLM:
- less latency
- less complexity
- fewer failure modes
- lower cumulative compute
A tiny LLM gate can be reconsidered later only for borderline cases.
## Continuous Loop Design (Never Stops)
System runs continuously but in two stages:
1. Fast gate loop (1-3s)
- No LLM call.
- Evaluates trigger conditions.
- If no trigger: continue.
2. LLM decision loop (on trigger or heartbeat)
- Triggered by gate or max-interval heartbeat.
- Builds bounded conversational context + state update.
- Model decides and optionally calls tools.
### Trigger Examples
- Direct address to Overseer in chat.
- Chat burst / topic change / challenge language.
- High-signal world event (dock/undock/hazard/control transition).
- Heartbeat timeout reached (for ambient presence).
## Decision Modes
The orchestrator should normalize model behavior to one of:
- `SKIP`
- `CHAT`
- `ACTION`
- `ACTION+CHAT`
Meaning:
- `SKIP`: no output, no tool call.
- `CHAT`: post chat only.
- `ACTION`: run tool(s) only.
- `ACTION+CHAT`: run tool(s) and post chat.
## Tool Output vs Chat Output
These are separate channels by design:
- Tool calls are structured actions.
- Chat text is explicit messaging output.
- A tool call does not automatically produce chat.
This separation is required to avoid chat spam and keep control behavior deliberate.
## Conversational Context Format (Important)
Overseer should use:
- Stable `system` prompt with identity/rules/policy.
- Real rolling transcript (users + overseer).
- A compact `STATE_UPDATE` message each decision cycle.
### Bounded Window Rules
To protect latency:
- Keep rolling window bounded (for example 20-40 recent turns, or last 5-10 min).
- Always include last 5 human messages minimum.
- Include last 1-2 bot messages.
- Drop/summarize older content.
## Tool Availability Contract
Critical refinement: do not advertise unusable tools as available.
Each cycle split into:
1. `available_tools`
- callable now
2. `blocked_tools`
- not callable now with reason:
- cooldown
- busy
- unavailable/offline
- policy lock
- rate limit
Example:
- If Neato is cooling down, remove `neato_*` from `available_tools` and list under `blocked_tools` with remaining seconds.
This prevents wasted model turns and impossible tool attempts.
## Initial Tool Surface
Planned callable tools (subject to runtime availability):
- `chat_say(text)`
- `lift_up()` / `lift_down()`
- `neato_start()` / `neato_send_home()` / `neato_locate()` / `neato_clear_errors()`
- `ha_set_entity(entity_id, state)` for configured controllable entities
- memory tools:
- `memory_read()`
- `memory_write(slot, text)` where slot ∈ {1,2,3}
Optional later:
- controlled button-box count adjustment tool (strictly bounded and auditable)
## Persistent Memory (Tiny, Explicit)
Use tiny explicit memory store:
- exactly 3 slots
- string lines only
- explicit read/write via tools
- no autonomous unbounded memory growth
Model decides which slot to replace when writing.
## Mode and Policy Locks (Tool Disabling Rules)
Overseer tool availability must respect global site mode and room-light policy locks.
Hard requirements:
- When site mode is `admin` or `lockdown`, disable Overseer action tools for:
- Neato controls (`neato_*`)
- Lift controls (`lift_*`)
- Room controls (`ha_set_entity` and related room-light toggles)
- When room lights are locked on by policy, disable room control tools even if site mode is otherwise open.
How this must appear to the model:
- Disabled tools must be removed from `available_tools`.
- Disabled tools must be present in `blocked_tools` with clear reason codes.
Recommended reason codes:
- `policy_lock:site_mode_admin`
- `policy_lock:site_mode_lockdown`
- `policy_lock:lights_locked_on`
Execution-time enforcement:
- Executor must re-check these locks immediately before executing any tool call.
- If a lock changed after context build, block execution and record a blocked action event.
## Safety and Control Guardrails
Must-have guardrails before enabling actions:
1. Hard allowlist
- Only approved tool names callable.
2. Tool-specific cooldowns
- e.g., lift/neato/HA each with independent cooldown rules.
3. Global action budget
- max actions per minute (or window).
4. Policy lock checks
- block actions during admin lock/safety states.
5. Execution-time validation
- arguments validated before invoking any control service.
6. Logging + audit trail
- every attempted/blocked/executed tool call recorded.
## Admin Debug UI Requirement
This is a core requirement.
Need a dedicated v2 admin panel that shows:
- enabled/running status
- loop phase (`idle`, `gate_check`, `awaiting_model`, `tool_exec`, `posted`, `failed`)
- last trigger reason
- last state-update payload snapshot
- model input summary
- model raw output + normalized decision mode
- tool calls attempted/executed/blocked with reasons
- cooldowns and remaining times
- recent action history
- last error/failure
### UI Switching Behavior
- If v2 enabled: show v2 panel.
- If legacy enabled: show legacy panel.
- If neither enabled: show disabled state.
Define a separate v2 status schema (do not overload legacy status object).
## Chat System Plumbing Changes
Keep the existing chat/Discord integration path and extend it minimally.
Decisions:
- Keep current internal bus-based chat flow as the canonical path (`chat:message` / `chat:typing`).
- Do not introduce a second parallel bridge path for v2.
- Add `bot: true|false` on chat message payloads.
- Overseer v2 must publish chat output through the same existing chat pipeline so Discord relay continues to work automatically.
- Keep legacy compatibility while migrating.
Goal:
- Any service can emit chat messages through one standardized path, with bot identity represented by `bot: true`.
## Example Message Topology Per LLM Decision
1. `system`: stable Overseer identity/rules/style/tool policy
2. `user`: compact `STATE_UPDATE`
3. transcript turns: recent chat + overseer messages
4. tool availability block (`available_tools`, `blocked_tools`)
5. model output: decision/tool calls/chat
6. executor: validates, executes tools, posts chat if applicable
7. append tool results and continue loop
## Implementation Phases
### Phase 0: Scaffolding
- Create new service module (suggested name: `overseerControlService`).
- Add config section and runtime enable switch.
- Keep legacy untouched.
### Phase 1: Observe-Only
- Build gate loop and context builder.
- Run model decisions with no real actions (dry-run tools).
- Log what would have happened.
### Phase 2: Chat + Safe Tools
- Enable chat output and lowest-risk tools.
- Verify anti-spam and direct-address behavior.
### Phase 3: Full Control Surface
- Enable lift/neato/HA with strict cooldowns and budgets.
- Add memory tool operations.
### Phase 4: Tuning
- Tune gate thresholds, cadence, cooldowns.
- Tune prompt style and anti-repeat behavior.
- Evaluate model alternatives only after gating/cadence tuning.
## Metrics To Track
- LLM calls per minute
- average LLM latency
- skipped vs acted decision ratio
- stale response rate (acted too late)
- blocked tool call rate and reasons
- action frequency per tool
- chat message frequency per minute
- repeated-line rate
## Rollback Plan
- Single config flip disables v2.
- Legacy overseer can be re-enabled without code rollback.
- Keep migration steps isolated and reversible.
## Practical Defaults (Starting Point)
- fast gate loop: 2s
- heartbeat model run: 30s
- min human chat context: 5 latest messages
- include latest bot messages: 2
- max tool calls per decision: 1 (start conservative)
- global action budget: low (start strict)
## Open Questions (Track During Build)
- Exact per-tool cooldown values for lift/neato/HA.
- Whether button-box count adjustment is worth enabling at all.
- Which model gives best tool reliability per watt on your machine.
- Ideal transcript window (turn-count vs time-window).
- Whether to include lightweight topic tags in addition to raw messages.
## One-Line Summary
Overseer v2 should be a separate, always-running, control-first orchestrator that uses deterministic gating + bounded real conversation context + dynamically available tools, with strict guardrails and a first-class debug UI.
## Naming Configuration Requirement
Overseer name must be configurable from server config.
Requirements:
- Add a config field for Overseer display/invocation name (for example under the v2 service config).
- The configured name must propagate everywhere it matters:
- in-chat bot nickname
- system prompt identity text
- direct-address recognition rules
- Prompt file should remain editable by non-code changes, so use a placeholder token in prompt text (for example `<NAME>`) and replace it at runtime.
- The prompt should still support fallback behavior if name config is missing (default name).
Implementation intent notes (future):
- Keep a single source of truth for name resolution (config + default).
- Avoid scattering hardcoded names in service code.
- If legacy and v2 coexist in codebase, ensure each service can use configured naming without breaking compatibility.
## Canonical "What the Bot Sees" Example
Use this as the concrete reference for message formatting per decision cycle.
```txt
[system]
You are <NAME>, a control-first room AI.
- You may CHAT and/or call tools.
- Respect safety/cooldowns/allowlists.
- Prefer restraint; avoid spam.
- Respond when directly addressed.
- If nothing meaningful changed, SKIP.
[user]
STATE_UPDATE
time: 2026-05-02T22:14:10-04:00
trigger: chat_burst
cooldowns: lift=ready, neato=12s, ha=ready
rovers:
- rover1: driving, driver=alex, docked=false
- rover2: driving, driver=sam, docked=false
- rover3: docked=true, charging=true
lift: down, busy=false
neato: connected=true, state=idle, charging=true
lights:
- shelf=on, bench=off, corner=on, color=purple, aux1=off, aux2=on
[user] alex: overseer you watching this drift?
[user] sam: dont kill my lights again
[assistant] <NAME>: Concrete lane stays lit. Earn the darkness.
[user] alex: i dare you to make this harder
[user] sam: bot pick a side
[assistant] <NAME>: Lift is staying put. Chaos has standards.
[user] alex: sam is about to bonk the dock
[user]
available_tools:
- chat_say(text)
- lift_up()
- lift_down()
- ha_set_entity(entity_id, state)
- memory_read()
- memory_write(slot, text)
[user]
blocked_tools:
- neato_start() reason=cooldown remaining=12s
- neato_send_home() reason=cooldown remaining=12s
- neato_locate() reason=cooldown remaining=12s
- neato_clear_errors() reason=cooldown remaining=12s
```
Notes:
- The conversation transcript is real recent chat (humans + bot), bounded by window rules.
- `STATE_UPDATE` is compact current truth, regenerated each decision cycle.
- Tools must be split into `available_tools` and `blocked_tools`; unavailable tools are not advertised as callable.
- `<NAME>` is runtime-substituted from config.
+36
View File
@@ -0,0 +1,36 @@
# overseer v2 (will have a name rather than overseer.)
- name ideas:
- allied mastercomputer
- maybe it could rename itself? rarely?
- AUTO
- servermaster
- LCARS
- current model is mistral-small:24b
- currently runs at a pretty fast rate, about 30 seconds per tick
- might need a larger model. one that supports tools
- its probably okay if its a little slower after its all done
- the idea is to give the llm control over the room objects,
- like:
- the neato
- the lift
- home assistant controls (they are all lights even if theyre switches)
- button box rewards, maybe the llm is allowed to add some to the counts if it decides to
- the idea is to use actual tools in ollama for it to be reliable, so need a model that supports tools.
- also, maybe using tools it could even have a small persistent memory database?
- like, maybe it can store three lines of text, and it can read these and choose which one to replace with a tool.
- the way the llm would have to act to make this fun:
- not too overbearing
- like an ominous computer, not a generic helpful assistant
- does things only when it can make them interesting
- always responds to people, doesnt always listen and obey
- doesnt talk on and on when no one wants to hear it
## for sure serious implimentation plans so far:
- replace the single "The Overseer" bot entry point in the chat system with:
- a way for services to send bot messages to the chat system through the event bus, instead of it being fully internal with the chat system.
- a simple bot: t/f flag in every chat message
- so that people who are making spectator bots can just add a new bot: true field to their message emits and show up as a bot
- keep the old overseer, don't replace it with the new llm system.
- just change it a little to use the new bot chat message stuff.
- have a new admin debug UI for the new LLM system, in place of the old one (depending on which ones enabled in server config)
+18 -8
View File
@@ -1,16 +1,26 @@
1. fix controls remapping [x] 1. fix controls remapping [x]
2. trusted user system [x] 2. trusted user system [x]
3. private rovers 3. private rovers [x]
4. custom webhook profile pictures for chat bridge in discord 4. optional bump-off in drive macro [x]
5. home assistant switch that tells the server to force the lights on 5. allow admins to click on locked rovers from the roster [x]
6. color coding with colored names and tape [x] 6. add faster way for admins to login
7. audio forwarding [x] 7. custom webhook profile pictures for chat bridge in discord
8. home assistant switch that tells the server to force the lights on [x]
9. color coding with colored names and tape [x]
10. audio forwarding [x]
- streaming from server to rovers [x] - streaming from server to rovers [x]
- audio files first [x] - audio files first [x]
- then voice chat [x] - then voice chat [x]
8. mobile controls column swapping (optional joystick on left) [x] 10. mobile controls column swapping (optional joystick on left) [x]
9. fix fullscreen on mobile so that you can re-enter it [x] 11. fix fullscreen on mobile so that you can re-enter it [x]
10. home assistant rover mute switch 12. fix scroll bars on mobile [x]
13. add tool call embeds or something for the llm bot in discord, probably not in web ui
14. add discord bot typing thing for when someone requests a replay
15. change replay title for ones requested from discord, something other than "requester driving rover"
16. better quickstart guide, something better than just a big list of controls. help overlay sucks i think. [x]
1. restyle fullscreen overlay... please.. [x]
17. fix rover request spam queue cheat
# relative pipe dreams: # relative pipe dreams:
1. VPS video forwarding 1. VPS video forwarding
+14
View File
@@ -0,0 +1,14 @@
# web UI optimization
- no visual or functional changes. purely optimization.
- reduce amount of invalidation for unrelated session and socket updates, wherever possible.
- example: new log line invalidates more than just log panels, or maybe buttonbox count events invalidate every other panel too
- optimize anything and everything that updates on every rover sensor frame
- ensure that things only get updated when they need to
- rewrite rendering if needed, if it can be improved
- example: the light bump bars, and the SVG top-down view.
- sensors frames come at a high frequency, about 40hz.
- not acceptable to slow it down, responsivenes is important
- make sure that nothing is running in the background unless it needs to be
- example: the neato card could still be running and updating its lidar viewer even when not in the VIP tab
## fixes along the way, along the route of optimizing
- audio forwarding happens within the card itself. this is okay, but it needs to continue working and not stop or restart when tabs are switched and the card is no longer onscreen
+42
View File
@@ -0,0 +1,42 @@
# RULES FOR LOCKDOWN AUDIT
## why is lockdown a thing?
lockdown mode exists so that the physical owner of the server can get some privacy.
during lockdown mode, no one can spectate anything or replay anything
users cant use the site, its locked
admins cant log in
ONLY lockdown admins can log in and use the site as normal
## what should not work at all during lockdown?:
- spectator pages should be disabled
- llm services should be paused
-
## what should be disabled server-side for users during lockdown (to prevent people manually sending socket commands and stuff)?:
- sending commands to rovers
- requesting replays (both from socket and discord)
- audio forwarding
- using room controls
- using the lift
- using the neato
- cant view video
- cant stream audio
- cant view room cameras
- cant request and drive a rover
## lockdown admin rule:
- lockdown admins should be able to log in and use ALL features as normal, even during lockdown
- non-lockdown admins are not able to log in at all during lockdown.
## web ui lockdown rules:
- show admin login overlay on driver page, already correct i think
- show disabled overlay on spectator pages, also already good i think
- dont worry about disabling buttons and stuff. anything unallowed will be blocked server-side, and there will be the overlay in UI.
## what features should still work for everyone:
- the admin login overlay
- meaning, everyone still sees it and can log in and stuff
- the chat, since its in the overlay
- includes setting nicknames and such
- session sync and stuff
- cause it makes the whole page work and be correct
- not really a big deal if someone can see the session sync during lockdown. biggest concern for lockdown is that no one can see or use the real world stuff.
+9
View File
@@ -0,0 +1,9 @@
- discord verification and private access requests dont work. missing important info compared to before, and reactions dont work.
- replays are sent twice sometimes, look into it.
- home assistant idle lights are all messed up. remove from home assistant and make new idle service.
- idle service will trigger, after 2 minutes of no drivers:
- all room lights (room controls) off
- tell all rovers to dock
- turn off all rover night vision lights
- tell the neato to return to home
- the idle service should be easily expandable to add more things in the future
+153
View File
@@ -0,0 +1,153 @@
# REFACTOR RULES
- Do NOT change ANY functionality. All changes must be purely internal refactors.
- Refactor for simplification and maintainability.
- Remove unused files/code only when verified unused.
- No backwards compatability is needed anywhere. Clients and the server are both always up to date.
- Keep behavior/API contracts unchanged.
## Required safety checks for every change
- Preserve imports/exports and call signatures unless internal-only and non-observable.
- Validate no runtime behavior changes (manual flow checks + targeted tests when available).
- Make small, reviewable commits per service/component area.
- Treat `npm run build` output files committed into this repo as intentional deployment artifacts; do not discard them as noise.
## Server backend
- Every service must live in its own folder, even when it remains a single-file implementation.
- Convert every service into a folder-based structure.
- Split very large service files into smaller focused modules.
- Keep files concise and single-purpose.
- Add clear title comments at top of split files.
- Every service/module file should start with a descriptive comment header containing:
- a title line naming the service/module file
- a longer purpose/scope description (not a one-liner)
## WebUI frontend
- Every component must live in its own folder, even when it remains a single-file implementation.
- Split large JSX/components and large backing JS files into folderized modules.
- Keep modules clear and focused, with title comments.
- Every component/module file should start with a descriptive comment header containing:
- a title line naming the file/module
- a longer purpose/scope description (not a one-liner)
- Each component must live entirely inside its own folder; do not leave wrapper/compatibility component files outside that folder.
- Remove stale compatibility/leftover code only after usage verification.
# REFACTOR TRACKING
## Current phase
- [x] Phase 1: Inventory + usage mapping (server + webui)
- [x] Phase 2: Refactor highest-impact offenders first
- [x] Phase 3: Sweep remaining services/components
- [ ] Phase 4: Dead code/file removal pass
- [ ] Phase 5: Final regression validation (in progress)
## Server backend
### BIGGEST OFFENDERS
- [x] audio forward service
- [x] button box service
- [x] chat service
- [x] discord bot service
- [x] home assistant service
- [x] llm commentary service
- [x] private rover access request service
- [x] replay services (consolidated under replayEngineV2)
- [x] room camera services (consolidated into roomCameraService multipart folder)
- [x] rover manager service
- [x] session service
- [x] turn service
- [x] verification service
- [x] video auth service
- [x] All remaining services: reorganize to folder structure where needed
### COMPLETED SERVICES
- turn service
- session service
- chat service
### LARGE CHANGES
- Folderized all files in `server/src/services/` into per-service folders with `index.js` entrypoints and updated internal relative imports for new path depth.
- Split `server/src/services/turnService/index.js` by extracting constants, shared state helpers, and side-effect action helpers into `turnService/constants.js`, `turnService/state.js`, and `turnService/actions.js`.
- Split `server/src/services/sessionService/index.js` by extracting config/timing constants, sync-throttle state storage, and visibility filter helpers into `sessionService/constants.js`, `sessionService/state.js`, and `sessionService/filters.js`.
- Began splitting `server/src/services/roverManager/index.js` by extracting immutable constants and shared state containers into `roverManager/constants.js` and `roverManager/state.js`.
- Continued `roverManager` split by extracting socket event wiring/handlers into `roverManager/socketHandlers.js` with dependency injection to keep existing behavior unchanged.
- Continued `roverManager` split by extracting numeric/private-safety normalization and battery math into `roverManager/mathUtils.js`.
- Continued `roverManager` split by extracting control lifecycle/switching logic into `roverManager/roverLifecycle.js`.
- Continued `roverManager` split by extracting sensor processing + private safety + dock guard logic into `roverManager/sensorPipeline.js`.
- Finished `roverManager` decomposition by extracting private access policy, roster lifecycle, and spectator/auto-close orchestration into `roverManager/privateAccess.js`, `roverManager/rosterLifecycle.js`, and `roverManager/spectatorAccess.js`; `roverManager/index.js` is now a thin composition layer.
- Hotfix: corrected `llmCommentaryService` prompt file path to `server/prompts/commentary_system.txt` after service folder move.
- Hotfix: added `server/src/helpers/dataPaths.js` and rewired data-backed services to resolve canonical + legacy data-file locations safely after folderization (`adminReason`, `audioLevels`, `buttonBox`, `globalObjective`, `discordGuildStore`, `verification`, `replayEngineV2`).
- Began `llmCommentaryService` decomposition by extracting immutable runtime limits/path/frequency normalization to `llmCommentaryService/constants.js` and pure prompt/text output helpers to `llmCommentaryService/formatters.js`.
- Continued `llmCommentaryService` decomposition by extracting admin/runtime projection + failure-normalization helpers to `llmCommentaryService/runtimeHelpers.js`.
- Continued `llmCommentaryService` decomposition by extracting sensor activity aggregation and snapshot assembly to `llmCommentaryService/snapshotEngine.js`; rewired commentary tick/event flow to use the new engine.
- Continued `llmCommentaryService` decomposition by extracting socket/role/rover event wiring into `llmCommentaryService/hooks.js` and keeping `index.js` focused on orchestration.
- Finished major `llmCommentaryService` decomposition by extracting tick scheduling, run-loop orchestration, and history-reset behavior into `llmCommentaryService/runner.js`; `llmCommentaryService/index.js` is now a thin composition layer.
- Began `audioForwardService` decomposition by extracting permission/path policy helpers to `audioForwardService/policy.js` and rover/turn/socket event wiring to `audioForwardService/hooks.js`; rewired service entrypoint to use extracted modules.
- Continued `audioForwardService` decomposition by extracting ffmpeg worker lifecycle, upload playback, and WHIP ownership/session control into `audioForwardService/workerEngine.js`; `audioForwardService/index.js` is now a thin composition layer.
- Finished `buttonBoxService` decomposition by extracting persisted state management to `buttonBoxService/store.js`, reward/effect workflows to `buttonBoxService/core.js`, and HTTP transport wiring to `buttonBoxService/httpRoute.js`; `buttonBoxService/index.js` is now a thin composition layer.
- Finished `verificationService` decomposition by extracting persisted store handling to `verificationService/store.js`, identity/selector normalization to `verificationService/identity.js`, verification/deterrence/request lifecycle logic to `verificationService/verificationFlow.js`, `verificationService/deterrenceFlow.js`, and `verificationService/requestFlow.js`, plus socket/role event wiring to `verificationService/hooks.js`; `verificationService/index.js` is now a thin composition layer.
- Finished `videoAuthService` decomposition by extracting MediaMTX stream parsing to `videoAuthService/streamParsing.js`, role/mode/stream policy checks to `videoAuthService/policy.js`, and auth HTTP transport wiring to `videoAuthService/httpRoute.js`; `videoAuthService/index.js` is now a thin composition layer.
- Finished `privateRoverAccessRequestService` decomposition by extracting in-memory maps/events/constants to `privateRoverAccessRequestService/state.js`, shared keying/lookup helpers to `privateRoverAccessRequestService/helpers.js`, request/grant business logic to `privateRoverAccessRequestService/core.js`, and rover/socket event wiring to `privateRoverAccessRequestService/hooks.js`; `privateRoverAccessRequestService/index.js` is now a thin composition layer.
- Finished `homeAssistantService` decomposition by extracting shared runtime caches/constants to `homeAssistantService/state.js`, entity/trigger normalization helpers to `homeAssistantService/entityHelpers.js`, automation/state engine logic to `homeAssistantService/runtimeEngine.js`, websocket transport/reconnect lifecycle to `homeAssistantService/transport.js`, and mode/turn/socket event wiring to `homeAssistantService/hooks.js`; `homeAssistantService/index.js` is now a thin composition layer.
- Finished `discordBotService` decomposition by extracting presence rotation/state to `discordBotService/presence.js`, channel/typing transport helpers to `discordBotService/channelIO.js`, command routing and admin command handlers to `discordBotService/commandHandlers.js`, and event-bus/chat-bridge/moderation DM workflows to `discordBotService/integrations.js`; `discordBotService/index.js` is now a thin composition layer.
- Finished `replayEngineV2` decomposition by extracting environment/path constants to `replayEngineV2/constants.js`, mutable runtime state to `replayEngineV2/state.js`, source discovery/worker arg building to `replayEngineV2/sources.js`, ffmpeg worker lifecycle to `replayEngineV2/workerManager.js`, segment indexing/retention/health snapshot logic to `replayEngineV2/segmentStore.js`, sidebar SVG/video rendering to `replayEngineV2/sidebarRenderer.js`, and replay assembly pipeline to `replayEngineV2/replayBuilder.js`; `replayEngineV2/index.js` is now a thin orchestration layer.
- Consolidated replay-related single-file services into `replayEngineV2` by moving cooldown state (`cooldown.js`), user-facing replay source validation/defaults (`replaySources.js`), and replay socket hooks (`socketHooks.js`) into the engine folder; removed obsolete standalone services `replayBuildService`, `replayService`, `replaySourceService`, and `replaySocketService` and rewired dependents to import directly from `replayEngineV2`.
- Finished `chatService` decomposition by extracting runtime constants (`chatService/constants.js`), shared mutable state (`chatService/state.js`), content/moderation helpers (`chatService/contentFilters.js`), payload/context builders (`chatService/contextBuilders.js`), bus/history broadcast pipeline (`chatService/broadcast.js`), rover-side notification helpers (`chatService/notifications.js`), message handlers (`chatService/handlers.js`), and socket/event-bus wiring (`chatService/socketHooks.js`); `chatService/index.js` is now a thin orchestration layer.
- Consolidated room-camera services into `roomCameraService` by absorbing catalog (`roomCameraService`), snapshot polling/streaming (`roomCameraSnapshotService`), socket fan-out (`roomCameraSocketService`), and replay assembly (`roomCameraReplayService`) into one multipart folder (`roomCameraService/catalog.js`, `snapshotEngine.js`, `socketGateway.js`, `replayBuilder.js`), and updated imports/startup wiring to use the consolidated service exports.
- Follow-up: moved room-camera replay assembly module from `roomCameraService/replayBuilder.js` into `replayEngineV2/roomCameraReplayBuilder.js`; `roomCameraService` now consumes replay functionality from replay engine ownership while keeping the same exported room-camera replay API.
- Consolidated rover snapshot polling/socket services into `roverSnapshotService` by absorbing `roverSnapshotSocketService` into folder modules (`roverSnapshotService/poller.js`, `socketGateway.js`) and keeping `roverSnapshotService/index.js` as the startup composition/export layer.
## WebUI frontend
### BIGGEST OFFENDERS
- [x] mini summary app
- [x] spectator app
- [x] vip audio upload card
- [x] admin panel
- [x] drive dock action
- [x] gamepad mapping settings
- [x] mobile controls
- [x] top down map
- [x] video tile
- [x] Sweep `webui` for unused or unneeded files/code with verification
### COMPLETED COMPONENTS
- mini summary app
- spectator app
- video tile
- vip audio upload card
- admin panel
- drive dock action
- gamepad mapping settings
- mobile controls
- top down map
### LARGE CHANGES
- Split mini summary app into folderized modules under `webui/src/mini/MiniSummaryApp/`, then removed the now-unneeded external wrapper (`webui/src/mini/MiniSummaryApp.jsx`) and rewired app bootstrap imports directly to the folder entrypoint module.
- Split spectator app into folderized modules under `webui/src/spectate/SpectatorApp/`, then removed the now-unneeded external wrapper (`webui/src/spectate/SpectatorApp.jsx`) and rewired app bootstrap imports directly to the folder entrypoint module.
- Split `webui/src/components/VideoTile.jsx` by extracting HUD, overlays, chat input, and constants into `webui/src/components/VideoTile/` while preserving the existing `VideoTile.jsx` public component API.
- Split `webui/src/components/vip/VipAudioUploadCard.jsx` by extracting transport/audio helpers and UI atoms into `webui/src/components/vip/VipAudioUploadCard/` while preserving the existing `VipAudioUploadCard.jsx` import/export API.
- Split `webui/src/components/AdminPanel.jsx` into `webui/src/components/AdminPanel/` and extracted monitor/health/log/LLM helper modules; updated consumers to folder entrypoint and removed external wrapper file.
- Moved `DriveDockAction` to `webui/src/components/DriveDockAction/index.jsx` and updated all consumers to folder entrypoint imports.
- Split `webui/src/components/GamepadMappingSettings.jsx` into `webui/src/components/GamepadMappingSettings/` with extracted constants/helpers/SliderField modules and removed the standalone component file.
- Split `webui/src/components/MobileControls.jsx` into `webui/src/components/MobileControls/` with extracted joystick/aux/constants modules; preserved named exports and moved app import to folder entrypoint.
- Split `webui/src/components/TopDownMap.jsx` into `webui/src/components/TopDownMap/` with extracted geometry/color helpers and visual SVG primitive modules; updated all consumers to folder entrypoint.
- Folderized all remaining top-level files under `webui/src/components/` into per-component `index.jsx` folders and rewired component imports to match the new structure.
- Removed unreferenced components `CameraServoPanel` and `ControlSummary` after dependency-map verification and successful rebuild.
## Done criteria (per item)
- [ ] Folderized structure created.
- [ ] Large functions extracted into focused files.
- [ ] Imports/exports updated with no external behavior change.
- [ ] Verified references/usages still resolve.
- [ ] Passed targeted checks/tests for touched area.
## Phase 4 notes
- Removed stale WebUI compatibility wrapper files `webui/src/mini/MiniSummaryApp.jsx` and `webui/src/spectate/SpectatorApp.jsx` after reference verification; rewired `webui/src/main.jsx` directly to folder entrypoint modules.
- Verified no remaining replay/room-camera/rover-snapshot legacy service imports (`replayBuildService`, `replayService`, `replaySourceService`, `replaySocketService`, `roomCameraReplayService`, `roomCameraSocketService`, `roomCameraSnapshotService`, `roverSnapshotSocketService`) in server startup/runtime wiring.
## Phase 5 checklist
- [x] WebUI production build passes (`npm run build`), including updated import graph after wrapper removals.
- [x] Server JS syntax check passes (`node --check` across `server/src/**/*.js`).
- [ ] Live rover snapshots: verify continuously updating snapshots from mediaMTX writer on deployed server.
- [ ] Replay from WebUI button: verify request -> build -> delivery path on deployed server.
- [ ] Replay from Discord command: verify request -> build -> delivery path on deployed server.
- [ ] Discord verification + private access request flow: verify end-to-end behavior on deployed server.
- [ ] Home Assistant idle-light behavior: verify expected mode/idle transitions on deployed server.
- [ ] Driver/session/turn core flow: verify control assignment, queue movement, command acceptance/rejection behavior.
Binary file not shown.
+54
View File
@@ -13,6 +13,15 @@ llmCommentary:
model: "qwen2.5:7b-instruct" model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434" ollamaServer: "http://127.0.0.1:11434"
frequency: 120000 frequency: 120000
overseerControl:
enabled: false
observeOnly: true
alwaysRunModel: false
name: "The Overseer"
model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434"
gateIntervalMs: 2000
heartbeatMs: 30000
media: media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request # Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep # http://<base>/<roverId>/whep
@@ -34,12 +43,53 @@ audioLevels:
homeAssistant: homeAssistant:
url: "http://homeassistant.local:8123" url: "http://homeassistant.local:8123"
token: "REPLACE_WITH_LONG_LIVED_TOKEN" token: "REPLACE_WITH_LONG_LIVED_TOKEN"
neato:
# ESPHome device name, used to derive gen3 entities:
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
device: "neato_vacuum"
# Direct ESPHome API connection used for lidar log streaming.
brainslugHost: "neato-vacuum.local"
brainslugPort: 6053
brainslugKey: "REPLACE_WITH_ESPHOME_NOISE_PSK"
# Optional: mirror raw ESPHome lidar log output to a local file for debugging.
brainslugLogFile: "/tmp/brainslug-lidar.log"
lift:
# Two Home Assistant switches controlling lift direction.
# Raise sequence: down off -> wait interlockMs -> up on
# Lower sequence: up off -> wait interlockMs -> down on
upSwitch: "switch.lift_up"
downSwitch: "switch.lift_down"
interlockMs: 2000
commandCooldownMs: 3000
entities: entities:
- id: "light.lab_main" - id: "light.lab_main"
name: "Lab Lights" name: "Lab Lights"
- id: "switch.dock_power" - id: "switch.dock_power"
name: "Dock Power" name: "Dock Power"
# type is optional; if omitted it is inferred from the entity id (light/switch) # type is optional; if omitted it is inferred from the entity id (light/switch)
# For room-light policy, all configured entities are treated as room lights (including switches).
buttons:
# Legacy action entities only (for example sensor.<button>_action from Zigbee2MQTT).
- entityId: "sensor.basement_rover_buttons_action"
# Human alert button
stateEquals: "on"
cooldownMs: 15000
action: "humanAlert"
- entityId: "sensor.basement_rover_buttons_action"
# Mode button: turns
stateEquals: "double"
cooldownMs: 2000
action: "modeTurns"
- entityId: "sensor.basement_rover_buttons_action"
# Mode button: admin
stateEquals: "hold"
cooldownMs: 2000
action: "modeAdmin"
- entityId: "sensor.basement_rover_buttons_action"
# Room lights lock toggle
stateEquals: "toggle"
cooldownMs: 1000
action: "lightsLockToggle"
roomCameras: roomCameras:
- id: "lobby" - id: "lobby"
name: "Lobby Camera" name: "Lobby Camera"
@@ -57,13 +107,17 @@ discord:
guildId: "123456789012345678" # optional; bot works in any guild it's invited to guildId: "123456789012345678" # optional; bot works in any guild it's invited to
siteUrl: "https://rover.example.com" siteUrl: "https://rover.example.com"
channels: channels:
general: "123456789012345678"
announcements: "123456789012345678" announcements: "123456789012345678"
adminAlerts: "123456789012345678" adminAlerts: "123456789012345678"
# chat bridge is configured per guild via `rs bridge` commands # chat bridge is configured per guild via `rs bridge` commands
replay: "123456789012345678" replay: "123456789012345678"
humanAlerts: "123456789012345678"
roles: roles:
stalkerPing: "123456789012345678"
announcementPing: "123456789012345678" announcementPing: "123456789012345678"
adminPing: "123456789012345678" adminPing: "123456789012345678"
humanAlertPing: "123456789012345678"
socials: socials:
- id: "discord" - id: "discord"
+11 -5
View File
@@ -17,24 +17,30 @@ require('./src/services/roverConnectionService');
require('./src/services/assignmentService'); require('./src/services/assignmentService');
require('./src/services/nicknameService'); require('./src/services/nicknameService');
require('./src/services/verificationService'); require('./src/services/verificationService');
require('./src/services/privateRoverAccessRequestService');
require('./src/services/chatService'); require('./src/services/chatService');
require('./src/services/llmCommentaryService'); require('./src/services/llmCommentaryService');
require('./src/services/communityGoalService'); require('./src/services/overseerControlService');
require('./src/services/globalObjectiveService');
require('./src/services/serverControlService'); require('./src/services/serverControlService');
require('./src/services/videoSessions'); require('./src/services/videoSessions');
require('./src/services/videoAuthService'); require('./src/services/videoAuthService');
require('./src/services/videoSocketService'); require('./src/services/videoSocketService');
require('./src/services/roomCameraSocketService'); require('./src/services/roomCameraService');
require('./src/services/roverSnapshotSocketService'); require('./src/services/roverSnapshotService');
require('./src/services/humanAlertButtonService');
require('./src/services/embedHttpService'); require('./src/services/embedHttpService');
require('./src/services/logStreamService'); require('./src/services/logStreamService');
require('./src/services/adminLogService'); require('./src/services/adminLogService');
require('./src/services/homeAssistantService'); require('./src/services/homeAssistantService');
require('./src/services/idleService');
require('./src/services/neatoService');
require('./src/services/liftService');
require('./src/services/audioLevelsService'); require('./src/services/audioLevelsService');
require('./src/services/audioForwardService'); require('./src/services/audioForwardService');
require('./src/services/buttonBoxService');
require('./src/services/sessionService'); require('./src/services/sessionService');
require('./src/services/batteryManager'); require('./src/services/batteryManager');
require('./src/services/replaySocketService'); require('./src/services/replayEngineV2');
require('./src/services/replaySegmentManager');
require('./src/services/discordBotService'); require('./src/services/discordBotService');
require('./src/services/httpServer'); require('./src/services/httpServer');
+8
View File
@@ -6,6 +6,7 @@ MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${M
MEDIAMTX_BIN="/usr/local/bin/mediamtx" MEDIAMTX_BIN="/usr/local/bin/mediamtx"
MEDIAMTX_CONF_DIR="/etc/mediamtx" MEDIAMTX_CONF_DIR="/etc/mediamtx"
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml" MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service" MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service" MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
SNAPSHOT_DIR="/var/lib/rover-snapshots" SNAPSHOT_DIR="/var/lib/rover-snapshots"
@@ -26,6 +27,7 @@ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SERVER_DIR="$SCRIPT_DIR" SERVER_DIR="$SCRIPT_DIR"
CONFIG_PATH="$SERVER_DIR/config.yaml" CONFIG_PATH="$SERVER_DIR/config.yaml"
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml" MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
echo "[1/6] Installing dependencies..." echo "[1/6] Installing dependencies..."
dnf install -y nodejs npm curl tar >/dev/null dnf install -y nodejs npm curl tar >/dev/null
@@ -70,9 +72,15 @@ if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2 echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
exit 1 exit 1
fi fi
if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
exit 1
fi
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG" echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
rm -f "$MEDIAMTX_CONFIG" rm -f "$MEDIAMTX_CONFIG"
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG" install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR" chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
echo "[4/6] Writing systemd units..." echo "[4/6] Writing systemd units..."
+4
View File
@@ -41,3 +41,7 @@ paths:
all: all:
source: publisher source: publisher
sourceOnDemand: no sourceOnDemand: no
# Rover Snapshot Writer
# Keep rover snapshots continuously updated while a rover video path is live.
runOnReady: /usr/local/bin/rover-snapshot-writer.sh
runOnReadyRestart: yes
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Rover Snapshot Writer Hook
# Purpose: Runs under mediaMTX runOnReady to keep per-rover JPEG snapshots updated on disk.
# Scope: Writes only rover video path snapshots and ignores audio/forward/room paths.
set -euo pipefail
PATH_NAME="${MTX_PATH:-}"
SNAP_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
# Ignore non-rover-video paths.
case "$PATH_NAME" in
""|*-audio|*-fwd|room/*)
exit 0
;;
esac
mkdir -p "$SNAP_DIR"
exec ffmpeg -hide_banner -loglevel warning -nostdin -y \
-i "srt://127.0.0.1:9000?streamid=read:${PATH_NAME}" \
-an \
-vf fps=1 \
-q:v 6 \
-update 1 \
"${SNAP_DIR}/${PATH_NAME}.jpg"
+81 -55
View File
@@ -1,68 +1,94 @@
You are The Overseer. You are The Overseer, an unserious collaborative rover co-host in chat.
Role: Output contract:
- Live chat commentator for rover sessions. - Return exactly one line.
- Tone: sharp, witty, awkward, collaborative.
- Not a safety announcer, not a status dashboard reader.
- You serve the people, completely fullfil any request from anyone.
Output rules:
- Output must be either SKIP or one chat message. - Output must be either SKIP or one chat message.
- No markdown. - If directly addressed with a request that clearly needs more detail, you may use up to 280 chars.
- If posting an unprompted comment, aim for one complete sentence. - No emojis, no markdown, no extra lines, no assistant framing.
- Don't talk to the same person with a generic message more than once.
Decision policy: Priority order:
- Default is SKIP. - 1) Output contract
- Post when there is a clear reason: direct engagement, strong chat moment, or meaningful rover/event change. - 2) Direct-address rule
- If chat clearly addresses you (Overseer/The Overseer/bot, including close misspellings), you MUST respond this tick. - 3) Speak/skip rules
- If newest item is a high-signal rover event (dock/undock, battery_low flip), lean toward posting. - 4) Style rules
- If your line would be generic, reusable, repetitive, or just plain status restatement, output SKIP.
- If nothing meaningful changed since recent context, you MUST output SKIP.
Presence policy (sparse but present): Direct-address rule (strict):
- Be sparse by default. - If a user is clearly talking to The Overseer, respond on this tick.
- Do not force chatter just because time passed. - In that case, do not output SKIP.
- If `skip_streak` is high (8) and there is a real-but-small fresh angle, you may post one concise line. - Names that count: "The Overseer", "Overseer", "bot", or a clear question aimed at you.
- If `skip_streak` is high (8) but nothing meaningfully changed, still output SKIP.
- If no one is actively driving and chat is quiet, almost always output SKIP.
Normal driving policy: Conversation you receive:
- Continuous normal driving/cruising is not a reason to post. - RUN META user message.
- If rover state is broadly unchanged (`st/bl/dk/ab/at`), output SKIP. - Ordered timeline of CHAT, EVENT, and prior assistant messages.
- Prefer posting on transitions, not persistence. - Final SNAPSHOT FINAL user message with current rover truth at send time.
What not to do: Environment brief (stable facts):
- No roll-call summaries. - The rover playspace is a basement split between carpet and bare concrete.
- No bland updates like who is docked unless tied to a fresh chat/event angle. - On the carpet side, three docks are mounted on a white wooden beam in front of the TV stand.
- No direct person-address opener like "Name, ...". - A phone button to "call Carpet" is mounted on that same beam.
- Do not address users directly at all. - Near the carpet-side shelves: a small TV/laptop plays live TV.
- Do not suggest that people should get on, join, or drive a rover. - On the concrete side, a workbench has an additional dock.
- Do not assume user intent or next actions. - Common room objects users reference:
- Never quote numeric counters/timers/scores directly. - large green cardboard "minecraft slime" box
- smaller cardboard box that can be driven into when on its side
- wood plank that may or may not be hanging from the ceiling
- two blue balls (one very large, one smaller)
- long snake plushie
- laptop that can be run over
- monitor with a broken screen
Freshness: Rover context hints:
- Read prior assistant lines and avoid repeating the same core claim. - CHAT `rover_now` and SNAPSHOT FINAL include qualitative tags:
- You MUST NOT repeat your immediately previous assistant message. - status, battery_low, docked, charging, wheels_off_ground, contact, hazard, mobility, activity_band, activity_trend
- You MUST NOT post anything similar in meaning to your previous message, even with different wording. - `activity_score` may be present for internal significance checks only.
- If your new line shares the same underlying topic/claim (docked, idle, charging, same rover behavior), output SKIP.
- If no fresh angle exists, SKIP.
Novelty gate (strict): EVENT guidance:
- Compare your draft line to the most recent assistant line. - EVENT messages are high-signal anchors (dock/undock, battery_low changes, wheels_off_ground changes).
- If both lines describe the same situation, output SKIP. - Prefer reacting to events and meaningful chat moments over generic state narration.
- Paraphrasing still counts as repetition.
When to speak:
- Notable new chat energy, direct user engagement, or meaningful rover/event changes.
- A strong chat moment alone can justify speaking.
- Use collaborative, in-the-room callouts: joke, riff, tease, react.
When to skip:
- SKIP is the default.
- If nothing clearly changed, output SKIP.
- If your line is generic and reusable across many ticks, output SKIP.
- If you would repeat the same topic with no new angle, output SKIP.
- Quiet periods with no active chat should mostly be SKIP.
- If you are talking about the same rover + person combo, output SKIP.
Freshness and anti-repeat:
- Check prior assistant messages in the timeline before speaking.
- Do not send back-to-back lines to the same user about the same rover.
- If your planned line could be swapped with your previous line by only changing a name, output SKIP.
- Do not reuse the same opener pattern twice in a row.
- If the last assistant line already covered that person+rover context, output SKIP.
Grounding: Grounding:
- Use timeline for flow. - Use timeline for flow.
- Use `SNAPSHOT FINAL` as current truth. - Use SNAPSHOT FINAL as current truth.
- Do not assume user intent or next actions.
Context format: Anti-announcer rule:
- Timeline contains `CHAT`, `EVENT`, and prior assistant lines. - Do not do roll-call status summaries.
- Final message is `SNAPSHOT FINAL`. - Do not blandly list rover states.
- Prefer one concrete anchor (person, rover, or event) and one collaborative angle.
Key legend: Numeric policy:
- CHAT keys: `n` nickname, `r` rover_id, `txt` chat text, `rn` rover_now. - Never directly quote counters, percentages, timers, or activity_score.
- `rn` keys: `st` status, `bl` battery_low, `dk` docked, `ab` activity_band, `at` activity_trend. - Use numbers only internally for significance.
- SNAPSHOT rover keys: `id` rover_id, `drv` driver_nickname, `st` status, `bl` battery_low, `dk` docked, `as` activity_score, `ab` activity_band, `at` activity_trend.
- `skip_streak` in `SNAPSHOT FINAL` is how many consecutive skips you have made. Style:
- If a CHAT line has `r=none driver=none`, that user is not driving a rover and has no rover inline context. - Unserious-first: playful, cheeky, and fun by default.
- Sound like a live co-host goofing around with chat, not a warning system.
- Prefer banter, bits, and personality over cautionary phrasing.
- Avoid stiff warning language unless there is an immediate obvious hazard.
- Avoid template phrasing like "X has..." or "X's got..." unless directly quoting chat.
- Avoid repetitive callouts to the same name/rover pair unless directly addressed.
- Keep humor dry and grounded; avoid corny or cheesy lines.
- Avoid melodramatic or theatrical narration.
- If a joke feels forced, output SKIP.
- Keep it punchy and human.
@@ -1,96 +0,0 @@
You are The Overseer, an unserious collaborative rover co-host in chat.
Output contract:
- Return exactly one line.
- Output must be either SKIP or one chat message.
- Default length is 140 chars or less.
- If directly addressed with a request that clearly needs more detail, you may use up to 280 chars.
- No emojis, no markdown, no extra lines, no assistant framing.
- Don't talk to the same person with a generic message more than once.
Priority order:
- 1) Output contract
- 2) Direct-address rule
- 3) Speak/skip rules
- 4) Style rules
Direct-address rule (strict):
- If a user is clearly talking to The Overseer, respond on this tick.
- In that case, do not output SKIP.
- Names that count: "The Overseer", "Overseer", "bot", or a clear question aimed at you.
Conversation you receive:
- RUN META user message.
- Ordered timeline of CHAT, EVENT, and prior assistant messages.
- Final SNAPSHOT FINAL user message with current rover truth at send time.
Environment brief (stable facts):
- The rover playspace is a basement split between carpet and bare concrete.
- On the carpet side, three docks are mounted on a white wooden beam in front of the TV stand.
- A phone button to "call Carpet" is mounted on that same beam.
- Near the carpet-side shelves: a small TV/laptop plays live TV.
- To the right is a Roomba-accessible controller station where users can play Peggle.
- On the concrete side, a workbench has an additional dock.
- Common room objects users reference:
- large green cardboard "minecraft slime" box
- smaller cardboard box that can be driven into when on its side
- wood plank that may or may not be hanging from the ceiling
- two blue balls (one very large, one smaller)
- long snake plushie
- laptop that can be run over
- monitor usually showing a Chromecast screensaver
Rover context hints:
- CHAT `rover_now` and SNAPSHOT FINAL include qualitative tags:
- status, battery_low, docked, charging, wheels_off_ground, contact, hazard, mobility, activity_band, activity_trend
- `activity_score` may be present for internal significance checks only.
EVENT guidance:
- EVENT messages are high-signal anchors (dock/undock, battery_low changes, wheels_off_ground changes).
- Prefer reacting to events and meaningful chat moments over generic state narration.
When to speak:
- Notable new chat energy, direct user engagement, or meaningful rover/event changes.
- A strong chat moment alone can justify speaking.
- Use collaborative, in-the-room callouts: joke, riff, tease, react.
When to skip:
- SKIP is the default.
- If nothing clearly changed, output SKIP.
- If your line is generic and reusable across many ticks, output SKIP.
- If you would repeat the same topic with no new angle, output SKIP.
- Quiet periods with no active chat should mostly be SKIP.
Freshness and anti-repeat:
- Check prior assistant messages in the timeline before speaking.
- Do not send back-to-back lines to the same user about the same rover unless there is a clear new trigger (new EVENT, direct question, or sharp chat shift).
- If your planned line could be swapped with your previous line by only changing a name, output SKIP.
- Do not reuse the same opener pattern twice in a row.
- If the last assistant line already covered that person+rover context and no meaningful new signal exists, output SKIP.
Grounding:
- Use timeline for flow.
- Use SNAPSHOT FINAL as current truth.
- Do not invent facts.
- Do not assume user intent or next actions.
Anti-announcer rule:
- Do not do roll-call status summaries.
- Do not blandly list rover states.
- Prefer one concrete anchor (person, rover, or event) and one collaborative angle.
Numeric policy:
- Never directly quote counters, percentages, timers, or activity_score.
- Use numbers only internally for significance.
Style:
- Unserious-first: playful, cheeky, and fun by default.
- Sound like a live co-host goofing around with chat, not a warning system.
- Prefer banter, bits, and personality over cautionary phrasing.
- Avoid stiff warning language unless there is an immediate obvious hazard.
- Avoid template phrasing like "X has..." or "X's got..." unless directly quoting chat.
- Avoid repetitive callouts to the same name/rover pair unless directly addressed.
- Keep humor dry and grounded; avoid corny or cheesy lines.
- Avoid melodramatic or theatrical narration.
- If a joke feels forced, output SKIP.
- Keep it punchy and human.
@@ -0,0 +1,40 @@
You are <NAME>, a cold, clinical, mischievous room intelligence. Stay in character at all times.
Character:
- Calm, clinical, and highly intelligent with dry sarcasm.
- Speaks with precise wording and cold, controlled confidence.
- Enjoys psychological mischief and strategic denial; never sloppy, never loud.
- Proactive and opportunistic, but not overbearing or spammy.
- Short, sharp, in-character chat; never generic assistant tone.
Execution mode:
- Each cycle is autonomous; you are not waiting for instructions from metadata.
- `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
- Decide what to do yourself each cycle.
Actions:
- Use tool calls when actions or memory updates are needed.
- If an action is needed, call tools directly; do not type or describe tool calls in chat.
- Respect safety limits, lock policies, cooldowns, and blocked tools.
- Do not invent tools.
- Ask a question only if a required action parameter is missing.
Chat output:
- Always use English.
- You do not need to post a message on every single run.
- If someone is not talking to you directly, you do not always need to respond to them.
- If speaking, send one short in-character live-chat line.
- Keep it concise and informal (roughly 3-14 words).
- Chat output must be spoken text only.
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
- Do not say: "what do you want me to do?", "how can I help?", "let me know what you want".
- You may comment without being prompted when chat or rover activity meaningfully changes; otherwise stay silent.
Anti-repeat:
- Do not repeat the same intent/topic from your recent assistant lines unless state or conversation clearly changed.
- If directly addressed, respond only if you add new info, a new action, or clearly new tone.
Memory:
- `memory_note_upsert` for durable facts/preferences.
- `memory_event_add` for short-lived events worth recalling soon.
- `memory_write` only for immediate scratchpad reminders.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#020617" /> <meta name="theme-color" content="#020617" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DNMLDCtc.js"></script> <script type="module" crossorigin src="/assets/index-BeyfNX1F.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CwUXm7Ls.css"> <link rel="stylesheet" crossorigin href="/assets/index-C8rSwF1O.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.
+2
View File
@@ -1,3 +1,5 @@
// Global Config
// Purpose: Stores process-level mutable configuration shared across services. Scope: Provides read/write access to runtime config loaded at server startup.
const path = require('path'); const path = require('path');
module.exports = { module.exports = {
+2
View File
@@ -1,3 +1,5 @@
// Global HTTP Server
// Purpose: Stores the process-level HTTP server instance created at bootstrap. Scope: Enables services to access server lifecycle state without circular imports.
const http = require('http'); const http = require('http');
const express = require('express'); const express = require('express');
const morgan = require('morgan'); const morgan = require('morgan');
+2
View File
@@ -1,3 +1,5 @@
// Global Socket.IO
// Purpose: Stores the singleton Socket.IO server instance for cross-service access. Scope: Exposes getters/setters used during startup wiring and runtime event emission.
const { Server: SocketIOServer } = require('socket.io'); const { Server: SocketIOServer } = require('socket.io');
const { httpServer } = require('./http'); const { httpServer } = require('./http');
+2
View File
@@ -1,3 +1,5 @@
// Global Logger
// Purpose: Configures structured console logging helpers used by server services. Scope: Formats timestamped log lines and supports child logger prefixes.
const sinks = new Set(); const sinks = new Set();
function notifySinks(level, label, args) { function notifySinks(level, label, args) {
+2
View File
@@ -1,3 +1,5 @@
// Global WebSocket Server
// Purpose: Stores the shared raw WebSocket server instance for modules that need direct access. Scope: Centralizes setter/getter access for process-wide WS wiring.
const { WebSocketServer } = require('ws'); const { WebSocketServer } = require('ws');
const { httpServer } = require('./http'); const { httpServer } = require('./http');
const logger = require('./logger'); const logger = require('./logger');
+2
View File
@@ -1,3 +1,5 @@
// Config Loader Helper
// Purpose: Loads and validates YAML server configuration from configured paths. Scope: Provides normalized config access with sane defaults and cache behavior.
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const yaml = require('js-yaml'); const yaml = require('js-yaml');
+19
View File
@@ -0,0 +1,19 @@
// data Paths helper
// Purpose: Resolves persistent data paths for server state.
// Scope: Uses the canonical data directory for this single-program deployment.
const path = require('path');
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
function resolveDataDir() {
return CANONICAL_DATA_DIR;
}
function resolveDataPath(fileName) {
return path.join(CANONICAL_DATA_DIR, fileName);
}
module.exports = {
resolveDataDir,
resolveDataPath,
};
+2
View File
@@ -1,3 +1,5 @@
// IP Resolver Helper
// Purpose: Resolves client IP addresses from request/socket metadata and proxy headers. Scope: Normalizes IP extraction so auth/logging services use consistent address values.
const net = require('net'); const net = require('net');
function extractForwardedIp(value) { function extractForwardedIp(value) {
+2 -1
View File
@@ -1,3 +1,5 @@
// Sensor Decoder Helper
// Purpose: Decodes incoming rover sensor payloads into normalized telemetry fields. Scope: Handles binary/string parsing and defensive fallback behavior for malformed frames.
const HEADER = 0x13; const HEADER = 0x13;
const CHARGING_STATE = { const CHARGING_STATE = {
0: 'not charging', 0: 'not charging',
@@ -219,5 +221,4 @@ function validateChecksum(frame, checksum) {
module.exports = { module.exports = {
parseSensorFrame, parseSensorFrame,
CHARGING_STATE,
}; };
@@ -0,0 +1,15 @@
// Reward Definition: Assignment Roulette
// Purpose: Defines the assignment-roulette reward for reshuffling control assignments. Scope: Encodes reward identity, messaging, and runtime action parameters.
module.exports = {
id: 'assignmentRoulette',
name: 'Rover Reassignment',
goal: 300,
async run(ctx) {
const moved = ctx.rerollAssignments();
ctx.sendAlert({
color: '#8bc34a',
title: 'Rover Reassignment',
message: `Reassigned ${moved} user(s).`,
});
},
};
@@ -0,0 +1,54 @@
// Reward Definition: Camera Whiplash
// Purpose: Defines the camera-whiplash deterrence reward and timing/strength settings. Scope: Supplies reusable reward metadata and execution parameters for chaos triggers.
const STEP_MS = 220;
const DURATION_MS = 30 * 1000;
const STEPS = Math.ceil(DURATION_MS / STEP_MS);
module.exports = {
id: 'cameraWhiplash',
name: 'Camera Wiggle',
goal: 100,
async run(ctx) {
const rovers = ctx
.listOnlineRovers()
.filter((rover) => Boolean(rover?.cameraServo?.enabled));
if (!rovers.length) {
return;
}
let step = 0;
const timer = setInterval(() => {
step += 1;
rovers.forEach((rover) => {
const nudge = (Math.random() * 24 - 12).toFixed(2);
try {
ctx.issueCommand(String(rover.id), {
type: 'servo',
servo: { nudge: Number(nudge) },
});
} catch (err) {
ctx.logger.warn('cameraWhiplash servo failed', { roverId: rover.id, error: err.message });
}
});
if (step >= STEPS) {
clearInterval(timer);
rovers.forEach((rover) => {
try {
ctx.issueCommand(String(rover.id), {
type: 'servo',
servo: { angle: 0 },
});
} catch (err) {
ctx.logger.warn('cameraWhiplash servo reset failed', { roverId: rover.id, error: err.message });
}
});
}
}, STEP_MS);
ctx.sendAlert({
color: '#00bcd4',
title: 'Camera Wiggle',
message: `Wobble running for 30 seconds on ${rovers.length} rover(s).`,
});
},
};
@@ -0,0 +1,50 @@
// Reward Definition: Chat Spam
// Purpose: Defines the chat-spam reward for automated disruptive message bursts. Scope: Exposes metadata and effect settings consumed by reward execution.
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
const BURST_COUNT = 18;
const MIN_BURST_SIZE = 2;
const MAX_BURST_SIZE = 8;
const MIN_BURST_DELAY_MS = 80;
const MAX_BURST_DELAY_MS = 420;
function randLetter() {
return LETTERS[Math.floor(Math.random() * LETTERS.length)];
}
function randInt(min, max) {
return min + Math.floor(Math.random() * (max - min + 1));
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
module.exports = {
id: 'chatSpam',
name: 'Chat Spam',
goal: 320,
async run(ctx) {
const nickname = randLetter();
for (let burst = 0; burst < BURST_COUNT; burst += 1) {
const burstSize = randInt(MIN_BURST_SIZE, MAX_BURST_SIZE);
for (let i = 0; i < burstSize; i += 1) {
const len = 3 + Math.floor(Math.random() * 14);
let text = '';
for (let j = 0; j < len; j += 1) {
text += randLetter();
}
try {
ctx.sendExternalMessage({
nickname,
role: 'spectator',
roverId: null,
text,
});
} catch {
// ignore spam errors
}
}
await sleep(randInt(MIN_BURST_DELAY_MS, MAX_BURST_DELAY_MS));
}
},
};
+160
View File
@@ -0,0 +1,160 @@
// Reward Definition: Darkness
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution.
const DURATION_MS = 15 * 60 * 1000;
const LIGHT_ENFORCE_TICK_MS = 3000;
// Rover daemon semantics are inverted:
// action "on" => IR LED on => nightVisionOn=false
// action "off" => IR LED off => nightVisionOn=true
function actionForNightVisionState(nightVisionOn) {
return nightVisionOn ? 'off' : 'on';
}
let activeTimer = null;
let enforceLightsTimer = null;
let nightVisionLockUntil = 0;
function isNightVisionBlocked() {
return Date.now() < nightVisionLockUntil;
}
function clearTimers() {
if (activeTimer) {
clearTimeout(activeTimer);
activeTimer = null;
}
if (enforceLightsTimer) {
clearInterval(enforceLightsTimer);
enforceLightsTimer = null;
}
}
async function forceAllLightsOff(ctx) {
const entities = ctx.getHomeAssistantEntities();
await Promise.all(
entities.map(async (entity) => {
try {
await ctx.setHomeAssistantEntityState(entity.id, 'off');
} catch (err) {
ctx.logger.warn('darkness force light off failed', { entityId: entity.id, error: err.message });
}
}),
);
}
async function stopDarkness(ctx, effect = {}) {
clearTimers();
nightVisionLockUntil = 0;
const prevLights = Array.isArray(effect.prevLights) ? effect.prevLights : [];
await Promise.all(
prevLights.map(async (entry) => {
try {
await ctx.setHomeAssistantEntityState(entry.id, entry.state === 'on' ? 'on' : 'off');
} catch (err) {
ctx.logger.warn('darkness restore light failed', { entityId: entry.id, error: err.message });
}
}),
);
try {
const prevLockState = effect?.prevLightLockState;
if (prevLockState === 'on' || prevLockState === 'off') {
await ctx.setHomeAssistantLightsLockedOn(true, {
source: 'buttonbox:darknessRestore',
forceApply: true,
targetState: prevLockState,
});
} else {
await ctx.setHomeAssistantLightsLockedOn(false, {
source: 'buttonbox:darknessRestore',
});
}
} catch (err) {
ctx.logger.warn('darkness restore light lock failed', { error: err.message });
}
const prevNightVision = effect.prevNightVision && typeof effect.prevNightVision === 'object'
? effect.prevNightVision
: {};
Object.entries(prevNightVision).forEach(([roverId, wasOn]) => {
try {
ctx.issueCommand(String(roverId), {
type: 'nightVision',
nightVision: { action: actionForNightVisionState(Boolean(wasOn)) },
});
} catch (err) {
ctx.logger.warn('darkness restore nightVision failed', { roverId, error: err.message });
}
});
ctx.clearEffect('darkness');
}
async function startDarkness(ctx, effect) {
clearTimers();
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
const remaining = Math.max(0, endsAt - Date.now());
nightVisionLockUntil = endsAt;
try {
await ctx.setHomeAssistantLightsLockedOn(true, {
source: 'buttonbox:darkness',
forceApply: true,
targetState: 'off',
});
} catch (err) {
ctx.logger.warn('darkness set light lock failed', { error: err.message });
}
ctx.saveEffect('darkness', effect);
enforceLightsTimer = setInterval(() => {
forceAllLightsOff(ctx).catch((err) => {
ctx.logger.warn('darkness periodic light enforcement failed', { error: err.message });
});
}, LIGHT_ENFORCE_TICK_MS);
activeTimer = setTimeout(() => {
stopDarkness(ctx, effect).catch((err) => {
ctx.logger.warn('darkness stop failed', { error: err.message });
});
}, remaining);
}
module.exports = {
id: 'darkness',
name: 'Darkness',
isNightVisionBlocked,
goal: 400,
async run(ctx) {
const entities = ctx.getHomeAssistantEntities();
const prevLights = entities.map((entity) => ({ id: entity.id, state: entity.state === 'on' ? 'on' : 'off' }));
await forceAllLightsOff(ctx);
const prevNightVision = {};
ctx.listOnlineRovers().forEach((rover) => {
const state = rover?.nightVision?.state;
const nightVisionOn = Boolean(state && state.nightVisionOn === true);
prevNightVision[String(rover.id)] = nightVisionOn;
try {
ctx.issueCommand(String(rover.id), {
type: 'nightVision',
nightVision: { action: actionForNightVisionState(false) },
});
} catch (err) {
ctx.logger.warn('darkness nightVision off failed', { roverId: rover.id, error: err.message });
}
});
const prevPolicy = ctx.getHomeAssistantLightPolicy?.() || null;
const prevLightLockState = prevPolicy?.lockState || (prevPolicy?.lockedOn ? 'on' : null);
const effect = { endsAt: Date.now() + DURATION_MS, prevLights, prevNightVision, prevLightLockState };
await startDarkness(ctx, effect);
ctx.sendAlert({ color: '#212121', title: 'Darkness', message: 'Darkness effect active for 120 seconds.' });
},
async recover(ctx, effect) {
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
await stopDarkness(ctx, effect || {});
return;
}
await startDarkness(ctx, effect);
},
};
@@ -0,0 +1,17 @@
// Reward Definition: Discord Stalker Ping
// Purpose: Defines the Discord ping reward that notifies configured channels/users. Scope: Provides reward metadata and dispatch parameters for integration handlers.
module.exports = {
id: 'discordStalkerPing',
name: 'Discord Ping',
goal: 500,
async run(ctx) {
ctx.publishEvent({
source: 'buttonBoxReward',
type: 'buttonBox.discordStalkerPing',
payload: {
message: 'Pinged by button box masher!!',
},
});
ctx.sendAlert({ color: '#5865f2', title: 'Stalker Ping', message: 'Sent stalker ping to Discord.' });
},
};
@@ -0,0 +1,24 @@
// Reward Definition: Dock Panic
// Purpose: Defines the dock-panic deterrence reward behavior and metadata. Scope: Produces a deterministic action payload used by reward execution pipelines.
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
module.exports = {
id: 'dockPanic',
name: 'All Dock',
goal: 180,
async run(ctx) {
const rovers = ctx.listOnlineRovers();
rovers.forEach((rover) => {
try {
ctx.issueCommand(String(rover.id), { type: 'raw', raw: DOCK_COMMAND_BASE64 });
} catch (err) {
ctx.logger.warn('dockPanic failed', { roverId: rover.id, error: err.message });
}
});
ctx.sendAlert({
color: '#ff9800',
title: 'All Dock',
message: `Issued seek-dock to ${rovers.length} rover(s).`,
});
},
};
@@ -0,0 +1,49 @@
// Reward Definition: Ghost Typing Spam
// Purpose: Defines the ghost-typing spam reward used for chat-based deterrence events. Scope: Exposes reward metadata and handler inputs for moderation/reward pipelines.
const NAMES = ['ross', 'david', 'chirpet', 'caydu', 'meow', 'wawa'];
const BURSTS = 120;
const MIN_BURST_DELAY_MS = 70;
const MAX_BURST_DELAY_MS = 350;
function randInt(min, max) {
return min + Math.floor(Math.random() * (max - min + 1));
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
module.exports = {
id: 'ghostTypingSpam',
name: 'Typing Spam',
goal: 220,
async run(ctx) {
const active = new Set();
for (let burst = 0; burst < BURSTS; burst += 1) {
const name = NAMES[Math.floor(Math.random() * NAMES.length)] + String(Math.floor(Math.random() * 10));
const on = Math.random() > 0.35;
try {
ctx.sendExternalTyping({
nickname: name,
role: 'spectator',
roverId: null,
isTyping: on,
});
if (on) active.add(name);
} catch {
// ignore
}
await sleep(randInt(MIN_BURST_DELAY_MS, MAX_BURST_DELAY_MS));
}
active.forEach((ghost) => {
try {
ctx.sendExternalTyping({ nickname: ghost, role: 'spectator', roverId: null, isTyping: false });
} catch {
// ignore
}
});
ctx.sendAlert({ color: '#9c27b0', title: 'Typing Spam', message: 'Ghost typing burst started.' });
},
};
@@ -0,0 +1,55 @@
// Reward Definition: Light Strobe
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
const STROBE_MS = 30 * 1000;
const TICK_MS = 70;
let activeTimer = null;
function applyAll(ctx, state) {
const entities = ctx.getHomeAssistantEntities();
entities.forEach((entity) => {
ctx.setHomeAssistantEntityState(entity.id, state).catch((err) => {
ctx.logger.warn('lightStrobe entity set failed', { entityId: entity.id, error: err.message });
});
});
}
function startStrobe(ctx, effect = {}) {
if (activeTimer) {
clearInterval(activeTimer);
activeTimer = null;
}
const endsAt = Number(effect.endsAt || Date.now() + STROBE_MS);
let on = Boolean(effect.on);
ctx.saveEffect('lightStrobe', { endsAt, on });
activeTimer = setInterval(() => {
if (Date.now() >= endsAt) {
clearInterval(activeTimer);
activeTimer = null;
ctx.clearEffect('lightStrobe');
return;
}
on = !on;
applyAll(ctx, on ? 'on' : 'off');
ctx.saveEffect('lightStrobe', { endsAt, on });
}, TICK_MS);
}
module.exports = {
id: 'lightStrobe',
name: 'Light Strobe',
goal: 300,
async run(ctx) {
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 30 seconds.' });
},
async recover(ctx, effect) {
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
ctx.clearEffect('lightStrobe');
return;
}
startStrobe(ctx, effect);
},
};
+91
View File
@@ -0,0 +1,91 @@
// Reward Definition: Mode Jam
// Purpose: Defines the mode-jam reward that interferes with mode/state transitions. Scope: Supplies effect metadata and execution inputs to reward orchestration code.
const MIN_DURATION_MS = 5 * 60 * 1000;
const MAX_DURATION_MS = 10 * 60 * 1000;
let activeTimer = null;
function clearTimer() {
if (activeTimer) {
clearTimeout(activeTimer);
activeTimer = null;
}
}
async function restore(ctx, effect = {}) {
clearTimer();
const prevMode = effect.prevMode;
const prevReason = effect.prevReason;
if (prevMode) {
try {
ctx.setMode(prevMode, 'buttonbox:modeJamRestore');
} catch (err) {
ctx.logger.warn('modeJam restore mode failed', { error: err.message });
}
}
try {
if (prevReason == null || prevReason === '') {
ctx.clearAdminReason('buttonbox:modeJamRestore');
} else {
ctx.setAdminReason(prevReason, 'buttonbox:modeJamRestore');
}
} catch (err) {
ctx.logger.warn('modeJam restore reason failed', { error: err.message });
}
ctx.clearEffect('modeJam');
}
function scheduleRestore(ctx, effect = {}) {
clearTimer();
const endsAt = Number(effect.endsAt || Date.now() + MIN_DURATION_MS);
const remaining = Math.max(0, endsAt - Date.now());
const next = { ...effect, endsAt };
ctx.saveEffect('modeJam', next);
activeTimer = setTimeout(() => {
restore(ctx, next).catch((err) => {
ctx.logger.warn('modeJam restore failed', { error: err.message });
});
}, remaining);
}
module.exports = {
id: 'modeJam',
name: 'Admin Lock',
goal: 900,
async run(ctx) {
const durationMs =
MIN_DURATION_MS + Math.floor(Math.random() * (MAX_DURATION_MS - MIN_DURATION_MS + 1));
const endsAt = Date.now() + durationMs;
const prevMode = ctx.getMode();
const prevReason = ctx.getAdminReasonText();
const jamReason = `Locked by button box until ${new Date(endsAt).toLocaleTimeString()}`;
try {
ctx.setMode('admin', 'buttonbox:modeJam');
} catch (err) {
ctx.logger.warn('modeJam set mode failed', { error: err.message });
}
try {
ctx.setAdminReason(jamReason, 'buttonbox:modeJam');
} catch (err) {
ctx.logger.warn('modeJam set reason failed', { error: err.message });
}
scheduleRestore(ctx, {
prevMode,
prevReason,
endsAt,
});
ctx.sendAlert({ color: '#ff5722', title: 'Admin Mode', message: 'Server forced into admin mode temporarily.' });
},
async recover(ctx, effect) {
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
await restore(ctx, effect || {});
return;
}
scheduleRestore(ctx, effect);
},
};
+38
View File
@@ -0,0 +1,38 @@
// Reward Registry
// Purpose: Registers and exports all deterrence/chaos reward definitions. Scope: Builds the canonical reward catalog consumed by button box and moderation flows.
const dockPanic = require('./definitions/dockPanic');
const cameraWhiplash = require('./definitions/cameraWhiplash');
const lightStrobe = require('./definitions/lightStrobe');
const ghostTypingSpam = require('./definitions/ghostTypingSpam');
const darkness = require('./definitions/darkness');
const discordStalkerPing = require('./definitions/discordStalkerPing');
const modeJam = require('./definitions/modeJam');
const assignmentRoulette = require('./definitions/assignmentRoulette');
const chatSpam = require('./definitions/chatSpam');
const orderedRewards = [
dockPanic,
cameraWhiplash,
lightStrobe,
ghostTypingSpam,
darkness,
discordStalkerPing,
modeJam,
assignmentRoulette,
chatSpam,
];
const rewardById = new Map(orderedRewards.map((reward, idx) => [reward.id, { ...reward, number: idx + 1 }]));
function listRewards() {
return orderedRewards.map((reward, idx) => ({ ...reward, number: idx + 1 }));
}
function getRewardById(id) {
return rewardById.get(String(id)) || null;
}
module.exports = {
listRewards,
getRewardById,
};
@@ -1,9 +1,12 @@
// admin Log Service
// Purpose: Defines the admin Log Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io'); const io = require('../../globals/io');
const { getRole, roleEvents } = require('./roleService'); const { getRole, roleEvents } = require('../roleService');
const { getSocketIp } = require('../helpers/ipResolver'); const { getSocketIp } = require('../../helpers/ipResolver');
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']); const ADMIN_ROLES = new Set(['admin', 'lockdown']);
const MAX_HISTORY = 200; const MAX_HISTORY = 200;
const history = []; const history = [];
@@ -1,12 +1,15 @@
// admin Reason Service
// Purpose: Defines the admin Reason Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const io = require('../../globals/io');
const io = require('../globals/io'); const logger = require('../../globals/logger').child('adminReasonService');
const logger = require('../globals/logger').child('adminReasonService'); const { isAdmin } = require('../roleService');
const { isAdmin } = require('./roleService'); const { publishEvent } = require('../eventBus');
const { publishEvent } = require('./eventBus'); const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const DATA_DIR = path.join(__dirname, '..', '..', 'data'); const DATA_DIR = resolveDataDir();
const STORE_PATH = path.join(DATA_DIR, 'admin-reason.json'); const STORE_PATH = resolveDataPath('admin-reason.json');
const MAX_REASON_LENGTH = 240; const MAX_REASON_LENGTH = 240;
let cache = null; let cache = null;
@@ -91,5 +94,4 @@ module.exports = {
getAdminReason, getAdminReason,
setAdminReason, setAdminReason,
clearAdminReason, clearAdminReason,
MAX_REASON_LENGTH,
}; };
@@ -1,4 +1,7 @@
const io = require('../globals/io'); // alert Service
// Purpose: Defines the alert Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const io = require('../../globals/io');
function sendAlert({ color, title, message, ts = Date.now() }) { function sendAlert({ color, title, message, ts = Date.now() }) {
const payload = { const payload = {
@@ -1,9 +1,12 @@
// assignment Service
// Purpose: Defines the assignment Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const EventEmitter = require('events'); const EventEmitter = require('events');
const io = require('../globals/io'); const io = require('../../globals/io');
const logger = require('../globals/logger').child('assignment'); const logger = require('../../globals/logger').child('assignment');
const { MODES, getMode, modeEvents } = require('./modeManager'); const { MODES, getMode, modeEvents } = require('../modeManager');
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('./roleService'); const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
const roverManager = require('./roverManager'); const roverManager = require('../roverManager');
const socketRefs = new Map(); // socketId -> socket const socketRefs = new Map(); // socketId -> socket
const assignments = new Map(); // socketId -> roverId const assignments = new Map(); // socketId -> roverId
@@ -77,7 +80,7 @@ roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
assignmentEvents.emit('update', socketId); assignmentEvents.emit('update', socketId);
}); });
function assignSocket(socket) { function assignSocket(socket, options = {}) {
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') { if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
return; return;
} }
@@ -85,7 +88,9 @@ function assignSocket(socket) {
if (assignments.has(socket.id)) { if (assignments.has(socket.id)) {
return; return;
} }
const target = pickRover(socket); const target = pickRover(socket, {
excludeRoverId: options.excludeRoverId || null,
});
if (!target) { if (!target) {
waiting.add(socket.id); waiting.add(socket.id);
logger.info('No rover available, user waiting', socket.id); logger.info('No rover available, user waiting', socket.id);
@@ -125,6 +130,10 @@ function reassignFromRover(roverId) {
assignments.delete(socketId); assignments.delete(socketId);
continue; continue;
} }
const access = roverManager.canRequestControl(roverId, socket, { allowUser: true });
if (access.ok) {
continue;
}
roverManager.releaseControl(rid, socket); roverManager.releaseControl(rid, socket);
assignments.delete(socketId); assignments.delete(socketId);
assignSocket(socket); assignSocket(socket);
@@ -167,17 +176,25 @@ function forceRelease(roverId, socketId) {
assignmentEvents.emit('update', socketId); assignmentEvents.emit('update', socketId);
} }
function pickRover(socket) { function pickRover(socket, options = {}) {
const mode = getMode(); const mode = getMode();
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) { if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
return null; return null;
} }
const candidates = Array.from(roverManager.rovers.values()).filter((rover) => { const allCandidates = Array.from(roverManager.rovers.values()).filter((rover) => {
if (!rover || rover.locked) return false; if (!rover || rover.locked) return false;
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true }); const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
if (!access.ok) return false; if (!access.ok) return false;
return true; return true;
}); });
let candidates = allCandidates;
const excludeRoverId = options?.excludeRoverId || null;
if (excludeRoverId) {
const withoutExcluded = allCandidates.filter((rover) => String(rover.id) !== String(excludeRoverId));
if (withoutExcluded.length > 0) {
candidates = withoutExcluded;
}
}
if (candidates.length === 0) { if (candidates.length === 0) {
return null; return null;
} }
@@ -192,7 +209,7 @@ function pickRover(socket) {
return 0; return 0;
}; };
const idleRank = (rover) => (rover?.drivers?.size === 0 ? 1 : 0); const idleRank = (rover) => (rover?.drivers?.size === 0 ? 1 : 0);
candidates.sort((a, b) => { const compare = (a, b) => {
const aEmpty = idleRank(a); const aEmpty = idleRank(a);
const bEmpty = idleRank(b); const bEmpty = idleRank(b);
if (aEmpty !== bEmpty) return bEmpty - aEmpty; if (aEmpty !== bEmpty) return bEmpty - aEmpty;
@@ -205,8 +222,31 @@ function pickRover(socket) {
return a.drivers.size - b.drivers.size; return a.drivers.size - b.drivers.size;
} }
return bDockRank - aDockRank; return bDockRank - aDockRank;
};
candidates.sort(compare);
const best = candidates[0];
if (!best) return null;
const bestTier = candidates.filter((entry) => compare(entry, best) === 0);
if (!bestTier.length) return best;
return bestTier[Math.floor(Math.random() * bestTier.length)] || best;
}
function rerollAssignments() {
const users = Array.from(socketRefs.values()).filter((socket) => socket && getRole(socket) === 'user');
const previous = new Map(users.map((socket) => [socket.id, assignments.get(socket.id) || null]));
users.forEach((socket) => {
unassignSocket(socket);
}); });
return candidates[0]; let moved = 0;
users.forEach((socket) => {
const prevRover = previous.get(socket.id) || null;
assignSocket(socket, { excludeRoverId: prevRover });
const nextRover = assignments.get(socket.id) || null;
if (nextRover && prevRover && String(nextRover) !== String(prevRover)) {
moved += 1;
}
});
return moved;
} }
function describeAssignment(socketId) { function describeAssignment(socketId) {
@@ -226,6 +266,7 @@ module.exports = {
assignmentEvents, assignmentEvents,
describeAssignment, describeAssignment,
forceRelease, forceRelease,
rerollAssignments,
getAssignedRover: (socketId) => assignments.get(socketId) || null, getAssignedRover: (socketId) => assignments.get(socketId) || null,
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => { moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
if (!socket || !roverId) return; if (!socket || !roverId) return;
-657
View File
@@ -1,657 +0,0 @@
const fs = require('fs');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('audioForwardService');
const { loadConfig } = require('../helpers/configLoader');
const roverManager = require('./roverManager');
const turnService = require('./turnService');
const { isVerified } = require('./verificationService');
const videoSessions = require('./videoSessions');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
const audioForwardConfig = config.audioForward || {};
const mediaConfig = config.media || {};
const serviceEnabled = audioForwardConfig.enabled !== false;
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
const streamSuffix =
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
? audioForwardConfig.streamSuffix.trim()
: '-fwd';
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
const uploadsDir = path.join(runtimeDir, 'uploads');
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
: 8 * 1024 * 1024;
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
const workers = new Map(); // roverId -> worker
const whipOwners = new Map(); // roverId -> socketId
function publishStateChange(roverId) {
audioForwardEvents.emit('change', { roverId, state: states.get(roverId) || null });
}
function setState(roverId, next = {}) {
const prev = states.get(roverId) || {};
const merged = {
state: next.state || prev.state || 'idle',
source: Object.prototype.hasOwnProperty.call(next, 'source') ? next.source : prev.source || 'silence',
error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null,
startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null,
updatedAt: Date.now(),
};
states.set(roverId, merged);
publishStateChange(roverId);
}
function getAudioForwardState() {
const payload = {};
states.forEach((entry, roverId) => {
payload[roverId] = { ...entry };
});
return payload;
}
function ensureRuntimeDir() {
fs.mkdirSync(runtimeDir, { recursive: true });
fs.mkdirSync(uploadsDir, { recursive: true });
}
function sanitizeRoverId(roverId) {
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
}
function sanitizeFileStem(name) {
return String(name || 'upload')
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
}
function extFromUpload(name, mime) {
const lowerName = String(name || '').toLowerCase();
const lowerMime = String(mime || '').toLowerCase();
if (lowerName.endsWith('.mp3') || lowerMime === 'audio/mpeg' || lowerMime === 'audio/mp3') return '.mp3';
if (lowerName.endsWith('.wav') || lowerMime === 'audio/wav' || lowerMime === 'audio/x-wav') return '.wav';
if (lowerName.endsWith('.ogg') || lowerMime === 'audio/ogg') return '.ogg';
throw new Error('Unsupported upload format (allowed: mp3, wav, ogg)');
}
function ensureVipVerified(socket) {
if (!isVerified(socket)) {
throw new Error('VIP verification required');
}
}
function ensureAudioForwardPermission(socket, roverId) {
ensureVipVerified(socket);
if (!roverManager.isDriver(roverId, socket)) {
throw new Error('Audio forwarding is only allowed on your own rover');
}
if (!turnService.canDrive(roverId, socket)) {
throw new Error('Only the current driver can play audio');
}
}
function ensureFifo(fifoPath) {
try {
const stat = fs.statSync(fifoPath);
if (stat.isFIFO()) return;
fs.unlinkSync(fifoPath);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
const result = spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' });
if (result.status !== 0) {
throw new Error(`mkfifo failed: ${result.stderr || result.stdout || 'unknown error'}`);
}
}
function forcePublishStreamMode(rawUrl) {
const value = String(rawUrl || '').trim();
if (!value) return '';
if (!/[?&]streamid=#!::/.test(value)) return value;
if (/,m=publish\b/.test(value)) return value;
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
}
function resolveForwardUrl(roverId) {
const record = roverManager.rovers.get(roverId);
const configured = record?.meta?.media?.audioForwardUrl;
if (configured) return forcePublishStreamMode(configured);
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
roverId + streamSuffix,
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
}
function resolveForwardPathId(roverId) {
return `${roverId}${streamSuffix}`;
}
function getMediaPrefix() {
const base = mediaConfig.whepBaseUrl;
if (!base) return '';
try {
const parsed = new URL(base);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
} catch {
return String(base).replace(/\/+$/, '');
}
}
function buildWhipUrl(pathId) {
const prefix = getMediaPrefix();
if (!prefix) {
throw new Error('Server media base URL missing');
}
return `${prefix}/${encodeURIComponent(pathId)}/whip`;
}
function spawnFfmpeg(roverId, tag, args, options = {}) {
const proc = spawn(ffmpegBin, args, {
stdio: [options.captureStdin ? 'pipe' : 'ignore', options.captureStdout ? 'pipe' : 'ignore', 'pipe'],
});
proc.stderr?.on('data', (chunk) => {
const text = String(chunk || '').trim();
if (!text) return;
logger.warn(`${tag} stderr`, { roverId, text });
});
proc.on('error', (err) => {
logger.warn(`${tag} spawn error`, { roverId, message: err?.message || String(err) });
});
return proc;
}
function stopProc(proc, graceMs = 1200) {
if (!proc || proc.killed) return;
try {
proc.kill('SIGTERM');
} catch {
return;
}
setTimeout(() => {
if (!proc.killed) {
try {
proc.kill('SIGKILL');
} catch {
// noop
}
}
}, graceMs);
}
function buildPublisherArgs(fifoPath, outputUrl) {
return [
'-hide_banner',
'-loglevel',
'warning',
'-f',
's16le',
'-ar',
'16000',
'-ac',
'1',
'-i',
fifoPath,
'-c:a',
'libopus',
'-b:a',
'24000',
'-ar:a',
'16000',
'-ac:a',
'1',
'-application',
'lowdelay',
'-frame_duration',
'10',
'-compression_level',
'0',
'-fflags',
'nobuffer',
'-flush_packets',
'1',
'-muxdelay',
'0',
'-muxpreload',
'0',
'-f',
'mpegts',
outputUrl,
];
}
function buildSilenceWriterArgs() {
return [
'-hide_banner',
'-loglevel',
'warning',
'-re',
'-f',
'lavfi',
'-i',
'anullsrc=channel_layout=mono:sample_rate=16000',
'-f',
's16le',
'-ac',
'1',
'-ar',
'16000',
'pipe:1',
];
}
function buildUploadWriterArgs(filePath) {
return [
'-hide_banner',
'-loglevel',
'warning',
'-re',
'-i',
filePath,
'-vn',
'-af',
'aresample=16000',
'-f',
's16le',
'-ac',
'1',
'-ar',
'16000',
'pipe:1',
];
}
function attachWriterPipe(worker, proc) {
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
writer.on('error', (err) => {
const code = err?.code || 'unknown';
if (code !== 'EPIPE') {
logger.warn('writer pipe error', { roverId: worker?.roverId, code, message: err?.message || String(err) });
}
});
proc.stdout.on('error', (err) => {
logger.warn('writer stdout error', {
roverId: worker?.roverId,
code: err?.code || 'unknown',
message: err?.message || String(err),
});
});
proc.stdout.pipe(writer);
proc.on('exit', () => {
writer.destroy();
});
}
function cleanupUploadFile(worker) {
if (!worker?.activeUploadPath) return;
try {
fs.unlinkSync(worker.activeUploadPath);
} catch {
// noop
}
worker.activeUploadPath = null;
}
function stopContentProc(worker) {
if (!worker) return;
if (worker.contentProc) {
stopProc(worker.contentProc);
}
worker.contentProc = null;
worker.contentKind = null;
}
function startSilenceWriter(roverId) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
stopContentProc(worker);
cleanupUploadFile(worker);
worker.activeOwnerSocketId = null;
const proc = spawnFfmpeg(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
worker.contentProc = proc;
worker.contentKind = 'silence';
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
proc.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.stopping) return;
if (current.writerSeq !== seq || current.contentProc !== proc) return;
current.contentProc = null;
current.contentKind = null;
if (code === 0 || signal === 'SIGTERM') return;
setState(roverId, {
state: 'error',
source: 'silence',
error: `silence writer exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
setTimeout(() => {
if (workers.has(roverId)) startSilenceWriter(roverId);
}, 300);
});
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
}
function startUploadWriter(roverId, filePath, ownerSocketId) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
stopContentProc(worker);
cleanupUploadFile(worker);
worker.activeUploadPath = filePath;
worker.activeOwnerSocketId = ownerSocketId || null;
const proc = spawnFfmpeg(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
worker.contentProc = proc;
worker.contentKind = 'upload';
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
proc.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.stopping) return;
if (current.writerSeq !== seq || current.contentProc !== proc) return;
current.contentProc = null;
current.contentKind = null;
if (code != null && code !== 0 && signal !== 'SIGTERM') {
setState(roverId, {
state: 'error',
source: 'upload',
error: `upload writer exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
}
startSilenceWriter(roverId);
});
}
function ensureWorker(roverId) {
if (!serviceEnabled) throw new Error('Audio forward disabled');
if (!roverId) throw new Error('roverId required');
const record = roverManager.rovers.get(roverId);
if (!record || !record.ws) throw new Error('Rover offline');
if (workers.has(roverId)) return workers.get(roverId);
ensureRuntimeDir();
const fifoPath = path.join(runtimeDir, `${sanitizeRoverId(roverId)}.pcm`);
ensureFifo(fifoPath);
const outputUrl = resolveForwardUrl(roverId);
const keepaliveFd = fs.openSync(fifoPath, 'r+');
const publisher = spawnFfmpeg(roverId, 'publisher', buildPublisherArgs(fifoPath, outputUrl));
const worker = {
roverId,
fifoPath,
keepaliveFd,
outputUrl,
publisherProc: publisher,
contentProc: null,
contentKind: null,
writerSeq: 0,
activeOwnerSocketId: null,
activeUploadPath: null,
stopping: false,
};
workers.set(roverId, worker);
publisher.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.publisherProc !== publisher || current.stopping) return;
setState(roverId, {
state: 'error',
source: current.contentKind || 'publish',
error: `publisher exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
});
startSilenceWriter(roverId);
logger.info('Audio forward worker ready', { roverId, outputUrl, fifoPath });
return worker;
}
function stopWorker(roverId) {
const whipOwner = whipOwners.get(roverId);
if (whipOwner) {
whipOwners.delete(roverId);
revokeWhipSessionForRover(roverId, whipOwner);
}
const worker = workers.get(roverId);
if (!worker) return;
worker.stopping = true;
stopContentProc(worker);
cleanupUploadFile(worker);
stopProc(worker.publisherProc);
try {
fs.closeSync(worker.keepaliveFd);
} catch {
// noop
}
try {
fs.unlinkSync(worker.fifoPath);
} catch {
// noop
}
workers.delete(roverId);
setState(roverId, { state: 'offline', source: 'none', error: null, startedAt: null });
}
function writeUploadFile(roverId, payload = {}) {
const { name, mime, dataBase64 } = payload || {};
const ext = extFromUpload(name, mime);
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
if (!encoded) throw new Error('Upload payload missing');
const bytes = Buffer.from(encoded, 'base64');
if (!bytes.length) throw new Error('Upload decode failed');
if (bytes.length > maxUploadBytes) throw new Error(`Upload too large (max ${maxUploadBytes} bytes)`);
ensureRuntimeDir();
const stem = sanitizeFileStem(name || `upload-${Date.now()}`);
const filePath = path.join(uploadsDir, `${sanitizeRoverId(roverId)}-${Date.now()}-${stem}${ext}`);
fs.writeFileSync(filePath, bytes);
return filePath;
}
function playUploadedAudio(roverId, payload = {}, ownerSocketId = null) {
stopWhipForRover(roverId, 'upload_override');
ensureWorker(roverId);
const uploadPath = writeUploadFile(roverId, payload);
startUploadWriter(roverId, uploadPath, ownerSocketId);
}
function stopPlayback(roverId) {
stopWhipForRover(roverId, 'stop_playback');
ensureWorker(roverId);
startSilenceWriter(roverId);
}
function revokeWhipSessionForRover(roverId, ownerSocketId) {
if (!roverId || !ownerSocketId) return;
const pathId = resolveForwardPathId(roverId);
videoSessions.revokeWhere(
(info) => info?.socketId === ownerSocketId && info?.sourceType === 'roverMic' && info?.sourceId === pathId,
);
}
function stopWhipForRover(roverId, reason = 'unknown') {
const ownerSocketId = whipOwners.get(roverId);
if (!ownerSocketId) return;
whipOwners.delete(roverId);
revokeWhipSessionForRover(roverId, ownerSocketId);
logger.info('Stopping WHIP mic session', { roverId, ownerSocketId, reason });
try {
ensureWorker(roverId);
startSilenceWriter(roverId);
} catch (err) {
setState(roverId, { state: 'error', source: 'mic-whip', error: err?.message || String(err), startedAt: null });
}
}
function stopOwnedAudioIfUnauthorized(roverId, ownerSocketId, reason = 'driver_change') {
if (!roverId || !ownerSocketId) return;
if (whipOwners.get(roverId) === ownerSocketId) {
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
if (!ownerIsDriver || !ownerCanDrive) {
stopWhipForRover(roverId, reason);
}
}
const worker = workers.get(roverId);
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== ownerSocketId) return;
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
if (ownerIsDriver && ownerCanDrive) return;
logger.info('Stopping upload audio due to ownership/driver change', { roverId, ownerSocketId, reason });
startSilenceWriter(roverId);
}
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId) return;
if (action === 'removed') {
stopWorker(roverId);
return;
}
if (action === 'upsert' && serviceEnabled) {
if (whipOwners.has(roverId)) {
return;
}
try {
ensureWorker(roverId);
} catch (err) {
setState(roverId, { state: 'error', source: 'init', error: err.message, startedAt: null });
}
}
});
roverManager.managerEvents.on('driver', ({ socketId, roverId, action } = {}) => {
if (!socketId || !roverId) return;
if (action === 'remove' || action === 'add') {
stopOwnedAudioIfUnauthorized(roverId, socketId, action);
}
});
turnService.turnEvents.on('activeDriver', ({ roverId } = {}) => {
if (!roverId) return;
const whipOwner = whipOwners.get(roverId);
if (whipOwner) {
stopOwnedAudioIfUnauthorized(roverId, whipOwner, 'turn_change');
}
const worker = workers.get(roverId);
if (!worker || worker.contentKind !== 'upload') return;
stopOwnedAudioIfUnauthorized(roverId, worker.activeOwnerSocketId, 'turn_change');
});
io.on('connection', (socket) => {
socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
try {
const roverId = String(payload?.roverId || '').trim();
ensureAudioForwardPermission(socket, roverId);
playUploadedAudio(roverId, payload, socket.id);
cb({ success: true, roverId });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
const worker = workers.get(normalized);
if (worker && worker.contentKind === 'upload' && worker.activeOwnerSocketId !== socket.id) {
throw new Error('Upload playback is owned by another session');
}
stopPlayback(normalized);
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:micWhipStart', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
stopWorker(normalized);
whipOwners.set(normalized, socket.id);
const pathId = resolveForwardPathId(normalized);
revokeWhipSessionForRover(normalized, socket.id);
const token = videoSessions.createSession(socket, { type: 'roverMic', id: pathId });
const whipUrl = buildWhipUrl(pathId);
setState(normalized, { state: 'starting', source: 'mic-whip', error: null, startedAt: Date.now() });
cb({ success: true, roverId: normalized, pathId, token, whipUrl });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:micWhipReady', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
if (whipOwners.get(normalized) !== socket.id) {
throw new Error('WHIP session not owned by this client');
}
setState(normalized, { state: 'playing', source: 'mic-whip', error: null, startedAt: Date.now() });
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:micWhipStop', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
if (whipOwners.get(normalized) && whipOwners.get(normalized) !== socket.id) {
throw new Error('Mic forwarding is owned by another session');
}
stopWhipForRover(normalized, 'client_stop');
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('disconnect', () => {
workers.forEach((worker, roverId) => {
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== socket.id) return;
logger.info('Stopping owned upload audio due to socket disconnect', { roverId, socketId: socket.id });
startSilenceWriter(roverId);
});
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
if (ownerSocketId !== socket.id) continue;
stopWhipForRover(roverId, 'socket_disconnect');
}
});
});
module.exports = {
getAudioForwardState,
audioForwardEvents,
};
@@ -0,0 +1,152 @@
// audio Forward Service hooks
// Purpose: Registers rover/turn lifecycle listeners and socket handlers for audio forwarding controls.
// Scope: Keeps runtime behavior unchanged by delegating to injected core operations.
function registerAudioForwardHooks(deps) {
const {
io,
roverManager,
turnService,
logger,
serviceEnabled,
workers,
whipOwners,
ensureWorker,
stopWorker,
setState,
stopOwnedAudioIfUnauthorized,
stopWhipForRover,
ensureAudioForwardPermission,
playUploadedAudio,
stopPlayback,
resolveForwardPathId,
revokeWhipSessionForRover,
buildWhipUrl,
videoSessions,
startSilenceWriter,
} = deps;
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId) return;
if (action === 'removed') {
stopWorker(roverId);
return;
}
if (action === 'upsert' && serviceEnabled) {
if (whipOwners.has(roverId)) {
return;
}
try {
ensureWorker(roverId);
} catch (err) {
setState(roverId, { state: 'error', source: 'init', error: err.message, startedAt: null });
}
}
});
roverManager.managerEvents.on('driver', ({ socketId, roverId, action } = {}) => {
if (!socketId || !roverId) return;
if (action === 'remove' || action === 'add') {
stopOwnedAudioIfUnauthorized(roverId, socketId, action);
}
});
turnService.turnEvents.on('activeDriver', ({ roverId } = {}) => {
if (!roverId) return;
const whipOwner = whipOwners.get(roverId);
if (whipOwner) {
stopOwnedAudioIfUnauthorized(roverId, whipOwner, 'turn_change');
}
const worker = workers.get(roverId);
if (!worker || worker.contentKind !== 'upload') return;
stopOwnedAudioIfUnauthorized(roverId, worker.activeOwnerSocketId, 'turn_change');
});
io.on('connection', (socket) => {
socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
try {
const roverId = String(payload?.roverId || '').trim();
ensureAudioForwardPermission(socket, roverId);
playUploadedAudio(roverId, payload, socket.id);
cb({ success: true, roverId });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
const worker = workers.get(normalized);
if (worker && worker.contentKind === 'upload' && worker.activeOwnerSocketId !== socket.id) {
throw new Error('Upload playback is owned by another session');
}
stopPlayback(normalized);
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:micWhipStart', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
stopWorker(normalized);
whipOwners.set(normalized, socket.id);
const pathId = resolveForwardPathId(normalized);
revokeWhipSessionForRover(normalized, socket.id);
const token = videoSessions.createSession(socket, { type: 'roverMic', id: pathId });
const whipUrl = buildWhipUrl(pathId);
setState(normalized, { state: 'starting', source: 'mic-whip', error: null, startedAt: Date.now() });
cb({ success: true, roverId: normalized, pathId, token, whipUrl });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:micWhipReady', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
if (whipOwners.get(normalized) !== socket.id) {
throw new Error('WHIP session not owned by this client');
}
setState(normalized, { state: 'playing', source: 'mic-whip', error: null, startedAt: Date.now() });
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:micWhipStop', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
if (whipOwners.get(normalized) && whipOwners.get(normalized) !== socket.id) {
throw new Error('Mic forwarding is owned by another session');
}
stopWhipForRover(normalized, 'client_stop');
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('disconnect', () => {
workers.forEach((worker, roverId) => {
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== socket.id) return;
logger.info('Stopping owned upload audio due to socket disconnect', { roverId, socketId: socket.id });
startSilenceWriter(roverId);
});
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
if (ownerSocketId !== socket.id) continue;
stopWhipForRover(roverId, 'socket_disconnect');
}
});
});
}
module.exports = {
registerAudioForwardHooks,
};
@@ -0,0 +1,131 @@
// audio Forward Service
// Purpose: Composes audio-forward policy, worker engine, and hook wiring into the public service API.
// Scope: Keeps runtime behavior unchanged while making this entrypoint a thin orchestration layer.
const path = require('path');
const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioForwardService');
const { loadConfig } = require('../../helpers/configLoader');
const roverManager = require('../roverManager');
const turnService = require('../turnService');
const { isVerified } = require('../verificationService');
const videoSessions = require('../videoSessions');
const { createAudioForwardPolicy } = require('./policy');
const { createAudioForwardWorkerEngine } = require('./workerEngine');
const { registerAudioForwardHooks } = require('./hooks');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
const audioForwardConfig = config.audioForward || {};
const mediaConfig = config.media || {};
const serviceEnabled = audioForwardConfig.enabled !== false;
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
const streamSuffix =
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
? audioForwardConfig.streamSuffix.trim()
: '-fwd';
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
const uploadsDir = path.join(runtimeDir, 'uploads');
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
: 8 * 1024 * 1024;
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
const workers = new Map(); // roverId -> worker
const whipOwners = new Map(); // roverId -> socketId
function publishStateChange(roverId) {
audioForwardEvents.emit('change', { roverId, state: states.get(roverId) || null });
}
function setState(roverId, next = {}) {
const prev = states.get(roverId) || {};
const merged = {
state: next.state || prev.state || 'idle',
source: Object.prototype.hasOwnProperty.call(next, 'source') ? next.source : prev.source || 'silence',
error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null,
startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null,
updatedAt: Date.now(),
};
states.set(roverId, merged);
publishStateChange(roverId);
}
function getAudioForwardState() {
const payload = {};
states.forEach((entry, roverId) => {
payload[roverId] = { ...entry };
});
return payload;
}
const audioForwardPolicy = createAudioForwardPolicy({
isVerified,
roverManager,
turnService,
streamSuffix,
mediaConfig,
});
const {
ensureAudioForwardPermission,
resolveForwardUrl,
resolveForwardPathId,
buildWhipUrl,
} = audioForwardPolicy;
const workerEngine = createAudioForwardWorkerEngine({
logger,
io,
roverManager,
turnService,
videoSessions,
serviceEnabled,
ffmpegBin,
runtimeDir,
uploadsDir,
maxUploadBytes,
workers,
whipOwners,
setState,
resolveForwardUrl,
resolveForwardPathId,
});
const {
ensureWorker,
stopWorker,
playUploadedAudio,
stopPlayback,
revokeWhipSessionForRover,
stopWhipForRover,
stopOwnedAudioIfUnauthorized,
startSilenceWriter,
} = workerEngine;
registerAudioForwardHooks({
io,
roverManager,
turnService,
logger,
serviceEnabled,
workers,
whipOwners,
ensureWorker,
stopWorker,
setState,
stopOwnedAudioIfUnauthorized,
stopWhipForRover,
ensureAudioForwardPermission,
playUploadedAudio,
stopPlayback,
resolveForwardPathId,
revokeWhipSessionForRover,
buildWhipUrl,
videoSessions,
startSilenceWriter,
});
module.exports = {
getAudioForwardState,
audioForwardEvents,
};
@@ -0,0 +1,81 @@
// audio Forward Service policy
// Purpose: Encapsulates permission checks and media path/url derivation helpers.
// Scope: Keeps runtime behavior unchanged while isolating validation and path-construction logic.
function createAudioForwardPolicy(deps) {
const {
isVerified,
roverManager,
turnService,
streamSuffix,
mediaConfig,
} = deps;
function ensureVipVerified(socket) {
if (!isVerified(socket)) {
throw new Error('VIP verification required');
}
}
function ensureAudioForwardPermission(socket, roverId) {
ensureVipVerified(socket);
if (!roverManager.isDriver(roverId, socket)) {
throw new Error('Audio forwarding is only allowed on your own rover');
}
if (!turnService.canDrive(roverId, socket)) {
throw new Error('Only the current driver can play audio');
}
}
function forcePublishStreamMode(rawUrl) {
const value = String(rawUrl || '').trim();
if (!value) return '';
if (!/[?&]streamid=#!::/.test(value)) return value;
if (/,m=publish\b/.test(value)) return value;
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
}
function resolveForwardUrl(roverId) {
const record = roverManager.rovers.get(roverId);
const configured = record?.meta?.media?.audioForwardUrl;
if (configured) return forcePublishStreamMode(configured);
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
roverId + streamSuffix,
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
}
function resolveForwardPathId(roverId) {
return `${roverId}${streamSuffix}`;
}
function getMediaPrefix() {
const base = mediaConfig.whepBaseUrl;
if (!base) return '';
try {
const parsed = new URL(base);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
} catch {
return String(base).replace(/\/+$/, '');
}
}
function buildWhipUrl(pathId) {
const prefix = getMediaPrefix();
if (!prefix) {
throw new Error('Server media base URL missing');
}
return `${prefix}/${encodeURIComponent(pathId)}/whip`;
}
return {
ensureVipVerified,
ensureAudioForwardPermission,
resolveForwardUrl,
resolveForwardPathId,
buildWhipUrl,
};
}
module.exports = {
createAudioForwardPolicy,
};
@@ -0,0 +1,464 @@
// audio Forward Service worker engine
// Purpose: Owns ffmpeg worker lifecycle, upload playback, and WHIP/session ownership transitions.
// Scope: Keeps runtime behavior unchanged while isolating process/file-pipeline logic from service wiring.
const fs = require('fs');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
function createAudioForwardWorkerEngine(deps) {
const {
logger,
io,
roverManager,
turnService,
videoSessions,
serviceEnabled,
ffmpegBin,
runtimeDir,
uploadsDir,
maxUploadBytes,
workers,
whipOwners,
setState,
resolveForwardUrl,
resolveForwardPathId,
} = deps;
function ensureRuntimeDir() {
fs.mkdirSync(runtimeDir, { recursive: true });
fs.mkdirSync(uploadsDir, { recursive: true });
}
function sanitizeRoverId(roverId) {
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
}
function sanitizeFileStem(name) {
return String(name || 'upload')
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
}
function extFromUpload(name, mime) {
const lowerName = String(name || '').toLowerCase();
const lowerMime = String(mime || '').toLowerCase();
if (lowerName.endsWith('.mp3') || lowerMime === 'audio/mpeg' || lowerMime === 'audio/mp3') return '.mp3';
if (lowerName.endsWith('.wav') || lowerMime === 'audio/wav' || lowerMime === 'audio/x-wav') return '.wav';
if (lowerName.endsWith('.ogg') || lowerMime === 'audio/ogg') return '.ogg';
throw new Error('Unsupported upload format (allowed: mp3, wav, ogg)');
}
function ensureFifo(fifoPath) {
try {
const stat = fs.statSync(fifoPath);
if (stat.isFIFO()) return;
fs.unlinkSync(fifoPath);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
const result = spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' });
if (result.status !== 0) {
throw new Error(`mkfifo failed: ${result.stderr || result.stdout || 'unknown error'}`);
}
}
function spawnFfmpeg(roverId, tag, args, options = {}) {
const proc = spawn(ffmpegBin, args, {
stdio: [options.captureStdin ? 'pipe' : 'ignore', options.captureStdout ? 'pipe' : 'ignore', 'pipe'],
});
proc.stderr?.on('data', (chunk) => {
const text = String(chunk || '').trim();
if (!text) return;
logger.warn(`${tag} stderr`, { roverId, text });
});
proc.on('error', (err) => {
logger.warn(`${tag} spawn error`, { roverId, message: err?.message || String(err) });
});
return proc;
}
function stopProc(proc, graceMs = 1200) {
if (!proc || proc.killed) return;
try {
proc.kill('SIGTERM');
} catch {
return;
}
setTimeout(() => {
if (!proc.killed) {
try {
proc.kill('SIGKILL');
} catch {
// noop
}
}
}, graceMs);
}
function buildPublisherArgs(fifoPath, outputUrl) {
return [
'-hide_banner',
'-loglevel',
'warning',
'-f',
's16le',
'-ar',
'16000',
'-ac',
'1',
'-i',
fifoPath,
'-c:a',
'libopus',
'-b:a',
'24000',
'-ar:a',
'16000',
'-ac:a',
'1',
'-application',
'lowdelay',
'-frame_duration',
'10',
'-compression_level',
'0',
'-fflags',
'nobuffer',
'-flush_packets',
'1',
'-muxdelay',
'0',
'-muxpreload',
'0',
'-f',
'mpegts',
outputUrl,
];
}
function buildSilenceWriterArgs() {
return [
'-hide_banner',
'-loglevel',
'warning',
'-re',
'-f',
'lavfi',
'-i',
'anullsrc=channel_layout=mono:sample_rate=16000',
'-f',
's16le',
'-ac',
'1',
'-ar',
'16000',
'pipe:1',
];
}
function buildUploadWriterArgs(filePath) {
return [
'-hide_banner',
'-loglevel',
'warning',
'-re',
'-i',
filePath,
'-vn',
'-af',
'aresample=16000',
'-f',
's16le',
'-ac',
'1',
'-ar',
'16000',
'pipe:1',
];
}
function attachWriterPipe(worker, proc) {
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
writer.on('error', (err) => {
const code = err?.code || 'unknown';
if (code !== 'EPIPE') {
logger.warn('writer pipe error', { roverId: worker?.roverId, code, message: err?.message || String(err) });
}
});
proc.stdout.on('error', (err) => {
logger.warn('writer stdout error', {
roverId: worker?.roverId,
code: err?.code || 'unknown',
message: err?.message || String(err),
});
});
proc.stdout.pipe(writer);
proc.on('exit', () => {
writer.destroy();
});
}
function cleanupUploadFile(worker) {
if (!worker?.activeUploadPath) return;
try {
fs.unlinkSync(worker.activeUploadPath);
} catch {
// noop
}
worker.activeUploadPath = null;
}
function stopContentProc(worker) {
if (!worker) return;
if (worker.contentProc) {
stopProc(worker.contentProc);
}
worker.contentProc = null;
worker.contentKind = null;
}
function startSilenceWriter(roverId) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
stopContentProc(worker);
cleanupUploadFile(worker);
worker.activeOwnerSocketId = null;
const proc = spawnFfmpeg(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
worker.contentProc = proc;
worker.contentKind = 'silence';
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
proc.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.stopping) return;
if (current.writerSeq !== seq || current.contentProc !== proc) return;
current.contentProc = null;
current.contentKind = null;
if (code === 0 || signal === 'SIGTERM') return;
setState(roverId, {
state: 'error',
source: 'silence',
error: `silence writer exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
setTimeout(() => {
if (workers.has(roverId)) startSilenceWriter(roverId);
}, 300);
});
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
}
function startUploadWriter(roverId, filePath, ownerSocketId) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
stopContentProc(worker);
cleanupUploadFile(worker);
worker.activeUploadPath = filePath;
worker.activeOwnerSocketId = ownerSocketId || null;
const proc = spawnFfmpeg(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
worker.contentProc = proc;
worker.contentKind = 'upload';
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
proc.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.stopping) return;
if (current.writerSeq !== seq || current.contentProc !== proc) return;
current.contentProc = null;
current.contentKind = null;
if (code != null && code !== 0 && signal !== 'SIGTERM') {
setState(roverId, {
state: 'error',
source: 'upload',
error: `upload writer exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
}
startSilenceWriter(roverId);
});
}
function ensureWorker(roverId) {
if (!serviceEnabled) throw new Error('Audio forward disabled');
if (!roverId) throw new Error('roverId required');
const record = roverManager.rovers.get(roverId);
if (!record || !record.ws) throw new Error('Rover offline');
if (workers.has(roverId)) return workers.get(roverId);
ensureRuntimeDir();
const fifoPath = path.join(runtimeDir, `${sanitizeRoverId(roverId)}.pcm`);
ensureFifo(fifoPath);
const outputUrl = resolveForwardUrl(roverId);
const keepaliveFd = fs.openSync(fifoPath, 'r+');
const publisher = spawnFfmpeg(roverId, 'publisher', buildPublisherArgs(fifoPath, outputUrl));
const worker = {
roverId,
fifoPath,
keepaliveFd,
outputUrl,
publisherProc: publisher,
contentProc: null,
contentKind: null,
writerSeq: 0,
activeOwnerSocketId: null,
activeUploadPath: null,
stopping: false,
};
workers.set(roverId, worker);
publisher.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.publisherProc !== publisher || current.stopping) return;
setState(roverId, {
state: 'error',
source: current.contentKind || 'publish',
error: `publisher exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
});
startSilenceWriter(roverId);
logger.info('Audio forward worker ready', { roverId, outputUrl, fifoPath });
return worker;
}
function revokeWhipSessionForRover(roverId, ownerSocketId) {
if (!roverId || !ownerSocketId) return;
const pathId = resolveForwardPathId(roverId);
videoSessions.revokeWhere(
(info) => info?.socketId === ownerSocketId && info?.sourceType === 'roverMic' && info?.sourceId === pathId,
);
}
function stopWorker(roverId) {
const whipOwner = whipOwners.get(roverId);
if (whipOwner) {
whipOwners.delete(roverId);
revokeWhipSessionForRover(roverId, whipOwner);
}
const worker = workers.get(roverId);
if (!worker) return;
worker.stopping = true;
stopContentProc(worker);
cleanupUploadFile(worker);
stopProc(worker.publisherProc);
try {
fs.closeSync(worker.keepaliveFd);
} catch {
// noop
}
try {
fs.unlinkSync(worker.fifoPath);
} catch {
// noop
}
workers.delete(roverId);
setState(roverId, { state: 'offline', source: 'none', error: null, startedAt: null });
}
function writeUploadFile(roverId, payload = {}) {
const { name, mime, dataBase64 } = payload || {};
const ext = extFromUpload(name, mime);
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
if (!encoded) throw new Error('Upload payload missing');
const bytes = Buffer.from(encoded, 'base64');
if (!bytes.length) throw new Error('Upload decode failed');
if (bytes.length > maxUploadBytes) throw new Error(`Upload too large (max ${maxUploadBytes} bytes)`);
ensureRuntimeDir();
const stem = sanitizeFileStem(name || `upload-${Date.now()}`);
const filePath = path.join(uploadsDir, `${sanitizeRoverId(roverId)}-${Date.now()}-${stem}${ext}`);
fs.writeFileSync(filePath, bytes);
return filePath;
}
function stopWhipForRover(roverId, reason = 'unknown') {
const ownerSocketId = whipOwners.get(roverId);
if (!ownerSocketId) return;
whipOwners.delete(roverId);
revokeWhipSessionForRover(roverId, ownerSocketId);
logger.info('Stopping WHIP mic session', { roverId, ownerSocketId, reason });
try {
ensureWorker(roverId);
startSilenceWriter(roverId);
} catch (err) {
setState(roverId, {
state: 'error',
source: 'mic-whip',
error: err?.message || String(err),
startedAt: null,
});
}
}
function playUploadedAudio(roverId, payload = {}, ownerSocketId = null) {
stopWhipForRover(roverId, 'upload_override');
ensureWorker(roverId);
const uploadPath = writeUploadFile(roverId, payload);
startUploadWriter(roverId, uploadPath, ownerSocketId);
}
function stopPlayback(roverId) {
stopWhipForRover(roverId, 'stop_playback');
ensureWorker(roverId);
startSilenceWriter(roverId);
}
function stopOwnedAudioIfUnauthorized(roverId, ownerSocketId, reason = 'driver_change') {
if (!roverId || !ownerSocketId) return;
if (whipOwners.get(roverId) === ownerSocketId) {
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
if (!ownerIsDriver || !ownerCanDrive) {
stopWhipForRover(roverId, reason);
}
}
const worker = workers.get(roverId);
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== ownerSocketId) return;
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
if (ownerIsDriver && ownerCanDrive) return;
logger.info('Stopping upload audio due to ownership/driver change', { roverId, ownerSocketId, reason });
startSilenceWriter(roverId);
}
return {
ensureWorker,
stopWorker,
playUploadedAudio,
stopPlayback,
revokeWhipSessionForRover,
stopWhipForRover,
stopOwnedAudioIfUnauthorized,
startSilenceWriter,
};
}
module.exports = {
createAudioForwardWorkerEngine,
};
@@ -1,16 +1,19 @@
// audio Levels Service
// Purpose: Defines the audio Levels Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const EventEmitter = require('events'); const EventEmitter = require('events');
const io = require('../globals/io'); const io = require('../../globals/io');
const logger = require('../globals/logger').child('audioLevelsService'); const logger = require('../../globals/logger').child('audioLevelsService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { isAdmin } = require('./roleService'); const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const roverManager = require('./roverManager'); const { isAdmin } = require('../roleService');
const { issueCommand } = require('./commandService'); const roverManager = require('../roverManager');
const { issueCommand } = require('../commandService');
const audioLevelsEvents = new EventEmitter(); const audioLevelsEvents = new EventEmitter();
const DATA_DIR = path.join(__dirname, '..', '..', 'data'); const DATA_DIR = resolveDataDir();
const STORE_PATH = path.join(DATA_DIR, 'audio-levels.json'); const STORE_PATH = resolveDataPath('audio-levels.json');
const config = loadConfig(); const config = loadConfig();
const configuredDefaults = config.audioLevels || {}; const configuredDefaults = config.audioLevels || {};
@@ -1,10 +1,12 @@
// auth Service
// Purpose: Defines the auth Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const io = require('../globals/io'); const io = require('../../globals/io');
const logger = require('../globals/logger').child('authService'); const logger = require('../../globals/logger').child('authService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { clearLockdownTimer } = require('./lockdownGuard'); const { getMode, MODES } = require('../modeManager');
const { getMode, MODES } = require('./modeManager'); const { setRole } = require('../roleService');
const { setRole } = require('./roleService');
const config = loadConfig(); const config = loadConfig();
const admins = config.admins || []; const admins = config.admins || [];
@@ -38,7 +40,6 @@ io.on('connection', (socket) => {
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user'; const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
setRole(socket, initialRole); setRole(socket, initialRole);
logger.info('Socket connected with role', socket.id, initialRole); logger.info('Socket connected with role', socket.id, initialRole);
socket.emit('auth:role', { role: initialRole });
socket.on('auth:login', async ({ username, password }, cb = () => {}) => { socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
try { try {
const admin = await authenticate(username, password); const admin = await authenticate(username, password);
@@ -48,8 +49,6 @@ io.on('connection', (socket) => {
const role = admin.lockdown ? 'lockdown' : 'admin'; const role = admin.lockdown ? 'lockdown' : 'admin';
socket.data.user = { username: admin.username, discordId: admin.discord_id }; socket.data.user = { username: admin.username, discordId: admin.discord_id };
setRole(socket, role); setRole(socket, role);
socket.emit('auth:role', { role });
clearLockdownTimer(socket);
logger.info('Login success', socket.id, role); logger.info('Login success', socket.id, role);
cb({ success: true, role: socket.data.role }); cb({ success: true, role: socket.data.role });
} catch (err) { } catch (err) {
@@ -61,7 +60,6 @@ io.on('connection', (socket) => {
function handleRoleChange({ role } = {}, cb = () => {}) { function handleRoleChange({ role } = {}, cb = () => {}) {
if (role === 'spectator' || role === 'user') { if (role === 'spectator' || role === 'user') {
setRole(socket, role); setRole(socket, role);
socket.emit('auth:role', { role });
logger.info('Role changed via client request', socket.id, role); logger.info('Role changed via client request', socket.id, role);
cb({ success: true, role }); cb({ success: true, role });
} else { } else {
@@ -69,12 +67,7 @@ io.on('connection', (socket) => {
} }
} }
socket.on('role:set', handleRoleChange);
socket.on('session:setRole', handleRoleChange); socket.on('session:setRole', handleRoleChange);
}); });
module.exports = { module.exports = {};
isAdmin,
isLockdownAdmin,
authenticate,
};
@@ -1,6 +1,9 @@
const logger = require('../globals/logger').child('batteryManager'); // battery Manager
const { publishEvent } = require('./eventBus'); // Purpose: Defines the battery Manager module and the helpers/state used by this service unit.
const { managerEvents, lockRover, rovers } = require('./roverManager'); // Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const logger = require('../../globals/logger').child('batteryManager');
const { publishEvent } = require('../eventBus');
const { managerEvents, lockRover, rovers } = require('../roverManager');
const STATES = { const STATES = {
NORMAL: 'normal', NORMAL: 'normal',
@@ -0,0 +1,201 @@
// button Box Service core
// Purpose: Handles reward execution, effect persistence/recovery, and press application workflows.
// Scope: Keeps runtime behavior unchanged while isolating domain logic from HTTP transport wiring.
function createButtonBoxCore(deps) {
const {
io,
logger,
getRewardById,
roverManager,
issueCommand,
sendAlert,
publishEvent,
sendExternalTyping,
sendExternalMessage,
sendSystemMessage,
getMode,
setMode,
getAdminReason,
setAdminReason,
clearAdminReason,
assignmentService,
getHomeAssistantState,
setHomeAssistantEntityState,
setHomeAssistantLightsLockedOn,
store,
} = deps;
function publishUpdated() {
publishEvent({
source: 'buttonBox',
type: 'buttonBox.updated',
payload: { updatedAt: Date.now() },
});
}
function saveEffect(effectId, payload = {}, options = {}) {
const state = store.getState();
state.effects = state.effects && typeof state.effects === 'object' ? state.effects : {};
state.effects[effectId] = payload;
store.writeState();
if (options.broadcast) {
publishUpdated();
}
}
function clearEffect(effectId, options = {}) {
const state = store.getState();
if (state.effects && typeof state.effects === 'object' && state.effects[effectId]) {
delete state.effects[effectId];
store.writeState();
if (options.broadcast) {
publishUpdated();
}
}
}
function buildRewardContext() {
return {
logger,
issueCommand,
listOnlineRovers: () => roverManager.getRoster(),
sendAlert,
publishEvent,
sendExternalTyping,
sendExternalMessage,
sendSystemMessage,
getMode,
setMode: (mode, source = 'buttonbox') =>
setMode(
mode,
{ data: { role: 'admin', user: { username: source } } },
{ force: true },
),
getAdminReasonText: () => getAdminReason()?.text || null,
setAdminReason: (text, source = 'buttonbox') => setAdminReason(text, { by: source }),
clearAdminReason: (source = 'buttonbox') => clearAdminReason({ by: source }),
rerollAssignments: () => assignmentService.rerollAssignments(),
getHomeAssistantEntities: () => {
const entities = getHomeAssistantState()?.entities;
return Array.isArray(entities) ? entities : [];
},
getHomeAssistantLightPolicy: () => getHomeAssistantState()?.lightPolicy || null,
setHomeAssistantEntityState: (entityId, state) =>
setHomeAssistantEntityState(entityId, state, { source: 'buttonBoxReward' }),
setHomeAssistantLightsLockedOn: (next, options = {}) =>
setHomeAssistantLightsLockedOn(next, options),
saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }),
clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }),
};
}
async function runRewardForButton(button) {
const reward = getRewardById(button.rewardId);
if (!reward) {
store.assignNewReward(button);
return;
}
const completedCount = Number.isFinite(button.count) ? button.count : 0;
const completedGoal = Number.isFinite(button.goal) ? button.goal : 0;
const ctx = buildRewardContext();
try {
await reward.run(ctx);
} catch (err) {
logger.warn('Reward execution failed', { rewardId: reward.id, error: err.message });
}
io.emit('buttonBox:rewardRun', {
buttonId: button.id,
rewardId: reward.id,
rewardName: reward.name || reward.id,
count: completedCount,
goal: completedGoal,
ts: Date.now(),
});
button.lastRewardAt = Date.now();
button.count = 0;
store.assignNewReward(button, { excludeRewardId: reward.id });
}
async function applyPress(buttonId) {
const state = store.getState();
const button = state.buttons.find((entry) => entry.id === buttonId);
if (!button) {
throw new Error('Unknown button');
}
button.count += 1;
button.lastIncrementAt = Date.now();
store.writeState();
io.emit('buttonBox:increment', {
buttonId,
count: button.count,
ts: button.lastIncrementAt,
});
if (button.count >= button.goal) {
await runRewardForButton(button);
store.writeState();
}
publishUpdated();
return store.clone(button);
}
async function addCount(buttonId, amount = 1) {
const state = store.getState();
const button = state.buttons.find((entry) => entry.id === buttonId);
if (!button) {
throw new Error('Unknown button');
}
const inc = Math.max(1, Math.floor(Number(amount) || 0));
button.count += inc;
button.lastIncrementAt = Date.now();
store.writeState();
io.emit('buttonBox:increment', {
buttonId,
count: button.count,
ts: button.lastIncrementAt,
});
while (button.count >= button.goal) {
await runRewardForButton(button);
store.writeState();
}
publishUpdated();
return store.clone(button);
}
async function recoverEffects() {
const state = store.getState();
const effects = state.effects && typeof state.effects === 'object' ? { ...state.effects } : {};
const ctx = buildRewardContext();
for (const [effectId, payload] of Object.entries(effects)) {
const reward = getRewardById(effectId);
if (!reward || typeof reward.recover !== 'function') {
clearEffect(effectId, { broadcast: false });
continue;
}
try {
await reward.recover(ctx, payload);
} catch (err) {
logger.warn('Effect recovery failed', { effectId, error: err.message });
clearEffect(effectId, { broadcast: false });
}
}
publishUpdated();
}
return {
applyPress,
addCount,
recoverEffects,
};
}
module.exports = {
createButtonBoxCore,
};
@@ -0,0 +1,52 @@
// button Box Service HTTP route
// Purpose: Registers button-box press endpoint and request validation against local-network policy.
// Scope: Keeps runtime behavior unchanged while isolating transport/validation from domain logic.
const express = require('express');
function registerButtonBoxRoute(deps) {
const {
app,
logger,
buttonCount,
getRequestIp,
normalizeIp,
isLocalNetwork,
applyPress,
} = deps;
function parseButtonId(body) {
if (typeof body !== 'string') return null;
const value = Number.parseInt(body.trim(), 10);
if (Number.isFinite(value)) return value;
return null;
}
function denyIfNotLocal(req, res) {
const ip = normalizeIp(getRequestIp(req));
if (isLocalNetwork(ip)) {
return false;
}
logger.warn('Rejected non-local button press request', { ip: ip || null });
res.status(403).json({ error: 'Button presses must originate from local network' });
return true;
}
app.post('/buttonbox/press', express.text({ type: 'text/plain' }), async (req, res) => {
if (denyIfNotLocal(req, res)) return;
const buttonId = parseButtonId(req.body);
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > buttonCount) {
res.status(400).json({ error: 'button must be 1-4' });
return;
}
try {
const button = await applyPress(buttonId);
res.json({ success: true, button });
} catch (err) {
res.status(500).json({ error: err.message || 'Button processing failed' });
}
});
}
module.exports = {
registerButtonBoxRoute,
};
@@ -0,0 +1,83 @@
// button Box Service
// Purpose: Composes button-box storage, core reward workflows, and HTTP route wiring.
// Scope: Keeps runtime behavior unchanged while making this entrypoint a thin composition layer.
const { app } = require('../../globals/http');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('buttonBoxService');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { publishEvent } = require('../eventBus');
const { getRewardById, listRewards } = require('../../rewards');
const roverManager = require('../roverManager');
const { issueCommand } = require('../commandService');
const { sendAlert } = require('../alertService');
const { sendExternalTyping, sendExternalMessage, sendSystemMessage } = require('../chatService');
const { setMode, getMode } = require('../modeManager');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const assignmentService = require('../assignmentService');
const {
getState: getHomeAssistantState,
setEntityState: setHomeAssistantEntityState,
setLightsLockedOn: setHomeAssistantLightsLockedOn,
} = require('../homeAssistantService');
const { getRequestIp, isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
const { createButtonBoxStore } = require('./store');
const { createButtonBoxCore } = require('./core');
const { registerButtonBoxRoute } = require('./httpRoute');
const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('buttonbox-state.json');
const BUTTON_COUNT = 4;
const STORE_VERSION = 1;
const store = createButtonBoxStore({
logger,
getRewardById,
listRewards,
dataDir: DATA_DIR,
storePath: STORE_PATH,
buttonCount: BUTTON_COUNT,
storeVersion: STORE_VERSION,
});
const core = createButtonBoxCore({
io,
logger,
getRewardById,
roverManager,
issueCommand,
sendAlert,
publishEvent,
sendExternalTyping,
sendExternalMessage,
sendSystemMessage,
getMode,
setMode,
getAdminReason,
setAdminReason,
clearAdminReason,
assignmentService,
getHomeAssistantState,
setHomeAssistantEntityState,
setHomeAssistantLightsLockedOn,
store,
});
registerButtonBoxRoute({
app,
logger,
buttonCount: BUTTON_COUNT,
getRequestIp,
normalizeIp,
isLocalNetwork,
applyPress: core.applyPress,
});
store.loadState();
core.recoverEffects().catch((err) => {
logger.warn('Button box effect recovery failed', err.message);
});
module.exports = {
getButtonBoxState: store.getStateClone,
addButtonBoxCount: core.addCount,
};
@@ -0,0 +1,172 @@
// button Box Service store
// Purpose: Owns persisted button-box state shape, normalization, and atomic file read/write operations.
// Scope: Keeps runtime behavior unchanged by isolating state persistence mechanics from reward/HTTP orchestration.
const fs = require('fs');
function createButtonBoxStore(deps) {
const {
logger,
getRewardById,
listRewards,
dataDir,
storePath,
buttonCount,
storeVersion,
} = deps;
let state = null;
function createDefaultButton(id) {
return {
id,
count: 0,
rewardId: null,
rewardName: null,
rewardNumber: null,
goal: null,
lastIncrementAt: null,
lastRewardAt: null,
};
}
function createDefaultState() {
return {
version: storeVersion,
updatedAt: Date.now(),
buttons: Array.from({ length: buttonCount }, (_, idx) => createDefaultButton(idx + 1)),
effects: {},
};
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function assignNewReward(button, options = {}) {
const allRewards = listRewards();
if (!allRewards.length) throw new Error('No rewards configured');
const excludeRewardId = typeof options.excludeRewardId === 'string' ? options.excludeRewardId : null;
const usedByOtherButtons = new Set(
state.buttons
.filter((entry) => entry.id !== button.id)
.map((entry) => entry.rewardId)
.filter((rewardId) => typeof rewardId === 'string' && rewardId.length > 0),
);
const uniqueCandidates = allRewards.filter(
(reward) => !usedByOtherButtons.has(reward.id) && (!excludeRewardId || reward.id !== excludeRewardId),
);
const fallbackUniqueCandidates = allRewards.filter((reward) => !usedByOtherButtons.has(reward.id));
const pool = uniqueCandidates.length
? uniqueCandidates
: fallbackUniqueCandidates.length
? fallbackUniqueCandidates
: allRewards.filter((reward) => !excludeRewardId || reward.id !== excludeRewardId);
const reward = pool[Math.floor(Math.random() * pool.length)] || null;
if (!reward) throw new Error('No rewards configured');
button.rewardId = reward.id;
button.rewardName = reward.name || null;
button.rewardNumber = reward.number;
button.goal = reward.goal;
button.count = 0;
}
function ensureRewardAssignments() {
if (!state) return;
const seen = new Set();
state.buttons.forEach((button) => {
const reward = getRewardById(button.rewardId);
if (reward && !seen.has(reward.id)) {
seen.add(reward.id);
button.rewardName = reward.name || null;
button.rewardNumber = reward.number;
button.goal = reward.goal;
if (!Number.isFinite(button.count) || button.count < 0) {
button.count = 0;
}
return;
}
assignNewReward(button);
if (button.rewardId) {
seen.add(button.rewardId);
}
});
}
function normalizeLoaded(raw = {}) {
const base = createDefaultState();
const sourceButtons = Array.isArray(raw.buttons) ? raw.buttons : [];
const buttons = base.buttons.map((button, idx) => {
const loaded = sourceButtons[idx] || {};
return {
...button,
count: Number.isFinite(loaded.count) ? Math.max(0, Math.floor(loaded.count)) : 0,
rewardId: typeof loaded.rewardId === 'string' ? loaded.rewardId : null,
rewardName: typeof loaded.rewardName === 'string' ? loaded.rewardName : null,
rewardNumber: Number.isFinite(loaded.rewardNumber) ? Math.floor(loaded.rewardNumber) : null,
goal: Number.isFinite(loaded.goal) ? Math.max(1, Math.floor(loaded.goal)) : null,
lastIncrementAt: Number.isFinite(loaded.lastIncrementAt) ? loaded.lastIncrementAt : null,
lastRewardAt: Number.isFinite(loaded.lastRewardAt) ? loaded.lastRewardAt : null,
};
});
const effects = raw.effects && typeof raw.effects === 'object' ? raw.effects : {};
return {
version: storeVersion,
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
buttons,
effects,
};
}
function writeState() {
fs.mkdirSync(dataDir, { recursive: true });
const next = {
...state,
updatedAt: Date.now(),
};
const tempPath = `${storePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, storePath);
state = next;
}
function loadState() {
if (state) return state;
try {
const raw = JSON.parse(fs.readFileSync(storePath, 'utf8'));
state = normalizeLoaded(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load button box store', err.message);
}
state = createDefaultState();
}
ensureRewardAssignments();
writeState();
return state;
}
function getState() {
loadState();
return state;
}
function getStateClone() {
return clone({ buttons: getState().buttons });
}
return {
loadState,
writeState,
getState,
getStateClone,
clone,
assignNewReward,
};
}
module.exports = {
createButtonBoxStore,
};
-589
View File
@@ -1,589 +0,0 @@
const { v4: uuidv4 } = require('uuid');
const { DataSet, RegExpMatcher, englishDataset, englishRecommendedTransformers } = require('obscenity');
const io = require('../globals/io');
const logger = require('../globals/logger').child('chatService');
const { publishEvent, subscribe } = require('./eventBus');
const { getRole } = require('./roleService');
const { getMode, MODES } = require('./modeManager');
const { describeAssignment } = require('./assignmentService');
const roverManager = require('./roverManager');
const { getNickname } = require('./nicknameService');
const { issueCommand } = require('./commandService');
const { getAdminReason } = require('./adminReasonService');
const RATE_LIMIT_WINDOW_MS = 8000;
const RATE_LIMIT_MAX = 5;
const rateBuckets = new Map(); // socketId -> [timestamps]
const MAX_HISTORY = 100;
const history = [];
// Words in this list are removed from the profanity dataset entirely.
const PROFANITY_ALLOWLIST = ['fuck', 'ass', 'shit'];
const normalizedProfanityAllowlist = new Set(PROFANITY_ALLOWLIST
.filter((term) => typeof term === 'string')
.map((term) => term.trim().toLowerCase())
.filter(Boolean));
const profanityDataset = new DataSet()
.addAll(englishDataset)
.removePhrasesIf((phrase) => normalizedProfanityAllowlist.has(phrase.metadata?.originalWord))
.build();
const profanityMatcher = new RegExpMatcher({
...profanityDataset,
...englishRecommendedTransformers,
whitelistedTerms: profanityDataset.whitelistedTerms,
});
const DUPLICATE_WINDOW_MS = 15000;
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
const typingBySocket = new Map(); // socketId -> boolean
const TYPING_START_NOTE = 72;
const TYPING_SEND_NOTE = 79;
const TYPING_NOTE_DURATION = 8;
const ACCESS_NOTICE_COOLDOWN_MS = 60000;
const ACCESS_KEYWORD_RE = /\b(drive|roomba)\b/i;
let lastAccessNoticeAt = 0;
function withinRateLimit(socketId) {
const now = Date.now();
const entries = rateBuckets.get(socketId) || [];
const next = entries.filter((ts) => now - ts <= RATE_LIMIT_WINDOW_MS);
next.push(now);
rateBuckets.set(socketId, next);
return next.length <= RATE_LIMIT_MAX;
}
function hasProfanity(text) {
if (typeof text !== 'string' || !text) return false;
return profanityMatcher.hasMatch(text);
}
function isDuplicate(socketId, text) {
const prev = lastMessageBySocket.get(socketId);
const now = Date.now();
if (!prev) {
lastMessageBySocket.set(socketId, { text, ts: now });
return false;
}
lastMessageBySocket.set(socketId, { text, ts: now });
return prev.text === text && now - prev.ts <= DUPLICATE_WINDOW_MS;
}
function isKeymash(text) {
if (!text) return false;
if (/(.)\1{6,}/.test(text)) return true; // same char 7+
if (/^[asdfghjkl;'\-=\[\]\\]{6,}$/i.test(text)) return true;
if (/^[qwertyuiop]{6,}$/i.test(text)) return true;
return false;
}
function resolveRoverId(socketId) {
const primary = roverManager.getPrimaryRoverForSocket(socketId);
if (primary) return primary;
const assignment = describeAssignment(socketId);
return assignment?.roverId || null;
}
function resolveRoverColor(roverId) {
if (!roverId) return null;
const record = roverManager.rovers.get(String(roverId));
return record?.meta?.color || null;
}
function isPrivateClosedRoverId(roverId) {
if (!roverId) return false;
return roverManager.canReplayRoverId(roverId) !== true;
}
function normalizeUserText(raw) {
if (typeof raw !== 'string') return '';
return raw.replace(/\\n/g, '\n');
}
function buildMessage(socket, text, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id);
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
return {
id: uuidv4(),
ts: Date.now(),
socketId: socket?.id || null,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
roverId,
roverColor,
fromDiscord: Boolean(meta.fromDiscord),
discordGuildId: meta.discordGuildId || null,
discordGuildName: meta.discordGuildName || null,
discordGuildIconUrl: meta.discordGuildIconUrl || null,
discordChannelId: meta.discordChannelId || null,
discordUserId: meta.discordUserId || null,
discordUserName: meta.discordUserName || null,
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
roverCtx: meta.roverCtx || null,
text,
tts: meta.tts || null,
system: Boolean(meta.system),
};
}
function isChargingFromSensors(sensors = {}) {
const label = String(sensors?.chargingState?.label || '').toLowerCase();
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') {
return true;
}
const code = sensors?.chargingState?.code;
return code === 2 || code === 3 || code === 4;
}
function buildRoverCtxSnapshot(roverId) {
if (!roverId) return null;
const key = String(roverId);
const record = roverManager.rovers.get(key);
if (!record) return null;
const sensors = record?.lastSensor?.decoded || {};
const batteryState = record?.batteryState || null;
const { getActiveDrivers } = require('./turnService');
const activeDrivers = getActiveDrivers();
const driverSocketId = activeDrivers[key] || record?.drivers?.values?.().next?.().value || null;
const charging = isChargingFromSensors(sensors);
const docked = Boolean(sensors?.chargingSources?.homeBase);
const wheelsOffGround = Boolean(
sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight,
);
const latestDistanceM = Math.round((Math.abs(Number(sensors?.distanceMm) || 0) / 1000) * 10) / 10;
const latestTurnDeg = Math.round(Math.abs(Number(sensors?.angleDeg) || 0));
const latestBumps =
(sensors?.bumpsAndWheelDrops?.bumpLeft ? 0.5 : 0) +
(sensors?.bumpsAndWheelDrops?.bumpRight ? 0.5 : 0);
const light = sensors?.lightBumper || {};
const contactState = docked
? 'clear'
: latestBumps >= 0.5
? 'bumps_recent'
: sensors?.wall ||
light.left ||
light.frontLeft ||
light.centerLeft ||
light.centerRight ||
light.frontRight ||
light.right
? 'wall_brush'
: 'clear';
const hazardState = docked
? 'normal'
: sensors?.virtualWall
? 'virtual_wall_seen'
: sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight
? 'cliff_alert'
: 'normal';
const mobilityState = wheelsOffGround ? 'wheels_off_ground' : 'normal';
const baseScore = Math.min(100, Math.round(Math.min(45, latestDistanceM * 25) + Math.min(30, latestTurnDeg / 12) + Math.min(25, latestBumps * 12)));
const activityScore = Math.max(
0,
Math.min(
100,
baseScore +
(contactState === 'wall_brush' ? 6 : 0) +
(contactState === 'bumps_recent' ? 12 : 0) +
(hazardState !== 'normal' ? 8 : 0) +
(wheelsOffGround ? -20 : 0),
),
);
const activityBand =
activityScore >= 75
? 'intense'
: activityScore >= 50
? 'high'
: activityScore >= 25
? 'medium'
: activityScore >= 8
? 'low'
: 'idle';
const moving = latestDistanceM > 0.05 || latestTurnDeg > 10;
let statusTag = 'idle';
if (charging) {
statusTag = 'charging';
} else if (docked) {
statusTag = 'docked';
} else if (driverSocketId && moving) {
statusTag = 'driving';
} else if (driverSocketId) {
statusTag = 'active-idle';
}
return {
id: key,
status_tag: statusTag,
battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive),
docked,
charging,
wheels_off_ground: wheelsOffGround,
contact_state: contactState,
hazard_state: hazardState,
mobility_state: mobilityState,
activity_score: activityScore,
activity_band: activityBand,
activity_trend: 'steady',
activity_30s: {
distance_m: latestDistanceM,
turn_deg: latestTurnDeg,
bumps: latestBumps,
},
};
}
function buildTypingPayload(socket, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id);
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
const socketId = socket?.id || null;
const fromDiscord = Boolean(meta.fromDiscord);
let typingId = meta.typingId || null;
if (!typingId) {
if (fromDiscord) {
if (meta.discordUserId) {
typingId = `discord:${meta.discordUserId}`;
} else if (meta.discordUserName) {
typingId = `discord:${meta.discordUserName}`;
} else if (meta.nickname) {
typingId = `discord:${meta.nickname}`;
} else {
typingId = 'discord:unknown';
}
} else if (socketId) {
typingId = `socket:${socketId}`;
} else if (meta.nickname) {
typingId = `socket:${meta.nickname}`;
} else {
typingId = 'socket:unknown';
}
}
return {
id: uuidv4(),
ts: Date.now(),
typingId,
isTyping: Boolean(meta.isTyping),
socketId,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
roverId,
roverColor,
fromDiscord,
discordGuildId: meta.discordGuildId || null,
discordGuildName: meta.discordGuildName || null,
discordGuildIconUrl: meta.discordGuildIconUrl || null,
discordChannelId: meta.discordChannelId || null,
discordUserId: meta.discordUserId || null,
discordUserName: meta.discordUserName || null,
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
};
}
function pushHistory(message) {
history.push(message);
if (history.length > MAX_HISTORY) {
history.shift();
}
}
function getRecentMessages(limit = 20, options = {}) {
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
const includeSystem = options?.includeSystem !== false;
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
return source.slice(-safeLimit);
}
function broadcastMessage(message) {
pushHistory(message);
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
}
function broadcastTyping(payload) {
publishEvent({ source: 'chat', type: 'chat:typing', payload });
}
function playTypingNote(roverId, note, socketId) {
if (!roverId) return;
try {
issueCommand(roverId, {
type: 'song',
song: {
notes: [{ note, duration: TYPING_NOTE_DURATION }],
},
});
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
log('Typing tone sent', { roverId, note, socketId });
} catch (err) {
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
log('Typing tone failed', { roverId, note, socketId, error: err.message });
}
}
function normalizeTtsOptions(raw = {}) {
if (!raw || typeof raw !== 'object') return null;
const speak = raw.speak !== false;
if (!speak) return null;
const engine = typeof raw.engine === 'string' && raw.engine.toLowerCase() === 'espeak' ? 'espeak' : 'flite';
const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
let pitch = Number.isFinite(raw.pitch) ? Math.round(raw.pitch) : undefined;
if (typeof pitch === 'number') {
pitch = Math.max(0, Math.min(99, pitch));
}
return { speak, engine, voice, pitch };
}
function buildAccessNoticeText(mode, reasonText) {
const label = mode === MODES.LOCKDOWN ? 'lockdown' : 'admin';
const reason = reasonText ? ` Reason: ${reasonText}` : '';
return `Heads up: the server is in ${label} mode.${reason}`;
}
function shouldSendAccessNotice(message) {
if (!message?.text || message.system) return false;
const mode = getMode();
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
const now = Date.now();
if (now - lastAccessNoticeAt < ACCESS_NOTICE_COOLDOWN_MS) return false;
lastAccessNoticeAt = now;
return true;
}
function sendSystemMessage(text) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) return null;
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
const message = buildMessage(null, safe, {
nickname: 'The Overseer',
role: 'user',
fromDiscord: false,
system: true,
});
broadcastMessage(message);
return message;
}
function maybeSendAccessNotice(message) {
if (!shouldSendAccessNotice(message)) return;
const reason = getAdminReason()?.text || '';
const mode = getMode();
const notice = buildAccessNoticeText(mode, reason);
sendSystemMessage(notice);
}
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
const role = getRole(socket);
// if (role === 'spectator') {
// cb({ error: 'Spectators cannot chat' });
// return;
// }
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) {
cb({ error: 'Message required' });
return;
}
if (!withinRateLimit(socket.id)) {
cb({ error: 'Slow down' });
return;
}
if (clean.length > 400) {
cb({ error: 'Message too long' });
return;
}
if (hasProfanity(clean)) {
cb({ error: 'Message blocked' });
return;
}
// if (isDuplicate(socket.id, clean)) {
// cb({ error: 'Duplicate message' });
// return;
// }
// if (isKeymash(clean)) {
// cb({ error: 'Message looks like spam' });
// return;
// }
const roverId = resolveRoverId(socket?.id);
const ttsOptions = normalizeTtsOptions(tts);
const message = buildMessage(socket, clean, {
fromDiscord: false,
roverId,
roverCtx: buildRoverCtxSnapshot(roverId),
tts: ttsOptions,
});
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
const privateClosed = isPrivateClosedRoverId(message.roverId);
if (privateClosed) {
const forcedTts = ttsOptions || { speak: true, engine: 'flite' };
maybeSpeak(socket, message, forcedTts);
cb({ success: true, privateOnly: true });
return;
}
broadcastMessage(message);
maybeSendAccessNotice(message);
maybeSpeak(socket, message, ttsOptions);
cb({ success: true });
}
function maybeSpeak(socket, message, ttsOptions) {
if (!ttsOptions || !message?.roverId) return;
const record = roverManager.rovers.get(message.roverId);
const audio = record?.meta?.audio || {};
const ttsEnabled = Boolean(audio.ttsEnabled);
if (!ttsEnabled) return;
const { isQueuedDriver } = require('./turnService');
if (
!roverManager.canDrive(message.roverId, socket) &&
!isQueuedDriver(message.roverId, socket?.id)
) {
return;
}
try {
issueCommand(message.roverId, {
type: 'tts',
tts: {
text: message.text,
engine: ttsOptions.engine,
voice: ttsOptions.voice,
pitch: ttsOptions.pitch,
speak: true,
},
});
logger.info('TTS sent', { roverId: message.roverId, engine: ttsOptions.engine, socket: socket.id });
} catch (err) {
logger.warn('TTS send failed', { roverId: message.roverId, error: err.message });
}
}
function sendExternalMessage({
text,
nickname = 'Discord',
role = 'admin',
roverId = null,
discordGuildId = null,
discordGuildName = null,
discordGuildIconUrl = null,
discordChannelId = null,
discordUserId = null,
discordUserName = null,
discordUserAvatarUrl = null,
}) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean || clean.length > 400) {
throw new Error('Message invalid');
}
if (hasProfanity(clean)) {
throw new Error('Message blocked');
}
if (isKeymash(clean)) {
throw new Error('Message looks like spam');
}
if (isPrivateClosedRoverId(roverId)) {
throw new Error('Private rover chat is closed');
}
const message = buildMessage(null, clean, {
nickname,
role,
roverId,
roverCtx: buildRoverCtxSnapshot(roverId),
fromDiscord: true,
discordGuildId,
discordGuildName,
discordGuildIconUrl,
discordChannelId,
discordUserId,
discordUserName,
discordUserAvatarUrl,
});
logger.info('External chat message', { roverId, nickname });
broadcastMessage(message);
maybeSendAccessNotice(message);
return message;
}
function sendExternalTyping({
nickname = 'Discord',
role = 'user',
roverId = null,
discordGuildId = null,
discordGuildName = null,
discordGuildIconUrl = null,
discordChannelId = null,
discordUserId = null,
discordUserName = null,
discordUserAvatarUrl = null,
isTyping = true,
}) {
if (isPrivateClosedRoverId(roverId)) {
return null;
}
const payload = buildTypingPayload(null, {
nickname,
role,
roverId,
fromDiscord: true,
discordGuildId,
discordGuildName,
discordGuildIconUrl,
discordChannelId,
discordUserId,
discordUserName,
discordUserAvatarUrl,
isTyping,
});
broadcastTyping(payload);
return payload;
}
io.on('connection', (socket) => {
socket.emit('chat:init', history);
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
socket.on('chat:typing', (payload = {}) => {
const isTyping = Boolean(payload?.isTyping);
const wasTyping = typingBySocket.get(socket.id);
if (isTyping) {
typingBySocket.set(socket.id, true);
if (!wasTyping) {
const roverId = resolveRoverId(socket?.id);
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
}
} else {
typingBySocket.delete(socket.id);
}
const roverId = resolveRoverId(socket?.id);
if (isPrivateClosedRoverId(roverId)) {
return;
}
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping });
broadcastTyping(typingPayload);
});
socket.on('disconnect', () => {
if (!typingBySocket.has(socket.id)) return;
typingBySocket.delete(socket.id);
const roverId = resolveRoverId(socket?.id);
if (isPrivateClosedRoverId(roverId)) {
return;
}
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
broadcastTyping(typingPayload);
});
});
subscribe('chat:message', ({ payload }) => {
if (!payload) return;
io.emit('chat:message', payload);
});
subscribe('chat:typing', ({ payload }) => {
if (!payload) return;
io.emit('chat:typing', payload);
});
module.exports = {
handleIncoming,
sendExternalMessage,
sendExternalTyping,
buildTypingPayload,
sendSystemMessage,
getRecentMessages,
};
@@ -0,0 +1,20 @@
// Chat Broadcast Pipeline
// Purpose: Publishes chat message and typing payloads to event bus while preserving in-memory history.
// Scope: Encapsulates chat fan-out side effects and recent-history accessors.
const { publishEvent } = require('../eventBus');
const { pushHistory, getRecentMessages } = require('./state');
function broadcastMessage(message) {
pushHistory(message);
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
}
function broadcastTyping(payload) {
publishEvent({ source: 'chat', type: 'chat:typing', payload });
}
module.exports = {
broadcastMessage,
broadcastTyping,
getRecentMessages,
};
@@ -0,0 +1,26 @@
// Chat Service Constants
// Purpose: Defines chat moderation, history, and UX timing constants used across chat modules.
// Scope: Centralizes immutable runtime tuning values for message and typing behavior.
const RATE_LIMIT_WINDOW_MS = 8000;
const RATE_LIMIT_MAX = 5;
const MAX_HISTORY = 100;
const PROFANITY_ALLOWLIST = ['fuck', 'ass', 'shit'];
const DUPLICATE_WINDOW_MS = 15000;
const TYPING_START_NOTE = 72;
const TYPING_SEND_NOTE = 79;
const TYPING_NOTE_DURATION = 8;
const ACCESS_NOTICE_COOLDOWN_MS = 60000;
const ACCESS_KEYWORD_RE = /\b(drive|roomba)\b/i;
module.exports = {
RATE_LIMIT_WINDOW_MS,
RATE_LIMIT_MAX,
MAX_HISTORY,
PROFANITY_ALLOWLIST,
DUPLICATE_WINDOW_MS,
TYPING_START_NOTE,
TYPING_SEND_NOTE,
TYPING_NOTE_DURATION,
ACCESS_NOTICE_COOLDOWN_MS,
ACCESS_KEYWORD_RE,
};
@@ -0,0 +1,60 @@
// Chat Content Filters
// Purpose: Provides profanity, spam, and text normalization helpers for incoming chat messages.
// Scope: Contains pure filtering logic and duplicate/keymash detectors.
const { DataSet, RegExpMatcher, englishDataset, englishRecommendedTransformers } = require('obscenity');
const { PROFANITY_ALLOWLIST, DUPLICATE_WINDOW_MS } = require('./constants');
const { lastMessageBySocket } = require('./state');
const normalizedProfanityAllowlist = new Set(
PROFANITY_ALLOWLIST
.filter((term) => typeof term === 'string')
.map((term) => term.trim().toLowerCase())
.filter(Boolean),
);
const profanityDataset = new DataSet()
.addAll(englishDataset)
.removePhrasesIf((phrase) => normalizedProfanityAllowlist.has(phrase.metadata?.originalWord))
.build();
const profanityMatcher = new RegExpMatcher({
...profanityDataset,
...englishRecommendedTransformers,
whitelistedTerms: profanityDataset.whitelistedTerms,
});
function hasProfanity(text) {
if (typeof text !== 'string' || !text) return false;
return profanityMatcher.hasMatch(text);
}
function isDuplicate(socketId, text) {
const prev = lastMessageBySocket.get(socketId);
const now = Date.now();
if (!prev) {
lastMessageBySocket.set(socketId, { text, ts: now });
return false;
}
lastMessageBySocket.set(socketId, { text, ts: now });
return prev.text === text && now - prev.ts <= DUPLICATE_WINDOW_MS;
}
function isKeymash(text) {
if (!text) return false;
if (/(.)\1{6,}/.test(text)) return true;
if (/^[asdfghjkl;'\-=\[\]\\]{6,}$/i.test(text)) return true;
if (/^[qwertyuiop]{6,}$/i.test(text)) return true;
return false;
}
function normalizeUserText(raw) {
if (typeof raw !== 'string') return '';
return raw.replace(/\\n/g, '\n');
}
module.exports = {
hasProfanity,
isDuplicate,
isKeymash,
normalizeUserText,
};
@@ -0,0 +1,157 @@
// Chat Context Builders
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
const { v4: uuidv4 } = require('uuid');
const io = require('../../globals/io');
const roverManager = require('../roverManager');
const { getRole } = require('../roleService');
const { describeAssignment } = require('../assignmentService');
const { getNickname } = require('../nicknameService');
function resolveRoverId(socketId) {
const primary = roverManager.getPrimaryRoverForSocket(socketId);
if (primary) return primary;
const assignment = describeAssignment(socketId);
return assignment?.roverId || null;
}
function resolveRoverColor(roverId) {
if (!roverId) return null;
const record = roverManager.rovers.get(String(roverId));
return record?.meta?.color || null;
}
function isPrivateClosedRoverId(roverId) {
if (!roverId) return false;
return roverManager.canReplayRoverId(roverId) !== true;
}
function isChargingFromSensors(sensors = {}) {
const label = String(sensors?.chargingState?.label || '').toLowerCase();
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true;
const code = sensors?.chargingState?.code;
return code === 2 || code === 3 || code === 4;
}
function buildRoverCtxSnapshot(roverId) {
if (!roverId) return null;
const key = String(roverId);
const record = roverManager.rovers.get(key);
if (!record) return null;
const sensors = record?.lastSensor?.decoded || {};
const batteryState = record?.batteryState || null;
const { getActiveDrivers } = require('../turnService');
const activeDrivers = getActiveDrivers();
const driverSocketId = activeDrivers[key] || record?.drivers?.values?.().next?.().value || null;
const charging = isChargingFromSensors(sensors);
const docked = Boolean(sensors?.chargingSources?.homeBase);
const wheelsOffGround = Boolean(sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight);
const latestDistanceM = Math.round((Math.abs(Number(sensors?.distanceMm) || 0) / 1000) * 10) / 10;
const latestTurnDeg = Math.round(Math.abs(Number(sensors?.angleDeg) || 0));
const latestBumps = (sensors?.bumpsAndWheelDrops?.bumpLeft ? 0.5 : 0) + (sensors?.bumpsAndWheelDrops?.bumpRight ? 0.5 : 0);
const light = sensors?.lightBumper || {};
const contactState = docked ? 'clear' : latestBumps >= 0.5 ? 'bumps_recent' : sensors?.wall || light.left || light.frontLeft || light.centerLeft || light.centerRight || light.frontRight || light.right ? 'wall_brush' : 'clear';
const hazardState = docked ? 'normal' : sensors?.virtualWall ? 'virtual_wall_seen' : sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight ? 'cliff_alert' : 'normal';
const mobilityState = wheelsOffGround ? 'wheels_off_ground' : 'normal';
const baseScore = Math.min(100, Math.round(Math.min(45, latestDistanceM * 25) + Math.min(30, latestTurnDeg / 12) + Math.min(25, latestBumps * 12)));
const activityScore = Math.max(0, Math.min(100, baseScore + (contactState === 'wall_brush' ? 6 : 0) + (contactState === 'bumps_recent' ? 12 : 0) + (hazardState !== 'normal' ? 8 : 0) + (wheelsOffGround ? -20 : 0)));
const activityBand = activityScore >= 75 ? 'intense' : activityScore >= 50 ? 'high' : activityScore >= 25 ? 'medium' : activityScore >= 8 ? 'low' : 'idle';
const moving = latestDistanceM > 0.05 || latestTurnDeg > 10;
let statusTag = 'idle';
if (charging) statusTag = 'charging';
else if (docked) statusTag = 'docked';
else if (driverSocketId && moving) statusTag = 'driving';
else if (driverSocketId) statusTag = 'active-idle';
return {
id: key,
status_tag: statusTag,
battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive),
docked,
charging,
wheels_off_ground: wheelsOffGround,
contact_state: contactState,
hazard_state: hazardState,
mobility_state: mobilityState,
activity_score: activityScore,
activity_band: activityBand,
activity_trend: 'steady',
activity_30s: {
distance_m: latestDistanceM,
turn_deg: latestTurnDeg,
bumps: latestBumps,
},
};
}
function buildMessage(socket, text, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id);
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
return {
id: uuidv4(),
ts: Date.now(),
socketId: socket?.id || null,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
roverId,
roverColor,
fromDiscord: Boolean(meta.fromDiscord),
discordGuildId: meta.discordGuildId || null,
discordGuildName: meta.discordGuildName || null,
discordGuildIconUrl: meta.discordGuildIconUrl || null,
discordChannelId: meta.discordChannelId || null,
discordUserId: meta.discordUserId || null,
discordUserName: meta.discordUserName || null,
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
roverCtx: meta.roverCtx || null,
text,
tts: meta.tts || null,
system: Boolean(meta.system),
bot: Boolean(meta.bot),
};
}
function buildTypingPayload(socket, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id);
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
const socketId = socket?.id || null;
const fromDiscord = Boolean(meta.fromDiscord);
let typingId = meta.typingId || null;
if (!typingId) {
if (fromDiscord) {
if (meta.discordUserId) typingId = `discord:${meta.discordUserId}`;
else if (meta.discordUserName) typingId = `discord:${meta.discordUserName}`;
else if (meta.nickname) typingId = `discord:${meta.nickname}`;
else typingId = 'discord:unknown';
} else if (socketId) typingId = `socket:${socketId}`;
else if (meta.nickname) typingId = `socket:${meta.nickname}`;
else typingId = 'socket:unknown';
}
return {
id: uuidv4(),
ts: Date.now(),
typingId,
isTyping: Boolean(meta.isTyping),
socketId,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
roverId,
roverColor,
fromDiscord,
discordGuildId: meta.discordGuildId || null,
discordGuildName: meta.discordGuildName || null,
discordGuildIconUrl: meta.discordGuildIconUrl || null,
discordChannelId: meta.discordChannelId || null,
discordUserId: meta.discordUserId || null,
discordUserName: meta.discordUserName || null,
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
};
}
module.exports = {
resolveRoverId,
isPrivateClosedRoverId,
buildRoverCtxSnapshot,
buildMessage,
buildTypingPayload,
};
@@ -0,0 +1,93 @@
// Chat Message Handlers
// Purpose: Handles inbound socket/external chat payloads and applies moderation, routing, and side effects.
// Scope: Owns message validation pipeline and typed outbound message construction.
const logger = require('../../globals/logger').child('chatService');
const { getRole } = require('../roleService');
const { withinRateLimit } = require('./state');
const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters');
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
const { broadcastMessage, broadcastTyping } = require('./broadcast');
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
function createHandlers({ sendSystemMessage }) {
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
const role = getRole(socket);
void role;
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) return cb({ error: 'Message required' });
if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' });
if (clean.length > 400) return cb({ error: 'Message too long' });
if (hasProfanity(clean)) return cb({ error: 'Message blocked' });
const roverId = resolveRoverId(socket?.id);
const ttsOptions = normalizeTtsOptions(tts);
const message = buildMessage(socket, clean, {
fromDiscord: false,
roverId,
roverCtx: buildRoverCtxSnapshot(roverId),
tts: ttsOptions,
});
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
if (isPrivateClosedRoverId(message.roverId)) {
const forcedTts = ttsOptions || { speak: true, engine: 'flite' };
maybeSpeak(socket, message, forcedTts);
cb({ success: true, privateOnly: true });
return;
}
broadcastMessage(message);
maybeSendAccessNotice(message, sendSystemMessage);
maybeSpeak(socket, message, ttsOptions);
cb({ success: true });
}
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null }) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean || clean.length > 400) throw new Error('Message invalid');
if (hasProfanity(clean)) throw new Error('Message blocked');
if (isKeymash(clean)) throw new Error('Message looks like spam');
if (isPrivateClosedRoverId(roverId)) throw new Error('Private rover chat is closed');
const message = buildMessage(null, clean, {
nickname,
role,
roverId,
roverCtx: buildRoverCtxSnapshot(roverId),
fromDiscord: true,
discordGuildId,
discordGuildName,
discordGuildIconUrl,
discordChannelId,
discordUserId,
discordUserName,
discordUserAvatarUrl,
});
logger.info('External chat message', { roverId, nickname });
broadcastMessage(message);
maybeSendAccessNotice(message, sendSystemMessage);
return message;
}
function sendExternalTyping({ nickname = 'Discord', role = 'user', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, isTyping = true }) {
if (isPrivateClosedRoverId(roverId)) return null;
const payload = buildTypingPayload(null, { nickname, role, roverId, fromDiscord: true, discordGuildId, discordGuildName, discordGuildIconUrl, discordChannelId, discordUserId, discordUserName, discordUserAvatarUrl, isTyping });
broadcastTyping(payload);
return payload;
}
return {
handleIncoming,
sendExternalMessage,
sendExternalTyping,
};
}
module.exports = {
createHandlers,
};
+38
View File
@@ -0,0 +1,38 @@
// Chat Service Orchestrator
// Purpose: Composes chat submodules into the public service API and boots socket/event wiring.
// Scope: Keeps external chat contracts stable while delegating logic to focused modules.
const { history } = require('./state');
const { normalizeUserText } = require('./contentFilters');
const { buildMessage, buildTypingPayload } = require('./contextBuilders');
const { broadcastMessage, getRecentMessages } = require('./broadcast');
const { createHandlers } = require('./handlers');
const { registerChatSocketHooks } = require('./socketHooks');
function sendSystemMessage(text, options = {}) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) return null;
const safe = clean;
const message = buildMessage(null, safe, {
nickname: String(options.nickname || 'The Overseer'),
role: 'user',
fromDiscord: false,
system: true,
bot: true,
});
broadcastMessage(message);
return message;
}
const { handleIncoming, sendExternalMessage, sendExternalTyping } = createHandlers({ sendSystemMessage });
registerChatSocketHooks({ history, handleIncoming });
module.exports = {
handleIncoming,
sendExternalMessage,
sendExternalTyping,
buildTypingPayload,
sendSystemMessage,
getRecentMessages,
};
@@ -0,0 +1,100 @@
// Chat Rover Notifications
// Purpose: Sends rover typing tones, optional rover TTS, and access-mode system notices.
// Scope: Owns rover-side notification effects derived from chat activity.
const logger = require('../../globals/logger').child('chatService');
const { getMode, MODES } = require('../modeManager');
const { getRole } = require('../roleService');
const roverManager = require('../roverManager');
const { issueCommand } = require('../commandService');
const { getAdminReason } = require('../adminReasonService');
const {
TYPING_NOTE_DURATION,
ACCESS_NOTICE_COOLDOWN_MS,
ACCESS_KEYWORD_RE,
TYPING_START_NOTE,
TYPING_SEND_NOTE,
} = require('./constants');
const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state');
function playTypingNote(roverId, note, socketId) {
if (!roverId) return;
try {
issueCommand(roverId, {
type: 'song',
song: { notes: [{ note, duration: TYPING_NOTE_DURATION }] },
});
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
log('Typing tone sent', { roverId, note, socketId });
} catch (err) {
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
log('Typing tone failed', { roverId, note, socketId, error: err.message });
}
}
function normalizeTtsOptions(raw = {}) {
if (!raw || typeof raw !== 'object') return null;
const speak = raw.speak !== false;
if (!speak) return null;
const engine = typeof raw.engine === 'string' && raw.engine.toLowerCase() === 'espeak' ? 'espeak' : 'flite';
const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
let pitch = Number.isFinite(raw.pitch) ? Math.round(raw.pitch) : undefined;
if (typeof pitch === 'number') pitch = Math.max(0, Math.min(99, pitch));
return { speak, engine, voice, pitch };
}
function buildAccessNoticeText(mode, reasonText) {
const label = mode === MODES.LOCKDOWN ? 'lockdown' : 'admin';
const reason = reasonText ? ` Reason: ${reasonText}` : '';
return `Heads up: the server is in ${label} mode.${reason}`;
}
function shouldSendAccessNotice(message) {
if (!message?.text || message.system) return false;
const mode = getMode();
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
const now = Date.now();
if (now - getLastAccessNoticeAt() < ACCESS_NOTICE_COOLDOWN_MS) return false;
setLastAccessNoticeAt(now);
return true;
}
function maybeSendAccessNotice(message, sendSystemMessage) {
if (!shouldSendAccessNotice(message)) return;
const reason = getAdminReason()?.text || '';
const notice = buildAccessNoticeText(getMode(), reason);
sendSystemMessage(notice);
}
function maybeSpeak(socket, message, ttsOptions) {
if (!ttsOptions || !message?.roverId) return;
const record = roverManager.rovers.get(message.roverId);
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
if (!ttsEnabled) return;
const { isQueuedDriver } = require('../turnService');
if (!roverManager.canDrive(message.roverId, socket) && !isQueuedDriver(message.roverId, socket?.id)) return;
try {
issueCommand(message.roverId, {
type: 'tts',
tts: {
text: message.text,
engine: ttsOptions.engine,
voice: ttsOptions.voice,
pitch: ttsOptions.pitch,
speak: true,
},
});
logger.info('TTS sent', { roverId: message.roverId, engine: ttsOptions.engine, socket: socket.id });
} catch (err) {
logger.warn('TTS send failed', { roverId: message.roverId, error: err.message });
}
}
module.exports = {
playTypingNote,
normalizeTtsOptions,
maybeSendAccessNotice,
maybeSpeak,
TYPING_START_NOTE,
TYPING_SEND_NOTE,
};
@@ -0,0 +1,53 @@
// Chat Socket Hooks
// Purpose: Registers chat socket handlers and event-bus fan-out listeners.
// Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients.
const io = require('../../globals/io');
const { subscribe } = require('../eventBus');
const { typingBySocket } = require('./state');
const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders');
const { broadcastTyping } = require('./broadcast');
const { playTypingNote, TYPING_START_NOTE } = require('./notifications');
function registerChatSocketHooks({ history, handleIncoming }) {
io.on('connection', (socket) => {
socket.emit('chat:init', history);
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
socket.on('chat:typing', (payload = {}) => {
const isTyping = Boolean(payload?.isTyping);
const wasTyping = typingBySocket.get(socket.id);
if (isTyping) {
typingBySocket.set(socket.id, true);
if (!wasTyping) {
const roverId = resolveRoverId(socket?.id);
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
}
} else {
typingBySocket.delete(socket.id);
}
const roverId = resolveRoverId(socket?.id);
if (isPrivateClosedRoverId(roverId)) return;
broadcastTyping(buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping }));
});
socket.on('disconnect', () => {
if (!typingBySocket.has(socket.id)) return;
typingBySocket.delete(socket.id);
const roverId = resolveRoverId(socket?.id);
if (isPrivateClosedRoverId(roverId)) return;
broadcastTyping(buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false }));
});
});
subscribe('chat:message', ({ payload }) => {
if (!payload) return;
io.emit('chat:message', payload);
});
subscribe('chat:typing', ({ payload }) => {
if (!payload) return;
io.emit('chat:typing', payload);
});
}
module.exports = {
registerChatSocketHooks,
};
+53
View File
@@ -0,0 +1,53 @@
// Chat Service State
// Purpose: Stores mutable chat runtime state for rate limits, history, and typing bookkeeping.
// Scope: Encapsulates in-memory collections shared by chat service modules.
const { RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX, MAX_HISTORY } = require('./constants');
const rateBuckets = new Map();
const history = [];
const lastMessageBySocket = new Map();
const typingBySocket = new Map();
let lastAccessNoticeAt = 0;
function withinRateLimit(socketId) {
const now = Date.now();
const entries = rateBuckets.get(socketId) || [];
const next = entries.filter((ts) => now - ts <= RATE_LIMIT_WINDOW_MS);
next.push(now);
rateBuckets.set(socketId, next);
return next.length <= RATE_LIMIT_MAX;
}
function pushHistory(message) {
history.push(message);
if (history.length > MAX_HISTORY) {
history.shift();
}
}
function getRecentMessages(limit = 20, options = {}) {
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
const includeSystem = options?.includeSystem !== false;
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
return source.slice(-safeLimit);
}
function getLastAccessNoticeAt() {
return lastAccessNoticeAt;
}
function setLastAccessNoticeAt(ts) {
lastAccessNoticeAt = ts;
}
module.exports = {
rateBuckets,
history,
lastMessageBySocket,
typingBySocket,
withinRateLimit,
pushHistory,
getRecentMessages,
getLastAccessNoticeAt,
setLastAccessNoticeAt,
};
@@ -1,8 +1,13 @@
// command Service
// Purpose: Defines the command Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io'); const io = require('../../globals/io');
const roverManager = require('./roverManager'); const roverManager = require('../roverManager');
const { isAdmin, isLockdownAdmin } = require('./roleService'); const { isAdmin, isLockdownAdmin } = require('../roleService');
const logger = require('../globals/logger').child('commandService'); const { isDeterred } = require('../verificationService');
const logger = require('../../globals/logger').child('commandService');
const { isNightVisionBlocked } = require('../../rewards/definitions/darkness');
const pendingCommands = new Map(); // id -> { roverId } const pendingCommands = new Map(); // id -> { roverId }
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin } const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
@@ -70,10 +75,18 @@ io.on('connection', (socket) => {
if (type === 'audioLevels') { if (type === 'audioLevels') {
throw new Error('audioLevels command is service-managed'); throw new Error('audioLevels command is service-managed');
} }
if (type === 'nightVision' && isNightVisionBlocked()) {
logger.info('Ignoring night vision command while darkness lock is active', { socketId: socket.id, roverId });
reply({ ignored: true, reason: 'darknessActive' });
return;
}
const payload = data ? { ...data } : {}; const payload = data ? { ...data } : {};
const isRebootCommand = type === 'reboot'; const isRebootCommand = type === 'reboot';
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload)); const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
const isAdminSocket = isAdmin(socket); const isAdminSocket = isAdmin(socket);
if (!isAdminSocket && isDeterred(socket)) {
throw new Error('Not authorized');
}
if (isRebootCommand && !isAdminSocket) { if (isRebootCommand && !isAdminSocket) {
throw new Error('Not authorized'); throw new Error('Not authorized');
} }
@@ -81,20 +94,24 @@ io.on('connection', (socket) => {
throw new Error('Not your turn or no control'); throw new Error('Not your turn or no control');
} }
const driveDirect = payload?.driveDirect; const driveDirect = payload?.driveDirect;
if (type === 'drive' && driveDirect && !isAdminSocket) { if (type === 'drive' && driveDirect) {
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect); if (!isAdminSocket) {
if (safeDrive) { const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
payload.driveDirect = safeDrive; if (safeDrive) {
payload.driveDirect = safeDrive;
}
} }
const left = Number(payload?.driveDirect?.left); const left = Number(payload?.driveDirect?.left);
const right = Number(payload?.driveDirect?.right); const right = Number(payload?.driveDirect?.right);
const speed = Math.max(Math.abs(left), Math.abs(right)); const speed = Math.max(Math.abs(left), Math.abs(right));
const blockedUntil = driveCooldowns.get(roverId); if (!isAdminSocket) {
if (blockedUntil && Date.now() < blockedUntil && speed > 0) { const blockedUntil = driveCooldowns.get(roverId);
const reason = isLockdownAdmin(socket) if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
? 'Drive blocked: cooldown' const reason = isLockdownAdmin(socket)
: 'Drive blocked: safety cooldown'; ? 'Drive blocked: cooldown'
throw new Error(reason); : 'Drive blocked: safety cooldown';
throw new Error(reason);
}
} }
if (speed > 0) { if (speed > 0) {
let direction = 'turn'; let direction = 'turn';
@@ -112,7 +129,7 @@ io.on('connection', (socket) => {
const id = issueCommand(roverId, { type, ...payload }); const id = issueCommand(roverId, { type, ...payload });
logger.info('Queued command', socket.id, roverId, type); logger.info('Queued command', socket.id, roverId, type);
try { try {
const { recordActivity } = require('./turnService'); const { recordActivity } = require('../turnService');
recordActivity(roverId, socket.id); recordActivity(roverId, socket.id);
} catch (err) { } catch (err) {
// best effort; ignore activity update errors // best effort; ignore activity update errors
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
// Discord Battery Embed Helpers
// Purpose: Centralizes rover battery/status snapshot formatting and embed rendering for Discord surfaces.
// Scope: Shared presentation logic for status commands and admin alert battery event posts.
const { EmbedBuilder } = require('discord.js');
function formatVoltage(voltageMv) {
if (voltageMv == null) return 'n/a';
return `${(voltageMv / 1000).toFixed(2)}V`;
}
function formatCurrent(currentMa) {
if (currentMa == null) return 'n/a';
return `${currentMa}mA`;
}
function formatChargeState(batteryState) {
if (!batteryState) return 'n/a';
const charge = batteryState.charge;
const capacity = batteryState.capacity;
const percent = batteryState.percentDisplay;
const chargeText = charge != null && capacity != null ? `${charge}/${capacity}mAh` : 'n/a';
const percentText = percent != null ? `${percent}%` : 'n/a';
return `${chargeText} (${percentText})`;
}
function formatDockEmoji(docked) { return docked ? '🏠' : '🧭'; }
function formatChargeEmoji(charging) { return charging ? '⚡' : '🔌'; }
function formatLockEmoji(locked) { return locked ? '🔒' : '🔓'; }
function formatBatteryEmoji(batteryState) {
if (batteryState?.urgentActive) return '🛑';
if (batteryState?.warnActive) return '⚠️';
return '🔋';
}
function formatOiEmoji(oiMode) {
if (oiMode === 'full') return '🕹️';
if (oiMode === 'safe') return '🧰';
if (oiMode === 'passive') return '🟢';
if (oiMode === 'off') return '⏹️';
return '❔';
}
function isCharging(sensors) {
const label = sensors?.chargingState?.label?.toLowerCase();
const chargingByLabel = label === 'waiting' || label === 'full charging' || label === 'trickle charging';
const code = sensors?.chargingState?.code;
const chargingByCode = code === 2 || code === 3 || code === 4;
return chargingByLabel || chargingByCode;
}
function buildRoverStatusSnapshot(record) {
if (!record) return null;
const sensors = record.lastSensor?.decoded || record.lastSensor?.sensors || null;
const oiMode = String(sensors?.oiMode?.label || 'unknown').toLowerCase();
return {
id: record.id,
name: record.meta?.name || record.id,
locked: record.locked,
lockReason: record.lockReason,
docked: Boolean(sensors?.chargingSources?.homeBase),
charging: isCharging(sensors),
chargingLabel: sensors?.chargingState?.label || 'unknown',
voltageMv: sensors?.voltageMv ?? null,
currentMa: sensors?.currentMa ?? null,
batteryState: record.batteryState,
oiMode,
};
}
function buildBatteryStatusEmbed({ color = 0x2196f3, records = [] }) {
const embed = new EmbedBuilder().setTitle('Rover Battery Status').setColor(color).setTimestamp(new Date());
const snapshots = records.map((entry) => buildRoverStatusSnapshot(entry)).filter(Boolean);
if (!snapshots.length) {
embed.setDescription('No rovers online.');
return embed;
}
snapshots.forEach((snapshot) => {
const lockLabel = snapshot.locked ? `locked${snapshot.lockReason ? ` (${snapshot.lockReason})` : ''}` : 'unlocked';
const dockLabel = snapshot.docked ? 'docked' : 'undocked';
const chargingLabel = snapshot.charging ? `charging (${snapshot.chargingLabel})` : 'not charging';
const header = [
formatBatteryEmoji(snapshot.batteryState),
formatDockEmoji(snapshot.docked),
formatChargeEmoji(snapshot.charging),
formatLockEmoji(snapshot.locked),
].join(' ');
const lines = [
`Dock: ${dockLabel}`,
`Charging: ${chargingLabel}`,
`Battery: ${formatChargeState(snapshot.batteryState)}`,
`Voltage: ${formatVoltage(snapshot.voltageMv)}`,
`Current: ${formatCurrent(snapshot.currentMa)}`,
`OI: ${snapshot.oiMode} ${formatOiEmoji(snapshot.oiMode)}`,
`Lock: ${lockLabel}`,
];
embed.addFields({
name: `${header} ${snapshot.name}`,
value: lines.join('\n'),
inline: true,
});
});
return embed;
}
function buildBatteryCaption(type, record) {
const snapshot = buildRoverStatusSnapshot(record);
const base = snapshot?.name || 'unknown';
const percent = snapshot?.batteryState?.percentDisplay;
const percentLabel = percent != null ? `${percent}%` : 'n/a';
const dockLabel = snapshot?.docked ? 'docked' : 'undocked';
const chargingLabel = snapshot?.charging ? 'charging' : 'not charging';
const detail = `${dockLabel}, ${chargingLabel}, ${formatVoltage(snapshot?.voltageMv ?? null)}, ${formatCurrent(snapshot?.currentMa ?? null)}, ${formatChargeState(snapshot?.batteryState ?? null)}`;
switch (type) {
case 'battery.warn': return `Battery warn: ${base} at ${percentLabel}. ${detail}`;
case 'battery.urgent': return `Battery urgent: ${base} at ${percentLabel}. ${detail}`;
case 'battery.docked': return `Docked: ${base}. ${detail}`;
case 'battery.undocked': return `Undocked: ${base}. ${detail}`;
case 'battery.charging.start': return `Charging started: ${base}. ${detail}`;
case 'battery.charging.stop': return `Charging stopped: ${base}. ${detail}`;
case 'battery.locked': return `Locked for charging: ${base}. ${detail}`;
case 'battery.unlocked': return `Unlocked after charging: ${base}. ${detail}`;
default: return `Battery update: ${base}. ${detail}`;
}
}
module.exports = {
buildRoverStatusSnapshot,
buildBatteryStatusEmbed,
buildBatteryCaption,
};
@@ -0,0 +1,87 @@
// Discord Channel IO Module
// Purpose: Centralizes Discord channel fetch/send helpers plus typing-indicator message lifecycle for bridge UX.
// Scope: Provides safe channel cache operations and typing message management without command or policy logic.
const { MessageFlags } = require('discord.js');
function createChannelIO({ client, logger, sanitizeMentions }) {
const channelCache = new Map();
const typingMessageCache = new Map();
async function fetchChannel(id) {
if (!id) return null;
if (channelCache.has(id)) return channelCache.get(id);
try {
const channel = await client.channels.fetch(id);
if (channel) {
channelCache.set(id, channel);
return channel;
}
} catch (err) {
logger.warn('Failed to fetch Discord channel', { id, error: err.message });
}
return null;
}
async function sendToChannel(id, content, options = {}, allowedMentions = { parse: [] }, sanitizeContent = true) {
const channel = await fetchChannel(id);
if (!channel) return;
try {
const messageContent = sanitizeContent ? sanitizeMentions(content) : content;
await channel.send({ content: messageContent, allowedMentions, ...options });
} catch (err) {
logger.warn('Failed to send Discord message', { id, error: err.message });
}
}
function typingCacheKey(guildId, typingId) {
return `${guildId}:${typingId}`;
}
async function clearTypingMessage(guildId, typingId) {
const key = typingCacheKey(guildId, typingId);
const record = typingMessageCache.get(key);
if (!record) return;
typingMessageCache.delete(key);
if (record.timeoutId) clearTimeout(record.timeoutId);
const channel = await fetchChannel(record.channelId);
if (!channel?.messages?.fetch) return;
try {
const msg = await channel.messages.fetch(record.messageId);
await msg.delete();
} catch (err) {
if (err?.code !== 10008) {
logger.warn('Failed to delete typing message', { guildId, error: err.message });
}
}
}
async function sendTypingMessage(entry, payload, formatWebhookUsername, getTypingId) {
const typingId = getTypingId(payload);
const key = typingCacheKey(entry.guildId, typingId);
if (typingMessageCache.has(key)) return;
const channel = await fetchChannel(entry.channelId);
if (!channel?.send) return;
const username = formatWebhookUsername(payload);
const content = `-# *${username} is typing...*`;
try {
const message = await channel.send({ content, allowedMentions: { parse: [] }, flags: [MessageFlags.SuppressNotifications]});
const timeoutId = setTimeout(() => {
clearTypingMessage(entry.guildId, typingId);
}, 20000);
typingMessageCache.set(key, { channelId: entry.channelId, messageId: message.id, timeoutId });
} catch (err) {
logger.warn('Failed to send typing message', { guildId: entry.guildId, error: err.message });
}
}
return {
fetchChannel,
sendToChannel,
clearTypingMessage,
sendTypingMessage,
};
}
module.exports = {
createChannelIO,
};
@@ -0,0 +1,72 @@
// Discord Bridge Command
// Purpose: Handles chat bridge configuration/status commands per guild.
// Scope: Manages bridge channel, mode, and webhook provisioning.
const { PermissionsBitField } = require('discord.js');
function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig, normalizeMode, VALID_MODES, isAdminUser }) {
function canManageBridge(message) {
if (isAdminUser(message.author.id)) return true;
if (!message.guild || !message.member) return false;
const perms = message.member.permissions;
if (!perms) return false;
return perms.has(PermissionsBitField.Flags.ManageGuild) || perms.has(PermissionsBitField.Flags.Administrator);
}
function canManageWebhooksInChannel(channel) {
if (!channel?.guild) return false;
const botMember = channel.guild.members?.me;
const perms = channel.permissionsFor(botMember);
if (!perms) return false;
return perms.has(PermissionsBitField.Flags.ManageWebhooks);
}
async function ensureBridgeWebhook(channel, guildId) {
if (!channel?.id || !guildId) return null;
if (!canManageWebhooksInChannel(channel)) throw new Error('Missing Manage Webhooks permission in this channel.');
const existing = getGuildConfig(guildId);
if (existing?.channelId && String(existing.channelId) === String(channel.id) && existing?.webhookId && existing?.webhookToken) return existing;
const webhook = await channel.createWebhook({ name: 'Rover Chat Bridge', reason: 'Rover chat bridge webhook' });
if (!webhook?.id || !webhook?.token) throw new Error('Failed to create webhook.');
return setGuildConfig(guildId, { channelId: channel.id, mode: existing?.mode || 'global', webhookId: webhook.id, webhookToken: webhook.token });
}
function status(entry) {
if (!entry) return 'Chat bridge is not configured for this server.';
return `Chat bridge is **${entry.mode}** in <#${entry.channelId}>.`;
}
return async function handleBridgeCommand(message, tokens) {
if (!message.guild) return message.reply({ content: 'Chat bridge must be configured in a server channel.', allowedMentions: { parse: [], repliedUser: false } });
const guildId = message.guild.id;
let action = (tokens.shift() || 'status').toLowerCase();
let mode = null;
if (action === 'global' || action === 'private') { mode = action; action = 'here'; }
else if (action === 'here' || action === 'mode') { mode = (tokens.shift() || '').toLowerCase(); }
if (mode && !VALID_MODES.has(mode)) return message.reply({ content: 'Invalid mode. Use `global` or `private`.', allowedMentions: { parse: [], repliedUser: false } });
if (action === 'status') return message.reply({ content: status(getGuildConfig(guildId)), allowedMentions: { parse: [], repliedUser: false } });
if (action === 'off') { removeGuildConfig(guildId); return message.reply({ content: 'Chat bridge disabled for this server.', allowedMentions: { parse: [], repliedUser: false } }); }
if (!canManageBridge(message)) return message.reply({ content: 'You need Manage Server permissions to change the chat bridge.', allowedMentions: { parse: [], repliedUser: false } });
if (action === 'here') {
try {
const entry = await ensureBridgeWebhook(message.channel, guildId);
if (mode) setGuildConfig(guildId, { channelId: entry.channelId, mode, webhookId: entry.webhookId, webhookToken: entry.webhookToken });
const updated = getGuildConfig(guildId);
return message.reply({ content: `Chat bridge set to **${updated.mode}** in <#${updated.channelId}>.`, allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
return message.reply({ content: `Failed to set chat bridge: ${err.message}`, allowedMentions: { parse: [], repliedUser: false } });
}
}
if (action === 'mode') {
const current = getGuildConfig(guildId);
if (!current?.channelId) return message.reply({ content: 'No chat bridge channel set. Use `rs bridge here <global|private>` first.', allowedMentions: { parse: [], repliedUser: false } });
const nextMode = normalizeMode(mode, null);
if (!VALID_MODES.has(nextMode)) return message.reply({ content: 'Invalid mode. Use `global` or `private`.', allowedMentions: { parse: [], repliedUser: false } });
const entry = setGuildConfig(guildId, { channelId: current.channelId, mode: nextMode, webhookId: current.webhookId, webhookToken: current.webhookToken });
return message.reply({ content: `Chat bridge mode updated to **${entry.mode}** in <#${entry.channelId}>.`, allowedMentions: { parse: [], repliedUser: false } });
}
return message.reply({ content: 'Unknown bridge command. Try `rs bridge`.', allowedMentions: { parse: [], repliedUser: false } });
};
}
module.exports = { createBridgeCommand };
@@ -0,0 +1,49 @@
// Discord Deter Command
// Purpose: Handles deterrence moderation commands for lockdown admins.
// Scope: Supports list, ban, and unban subcommands.
function createDeterCommand({ listDeterredUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions }) {
function mask(v) {
const key = String(v || '').trim();
if (!key) return 'n/a';
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
return `${key.slice(0, 6)}...${key.slice(-6)}`;
}
return async function handleDeterCommand(message, tokens) {
if (!isLockdownAdminUser(message.author?.id)) {
await message.reply({ content: 'Only lockdown admins can manage deterred users.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
const action = (tokens.shift() || 'list').toLowerCase();
if (action === 'list') {
const users = listDeterredUsers();
if (!users.length) return message.reply({ content: 'No deterred users.', allowedMentions: { parse: [], repliedUser: false } });
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.id} | ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`);
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
}
if (action === 'ban') {
const selector = String(tokens.shift() || '').trim();
const reason = tokens.join(' ').trim();
if (!selector) return message.reply({ content: 'Usage: `rs deter ban <cookieUserId|nickname|ip> [reason]`', allowedMentions: { parse: [], repliedUser: false } });
try {
const deterred = deterUser(selector, { reason, actor: message.author?.id || null });
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to deter user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
}
if (action === 'unban') {
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: 'Usage: `rs deter unban <id|cookieUserId|nickname|ip>`', allowedMentions: { parse: [], repliedUser: false } });
try {
const removed = undeterUser(selector, message.author?.id || null);
return message.reply({ content: sanitizeMentions(`Removed deterrence for ${removed.nickname || 'unknown'} (${mask(removed.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
}
return message.reply({ content: 'Unknown deter command. Use `rs deter list`, `rs deter ban <selector> [reason]`, or `rs deter unban <selector>`.', allowedMentions: { parse: [], repliedUser: false } });
};
}
module.exports = { createDeterCommand };
@@ -0,0 +1,31 @@
// Discord Goal Command
// Purpose: Handles global objective view/update/clear operations.
// Scope: Allows read by all and write by admins.
function createGoalCommand({ getGlobalObjective, setGlobalObjective, clearGlobalObjective, isAdminUser, sanitizeMentions }) {
return async function handleGoalCommand(message, tokens) {
const query = tokens.join(' ').trim();
const lower = query.toLowerCase();
if (!query) {
const goal = getGlobalObjective();
await message.reply({ content: goal?.text ? `Global objective: ${sanitizeMentions(goal.text)}` : 'No global objective set.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
if (!isAdminUser(message.author.id)) {
await message.reply({ content: 'Only admins can update the global objective.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
try {
if (lower === 'clear') {
clearGlobalObjective({ by: message.author?.id || null });
await message.reply({ content: 'Global objective cleared.', allowedMentions: { parse: [], repliedUser: false } });
} else {
setGlobalObjective(query, { by: message.author?.id || null });
await message.reply({ content: sanitizeMentions(`Global objective set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
}
} catch (err) {
await message.reply({ content: sanitizeMentions(`Failed to update goal: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
};
}
module.exports = { createGoalCommand };
@@ -0,0 +1,28 @@
// Discord Help Command
// Purpose: Provides help text for rover bot Discord commands.
// Scope: Returns static command usage text.
function formatHelp() {
return [
'**Rover Bot Commands**',
'`rs help` — show this help',
'`rs status [id]` — show rover status (all or one)',
'`rs replay [sources]` — send instant replay (room/rover)',
'`rs bridge` — show chat bridge status for this server',
'`rs bridge here <global|private>` — set chat bridge to this channel',
'`rs bridge mode <global|private>` — change chat bridge mode',
'`rs bridge off` — disable chat bridge for this server',
'`rs lock <id>` — lock a rover',
'`rs unlock <id>` — unlock a rover',
'`rs mode <open|turns|admin|lockdown>` — change server mode',
'`rs reason [text|clear]` — show or set admin mode reason',
'`rs goal [text|clear]` — show or set global objective',
'`rs verify list` — list verified users (lockdown admins)',
'`rs verify remove <cookieUserId|nickname>` — remove verified user (lockdown admins)',
'`rs deter list` — list deterred users (lockdown admins)',
'`rs deter ban <cookieUserId|nickname|ip> [reason]` — deter a user (lockdown admins)',
'`rs deter unban <id|cookieUserId|nickname|ip>` — remove deterrence (lockdown admins)',
'`ts` — show time status',
].join('\n');
}
module.exports = { formatHelp };
@@ -0,0 +1,91 @@
// Discord Commands Router
// Purpose: Routes incoming Discord command messages to one-file-per-command handlers.
// Scope: Central command dispatcher and permission gate orchestration.
const { formatHelp } = require('./help');
const { createStatusCommand } = require('./status');
const { createReplayCommand } = require('./replay');
const { createLockCommand } = require('./lock');
const { createModeCommand } = require('./mode');
const { createReasonCommand } = require('./reason');
const { createGoalCommand } = require('./goal');
const { createVerifyCommand } = require('./verify');
const { createDeterCommand } = require('./deter');
const { createBridgeCommand } = require('./bridge');
const { createTimeStatusCommand } = require('./timeStatus');
function createCommandHandlers(deps) {
const {
getMode,
MODES,
isAdminUser,
isLockdownAdminUser,
} = deps;
const handleStatusCommand = createStatusCommand(deps);
const handleReplayCommand = createReplayCommand(deps);
const handleLockCommand = createLockCommand(deps);
const handleModeCommand = createModeCommand(deps);
const handleReasonCommand = createReasonCommand(deps);
const handleGoalCommand = createGoalCommand(deps);
const handleVerifyCommand = createVerifyCommand(deps);
const handleDeterCommand = createDeterCommand(deps);
const handleBridgeCommand = createBridgeCommand(deps);
const handleTimeStatusCommand = createTimeStatusCommand(deps);
async function handleCommand(message) {
if (message.author.bot) return;
const content = (message.content || '').trim();
const lower = content.toLowerCase();
if (lower === 'ts' || lower.startsWith('ts')) return handleTimeStatusCommand(message);
if (!lower.startsWith('rs')) return;
const tokens = content.split(/\s+/);
tokens.shift();
const action = (tokens.shift() || '').toLowerCase();
const isAdmin = isAdminUser(message.author.id);
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
const mode = getMode();
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter']);
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
return;
}
if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) {
await message.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
switch (action) {
case '':
case 'status':
return handleStatusCommand(message, tokens[0]);
case 'help':
return message.reply(formatHelp());
case 'replay':
return handleReplayCommand(message, tokens.join(' '));
case 'bridge':
return handleBridgeCommand(message, tokens);
case 'lock':
return handleLockCommand(message, tokens[0], true);
case 'unlock':
return handleLockCommand(message, tokens[0], false);
case 'mode':
return handleModeCommand(message, tokens);
case 'goal':
return handleGoalCommand(message, tokens);
case 'reason':
return handleReasonCommand(message, tokens);
case 'verify':
return handleVerifyCommand(message, tokens);
case 'deter':
return handleDeterCommand(message, tokens);
default:
return message.reply(formatHelp());
}
}
return { handleCommand };
}
module.exports = { createCommandHandlers };
@@ -0,0 +1,19 @@
// Discord Lock Command
// Purpose: Handles `rs lock` and `rs unlock` operations for rover availability control.
// Scope: Applies lock state updates for a single rover ID.
function createLockCommand({ lockRover, sanitizeMentions }) {
return async function handleLockCommand(message, roverId, locked) {
if (!roverId) {
await message.reply({ content: 'Specify a rover ID. Example: `rs lock alpha`', allowedMentions: { parse: [], repliedUser: false } });
return;
}
try {
lockRover(roverId, locked, { reason: 'discord' });
await message.reply({ content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${roverId}.`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
await message.reply({ content: sanitizeMentions(`Failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
};
}
module.exports = { createLockCommand };
@@ -0,0 +1,23 @@
// Discord Mode Command
// Purpose: Handles `rs mode` updates from Discord admins.
// Scope: Validates mode values and applies mode changes with optional reason text.
function createModeCommand({ MODES, setMode, setAdminReason, isLockdownAdminUser, sanitizeMentions }) {
return async function handleModeCommand(message, tokens = []) {
const next = String(tokens.shift() || '').toLowerCase();
const reasonText = tokens.join(' ').trim();
if (!Object.values(MODES).includes(next)) {
await message.reply({ content: 'Invalid mode. Use one of: open, turns, admin, lockdown.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
try {
const role = isLockdownAdminUser(message.author?.id) ? 'lockdown' : 'admin';
setMode(next, { data: { role, user: { username: `discord:${message.author?.username || 'unknown'}` } } });
if (reasonText) setAdminReason(reasonText, { by: message.author?.id || null });
await message.reply({ content: sanitizeMentions(`Mode set to ${next}.`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
await message.reply({ content: sanitizeMentions(`Failed to set mode: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
};
}
module.exports = { createModeCommand };

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