Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Transform isair/jarvis into a Discord-controlled voice assistant running on the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact. - bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral), voice channel join + voice receive/playback, pluggable VNC screen broadcast (selfbot live / noVNC / screenshot) - bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS behind a thin localhost HTTP API - .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite, docs/language-comparison.md and docs/vnc-xfce-setup.md Language decision: hybrid (Python brain + Node/bun Discord layer) because Discord blocks bot video; native screen broadcast only works via a Node selfbot library.
This commit is contained in:
216
bot/bun.lock
Normal file
216
bot/bun.lock
Normal file
@@ -0,0 +1,216 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "javis-bot",
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.18.0",
|
||||
"discord.js": "^14.16.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"opusscript": "^0.1.1",
|
||||
"prism-media": "^1.3.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.0",
|
||||
"typescript": "^5.6.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@dank074/discord-video-stream": "^4.2.1",
|
||||
"discord.js-selfbot-v13": "^3.7.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@discordjs/builders": ["@discordjs/builders@1.14.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="],
|
||||
|
||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
||||
"@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
|
||||
|
||||
"@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.24.1" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="],
|
||||
|
||||
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
|
||||
|
||||
"@discordjs/voice": ["@discordjs/voice@0.18.0", "", { "dependencies": { "@types/ws": "^8.5.12", "discord-api-types": "^0.37.103", "prism-media": "^1.3.5", "tslib": "^2.6.3", "ws": "^8.18.0" } }, "sha512-BvX6+VJE5/vhD9azV9vrZEt9hL1G+GlOdsQaVl5iv9n87fkXjf3cSwllhR3GdaUC8m6dqT8umXIWtn3yCu4afg=="],
|
||||
|
||||
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
|
||||
|
||||
"@minhducsun2002/leb128": ["@minhducsun2002/leb128@1.0.0", "", {}, "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g=="],
|
||||
|
||||
"@otplib/core": ["@otplib/core@12.0.1", "", {}, "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA=="],
|
||||
|
||||
"@otplib/plugin-crypto": ["@otplib/plugin-crypto@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1" } }, "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g=="],
|
||||
|
||||
"@otplib/plugin-thirty-two": ["@otplib/plugin-thirty-two@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "thirty-two": "^1.0.2" } }, "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA=="],
|
||||
|
||||
"@otplib/preset-default": ["@otplib/preset-default@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "@otplib/plugin-crypto": "^12.0.1", "@otplib/plugin-thirty-two": "^12.0.1" } }, "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ=="],
|
||||
|
||||
"@otplib/preset-v11": ["@otplib/preset-v11@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "@otplib/plugin-crypto": "^12.0.1", "@otplib/plugin-thirty-two": "^12.0.1" } }, "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg=="],
|
||||
|
||||
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
||||
|
||||
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
|
||||
|
||||
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
||||
|
||||
"@shinyoshiaki/jspack": ["@shinyoshiaki/jspack@0.0.6", "", {}, "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.20", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
||||
|
||||
"aes-js": ["aes-js@3.1.2", "", {}, "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||
|
||||
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||
|
||||
"discord-api-types": ["discord-api-types@0.38.48", "", {}, "sha512-WFUE/2o0lBlLeCQonQ+Pu2RqHAqbytBJ2RlXR91gzk05InSS6k9ShzzLYoymrA4c2oRgRKGE7/VqQJNNdGWSxQ=="],
|
||||
|
||||
"discord.js": ["discord.js@14.26.4", "", { "dependencies": { "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.1", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.24.1" } }, "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA=="],
|
||||
|
||||
"discord.js-selfbot-v13": ["discord.js-selfbot-v13@3.7.1", "", { "dependencies": { "@discordjs/builders": "^1.6.3", "@discordjs/collection": "^2.1.1", "@sapphire/async-queue": "^1.5.5", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.15", "fetch-cookie": "^3.1.0", "find-process": "^2.0.0", "otplib": "^12.0.1", "prism-media": "^1.3.5", "qrcode": "^1.5.4", "tough-cookie": "^5.1.2", "tree-kill": "^1.2.2", "undici": "^7.11.0", "werift-rtp": "^0.8.4", "ws": "^8.16.0" } }, "sha512-cq5AW/CVvNIUVTSBdZmhsob7v+wjxnkFjuNULcxBXvxutVBnSZqZupsT/9CDtdnT71iKUn9N8GGL6GPg9aZlGA=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fetch-cookie": ["fetch-cookie@3.2.0", "", { "dependencies": { "set-cookie-parser": "^2.4.8", "tough-cookie": "^6.0.0" } }, "sha512-n61pQIxP25C6DRhcJxn7BDzgHP/+S56Urowb5WFxtcRMpU6drqXD90xjyAsVQYsNSNNVbaCcYY1DuHsdkZLuiA=="],
|
||||
|
||||
"find-process": ["find-process@2.1.1", "", { "dependencies": { "chalk": "~4.1.2", "commander": "^14.0.3", "loglevel": "^1.9.2" }, "bin": { "find-process": "dist/cjs/bin/find-process.js" } }, "sha512-SrQDx3QhlmHM90iqn9rdjCQcw/T+WlpOkHFsjoRgB+zTpDfltNA1VSNYeYELwhUTJy12UFxqjWhmhOrJc+o4sA=="],
|
||||
|
||||
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"libsodium": ["libsodium@0.7.16", "", {}, "sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q=="],
|
||||
|
||||
"libsodium-wrappers": ["libsodium-wrappers@0.7.16", "", { "dependencies": { "libsodium": "^0.7.16" } }, "sha512-Gtr/WBx4dKjvRL1pvfwZqu7gO6AfrQ0u9vFL+kXihtHf6NfkROR8pjYWn98MFDI3jN19Ii1ZUfPR9afGiPyfHg=="],
|
||||
|
||||
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
||||
|
||||
"loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="],
|
||||
|
||||
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
|
||||
|
||||
"mp4box": ["mp4box@0.5.4", "", {}, "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA=="],
|
||||
|
||||
"opusscript": ["opusscript@0.1.1", "", {}, "sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA=="],
|
||||
|
||||
"otplib": ["otplib@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "@otplib/preset-default": "^12.0.1", "@otplib/preset-v11": "^12.0.1" } }, "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg=="],
|
||||
|
||||
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
"prism-media": ["prism-media@1.3.5", "", { "peerDependencies": { "@discordjs/opus": ">=0.8.0 <1.0.0", "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0", "node-opus": "^0.3.3", "opusscript": "^0.0.8" }, "optionalPeers": ["@discordjs/opus", "ffmpeg-static", "node-opus", "opusscript"] }, "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA=="],
|
||||
|
||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"thirty-two": ["thirty-two@1.0.2", "", {}, "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA=="],
|
||||
|
||||
"tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
|
||||
|
||||
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
|
||||
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.27.2", "", {}, "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"werift-rtp": ["werift-rtp@0.8.8", "", { "dependencies": { "@minhducsun2002/leb128": "^1.0.0", "@shinyoshiaki/jspack": "^0.0.6", "aes-js": "^3.1.2", "buffer": "^6.0.3", "mp4box": "^0.5.3" } }, "sha512-GiYMSdvCyScQaw5bnEsraSoHUVZpjfokJAiLV4R1FsiB06t6XiebPYPpkqB9nYNNKiA8Z/cYWsym7wISq1sYSQ=="],
|
||||
|
||||
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||
|
||||
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||
|
||||
"@discordjs/rest/@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="],
|
||||
|
||||
"@discordjs/rest/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||
|
||||
"@discordjs/voice/discord-api-types": ["discord-api-types@0.37.120", "", {}, "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw=="],
|
||||
|
||||
"discord.js/@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
||||
|
||||
"discord.js/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||
|
||||
"fetch-cookie/tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
|
||||
|
||||
"fetch-cookie/tough-cookie/tldts": ["tldts@7.4.2", "", { "dependencies": { "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw=="],
|
||||
|
||||
"fetch-cookie/tough-cookie/tldts/tldts-core": ["tldts-core@7.4.2", "", {}, "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA=="],
|
||||
}
|
||||
}
|
||||
28
bot/package.json
Normal file
28
bot/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "javis-bot",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Discord-native voice/video front-end for the Jarvis brain (bun + discord.js)",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"register": "bun run src/register-commands.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.18.0",
|
||||
"discord.js": "^14.16.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"opusscript": "^0.1.1",
|
||||
"prism-media": "^1.3.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@dank074/discord-video-stream": "^4.2.1",
|
||||
"discord.js-selfbot-v13": "^3.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
52
bot/src/bridge.ts
Normal file
52
bot/src/bridge.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* HTTP client for the Python brain bridge (bridge/server.py).
|
||||
* All AI work (STT, reply engine, TTS) lives behind these calls.
|
||||
*/
|
||||
import { config } from "./config.ts";
|
||||
|
||||
export interface ConverseResult {
|
||||
transcript: string;
|
||||
language?: string | null;
|
||||
reply: string;
|
||||
error?: string | null;
|
||||
/** base64-encoded 16-bit PCM WAV of the spoken reply, or null if TTS off */
|
||||
audio_b64?: string | null;
|
||||
}
|
||||
|
||||
export interface TextResult {
|
||||
reply: string;
|
||||
error?: string | null;
|
||||
audio_b64?: string | null;
|
||||
}
|
||||
|
||||
/** Full voice turn: WAV in -> {transcript, reply, reply audio}. */
|
||||
export async function converse(wav: Buffer): Promise<ConverseResult> {
|
||||
const res = await fetch(`${config.bridgeUrl}/converse`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "audio/wav" },
|
||||
body: wav,
|
||||
});
|
||||
if (!res.ok) throw new Error(`bridge /converse ${res.status}: ${await res.text()}`);
|
||||
return (await res.json()) as ConverseResult;
|
||||
}
|
||||
|
||||
/** Text-only turn (used by /자비스 ask). */
|
||||
export async function ask(text: string): Promise<TextResult> {
|
||||
const res = await fetch(`${config.bridgeUrl}/text`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`bridge /text ${res.status}: ${await res.text()}`);
|
||||
return (await res.json()) as TextResult;
|
||||
}
|
||||
|
||||
export async function health(): Promise<any> {
|
||||
const res = await fetch(`${config.bridgeUrl}/health`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function decodeWav(audio_b64?: string | null): Buffer | null {
|
||||
if (!audio_b64) return null;
|
||||
return Buffer.from(audio_b64, "base64");
|
||||
}
|
||||
55
bot/src/config.ts
Normal file
55
bot/src/config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Centralised, typed configuration loaded from environment (.env at repo root).
|
||||
* Nothing else in the bot reads process.env directly.
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
function req(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v) throw new Error(`Missing required env var: ${name} (see .env.example)`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function opt(name: string, fallback = ""): string {
|
||||
return process.env[name] ?? fallback;
|
||||
}
|
||||
|
||||
export type StreamBackend = "selfbot" | "novnc" | "screenshot" | "none";
|
||||
|
||||
export const config = {
|
||||
// --- Normal Discord bot (voice I/O, slash commands) ---
|
||||
botToken: req("DISCORD_BOT_TOKEN"),
|
||||
appId: req("DISCORD_APP_ID"),
|
||||
guildId: req("DISCORD_GUILD_ID"),
|
||||
|
||||
// --- Python brain bridge ---
|
||||
bridgeUrl: opt("BRIDGE_URL", "http://127.0.0.1:8765"),
|
||||
|
||||
// --- VNC screen broadcast ---
|
||||
// selfbot = real live "Go Live" stream via a user (burner) account token
|
||||
// novnc = post a noVNC web link the channel can open in a browser
|
||||
// screenshot= periodically upload VNC screenshots
|
||||
// none = disable screen sharing
|
||||
streamBackend: (opt("STREAM_BACKEND", "selfbot") as StreamBackend),
|
||||
|
||||
// x11grab source for the VNC display (TigerVNC runs the desktop on :1)
|
||||
vncDisplay: opt("VNC_DISPLAY", ":1"),
|
||||
vncResolution: opt("VNC_RESOLUTION", "1920x1080"),
|
||||
vncFramerate: parseInt(opt("VNC_FRAMERATE", "30"), 10),
|
||||
vncBitrateKbps: parseInt(opt("VNC_BITRATE_KBPS", "4000"), 10),
|
||||
|
||||
// selfbot backend (ToS-risk; use a throwaway account token, never your main)
|
||||
selfbotToken: opt("DISCORD_SELFBOT_TOKEN"),
|
||||
|
||||
// novnc backend
|
||||
novncUrl: opt("NOVNC_URL", ""),
|
||||
|
||||
// screenshot backend
|
||||
screenshotIntervalSec: parseInt(opt("SCREENSHOT_INTERVAL_SEC", "5"), 10),
|
||||
|
||||
// --- Voice behaviour ---
|
||||
// Min/max captured utterance bounds (ms) before forwarding to the brain.
|
||||
silenceMs: parseInt(opt("VOICE_SILENCE_MS", "800"), 10),
|
||||
};
|
||||
|
||||
export type AppConfig = typeof config;
|
||||
148
bot/src/index.ts
Normal file
148
bot/src/index.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Javis bot entry point.
|
||||
*
|
||||
* A normal Discord bot that:
|
||||
* - exposes /자비스 (join / leave / ask / stream / stop / status)
|
||||
* - replies to every slash command EPHEMERALLY (only the invoker sees it)
|
||||
* - joins the caller's voice channel for live voice conversation (brain in bridge/)
|
||||
* - broadcasts the VNC screen via a pluggable backend (selfbot / novnc / screenshot)
|
||||
*/
|
||||
import {
|
||||
Client,
|
||||
GatewayIntentBits,
|
||||
MessageFlags,
|
||||
type ChatInputCommandInteraction,
|
||||
type GuildMember,
|
||||
type TextBasedChannel,
|
||||
} from "discord.js";
|
||||
import { AttachmentBuilder } from "discord.js";
|
||||
import { config } from "./config.ts";
|
||||
import { ask, health } from "./bridge.ts";
|
||||
import { joinChannel, leaveGuild, getSession } from "./voice.ts";
|
||||
import { createStreamer, type ScreenStreamer, type StreamContext } from "./stream/index.ts";
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
|
||||
});
|
||||
|
||||
const streamers = new Map<string, ScreenStreamer>();
|
||||
|
||||
async function getStreamer(guildId: string): Promise<ScreenStreamer> {
|
||||
let s = streamers.get(guildId);
|
||||
if (!s) {
|
||||
s = await createStreamer(config);
|
||||
streamers.set(guildId, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const eph = { flags: MessageFlags.Ephemeral } as const;
|
||||
|
||||
client.once("clientReady", () => {
|
||||
console.log(`✓ 로그인: ${client.user?.tag} | stream backend: ${config.streamBackend}`);
|
||||
});
|
||||
|
||||
client.on("interactionCreate", async (interaction) => {
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
if (interaction.commandName !== "자비스") return;
|
||||
const i = interaction as ChatInputCommandInteraction;
|
||||
const sub = i.options.getSubcommand();
|
||||
|
||||
try {
|
||||
switch (sub) {
|
||||
case "join":
|
||||
return void (await handleJoin(i));
|
||||
case "leave":
|
||||
return void (await handleLeave(i));
|
||||
case "ask":
|
||||
return void (await handleAsk(i));
|
||||
case "stream":
|
||||
return void (await handleStream(i));
|
||||
case "stop":
|
||||
return void (await handleStop(i));
|
||||
case "status":
|
||||
return void (await handleStatus(i));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[/자비스 ${sub}]`, err);
|
||||
const msg = `오류: ${(err as Error).message}`;
|
||||
if (i.deferred || i.replied) await i.editReply(msg);
|
||||
else await i.reply({ content: msg, ...eph });
|
||||
}
|
||||
});
|
||||
|
||||
async function handleJoin(i: ChatInputCommandInteraction) {
|
||||
const member = i.member as GuildMember;
|
||||
const channel = member?.voice?.channel;
|
||||
if (!channel) {
|
||||
return i.reply({ content: "먼저 음성 채널에 들어간 뒤 다시 호출해주세요.", ...eph });
|
||||
}
|
||||
await i.deferReply(eph);
|
||||
const session = await joinChannel(channel);
|
||||
session.onTurn = ({ transcript, reply }) =>
|
||||
console.log(`🗣️ ${transcript}\n🤖 ${reply}`);
|
||||
await i.editReply(`🎙️ '${channel.name}' 채널에 접속했습니다. 말씀하세요.`);
|
||||
}
|
||||
|
||||
async function handleLeave(i: ChatInputCommandInteraction) {
|
||||
const left = leaveGuild(i.guildId!);
|
||||
await i.reply({ content: left ? "음성 채널에서 나갔습니다." : "접속 중인 세션이 없습니다.", ...eph });
|
||||
}
|
||||
|
||||
async function handleAsk(i: ChatInputCommandInteraction) {
|
||||
const q = i.options.getString("질문", true);
|
||||
await i.deferReply(eph);
|
||||
const res = await ask(q);
|
||||
const reply = res.reply || res.error || "(응답 없음)";
|
||||
await i.editReply(reply.slice(0, 1900));
|
||||
}
|
||||
|
||||
async function handleStream(i: ChatInputCommandInteraction) {
|
||||
const member = i.member as GuildMember;
|
||||
await i.deferReply(eph);
|
||||
const streamer = await getStreamer(i.guildId!);
|
||||
const ctx: StreamContext = {
|
||||
guildId: i.guildId!,
|
||||
voiceChannelId: member?.voice?.channelId ?? "",
|
||||
postImage: async (png, name) => {
|
||||
const ch = i.channel as TextBasedChannel | null;
|
||||
if (ch && "send" in ch) {
|
||||
await (ch as any).send({ files: [new AttachmentBuilder(png, { name })] });
|
||||
}
|
||||
},
|
||||
};
|
||||
if (config.streamBackend === "selfbot" && !ctx.voiceChannelId) {
|
||||
return i.editReply("셀프봇 송출은 음성 채널 안에서 호출해야 합니다. 음성 채널에 들어간 뒤 다시 시도하세요.");
|
||||
}
|
||||
const msg = await streamer.start(ctx);
|
||||
await i.editReply(msg);
|
||||
}
|
||||
|
||||
async function handleStop(i: ChatInputCommandInteraction) {
|
||||
const streamer = streamers.get(i.guildId!);
|
||||
if (!streamer) return i.reply({ content: "송출 중이 아닙니다.", ...eph });
|
||||
await streamer.stop();
|
||||
await i.reply({ content: "송출을 중단했습니다.", ...eph });
|
||||
}
|
||||
|
||||
async function handleStatus(i: ChatInputCommandInteraction) {
|
||||
await i.deferReply(eph);
|
||||
let brain = "unreachable";
|
||||
try {
|
||||
const h = await health();
|
||||
brain = h.brain_ready ? "ready" : `not-ready${h.brain_error ? " (" + h.brain_error + ")" : ""}`;
|
||||
} catch {
|
||||
/* keep unreachable */
|
||||
}
|
||||
const session = getSession(i.guildId!);
|
||||
const streamer = streamers.get(i.guildId!);
|
||||
await i.editReply(
|
||||
[
|
||||
`브릿지 두뇌: ${brain}`,
|
||||
`음성 세션: ${session ? "접속 중" : "없음"}`,
|
||||
`송출 백엔드: ${config.streamBackend} (${streamer?.isActive() ? "활성" : "대기"})`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
client.login(config.botToken);
|
||||
42
bot/src/register-commands.ts
Normal file
42
bot/src/register-commands.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Registers the /자비스 slash command (guild-scoped for instant availability).
|
||||
* Run once after changing the command shape: bun run register
|
||||
*/
|
||||
import { REST, Routes, SlashCommandBuilder } from "discord.js";
|
||||
import { config } from "./config.ts";
|
||||
|
||||
export const jarvisCommand = new SlashCommandBuilder()
|
||||
.setName("자비스")
|
||||
.setDescription("자비스 음성 비서를 제어합니다")
|
||||
.addSubcommand((s) =>
|
||||
s.setName("join").setDescription("당신이 있는 음성 채널에 접속해 듣기 시작합니다"),
|
||||
)
|
||||
.addSubcommand((s) => s.setName("leave").setDescription("음성 채널에서 나갑니다"))
|
||||
.addSubcommand((s) =>
|
||||
s
|
||||
.setName("ask")
|
||||
.setDescription("텍스트로 자비스에게 질문합니다")
|
||||
.addStringOption((o) =>
|
||||
o.setName("질문").setDescription("질문 내용").setRequired(true),
|
||||
),
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
s.setName("stream").setDescription("VNC 화면을 디스코드에 송출합니다"),
|
||||
)
|
||||
.addSubcommand((s) => s.setName("stop").setDescription("VNC 화면 송출을 중단합니다"))
|
||||
.addSubcommand((s) => s.setName("status").setDescription("브릿지/세션 상태를 봅니다"));
|
||||
|
||||
export async function registerCommands() {
|
||||
const rest = new REST({ version: "10" }).setToken(config.botToken);
|
||||
await rest.put(Routes.applicationGuildCommands(config.appId, config.guildId), {
|
||||
body: [jarvisCommand.toJSON()],
|
||||
});
|
||||
console.log("✓ /자비스 명령어 등록 완료 (guild:", config.guildId, ")");
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
registerCommands().catch((e) => {
|
||||
console.error("명령어 등록 실패:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
51
bot/src/stream/index.ts
Normal file
51
bot/src/stream/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Pluggable VNC screen-broadcast backends.
|
||||
*
|
||||
* Per the chosen design (option 1): the streaming method is swappable via
|
||||
* STREAM_BACKEND in .env. The default is the real live "Go Live" stream via a
|
||||
* selfbot account (only way to get a native Discord video broadcast), with safe
|
||||
* fallbacks (noVNC link / periodic screenshots) available without code changes.
|
||||
*/
|
||||
import type { AppConfig } from "../config.ts";
|
||||
|
||||
export interface StreamContext {
|
||||
guildId: string;
|
||||
voiceChannelId: string;
|
||||
/** Post an image to the invoking text channel (used by the screenshot backend). */
|
||||
postImage?: (png: Buffer, name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ScreenStreamer {
|
||||
readonly kind: AppConfig["streamBackend"];
|
||||
/** Start broadcasting. Returns a short user-facing status/link message. */
|
||||
start(ctx: StreamContext): Promise<string>;
|
||||
stop(): Promise<void>;
|
||||
isActive(): boolean;
|
||||
}
|
||||
|
||||
export async function createStreamer(config: AppConfig): Promise<ScreenStreamer> {
|
||||
switch (config.streamBackend) {
|
||||
case "selfbot": {
|
||||
const { SelfbotStreamer } = await import("./selfbot.ts");
|
||||
return new SelfbotStreamer(config);
|
||||
}
|
||||
case "novnc": {
|
||||
const { NoVncStreamer } = await import("./novnc.ts");
|
||||
return new NoVncStreamer(config);
|
||||
}
|
||||
case "screenshot": {
|
||||
const { ScreenshotStreamer } = await import("./screenshot.ts");
|
||||
return new ScreenshotStreamer(config);
|
||||
}
|
||||
case "none":
|
||||
default:
|
||||
return {
|
||||
kind: "none",
|
||||
async start() {
|
||||
return "화면 송출이 비활성화되어 있습니다 (STREAM_BACKEND=none).";
|
||||
},
|
||||
async stop() {},
|
||||
isActive: () => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
34
bot/src/stream/novnc.ts
Normal file
34
bot/src/stream/novnc.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* noVNC link backend (safe, real-time, no ban risk).
|
||||
*
|
||||
* Does not broadcast natively into Discord. Instead it shares a noVNC web URL
|
||||
* that anyone can open in a browser to watch (and optionally control) the VNC
|
||||
* desktop live. Set NOVNC_URL in .env (e.g. http://192.168.10.9:6080/vnc.html).
|
||||
*
|
||||
* Stand up noVNC once on the host with websockify, e.g.:
|
||||
* websockify --web=/usr/share/novnc 6080 localhost:5901
|
||||
*/
|
||||
import type { AppConfig } from "../config.ts";
|
||||
import type { ScreenStreamer, StreamContext } from "./index.ts";
|
||||
|
||||
export class NoVncStreamer implements ScreenStreamer {
|
||||
readonly kind = "novnc" as const;
|
||||
private active = false;
|
||||
constructor(private config: AppConfig) {}
|
||||
|
||||
isActive() {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
async start(_ctx: StreamContext): Promise<string> {
|
||||
if (!this.config.novncUrl) {
|
||||
return "NOVNC_URL이 설정되지 않았습니다 (.env). 예: http://192.168.10.9:6080/vnc.html";
|
||||
}
|
||||
this.active = true;
|
||||
return `🖥️ VNC 화면 실시간 보기 (브라우저): ${this.config.novncUrl}`;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
62
bot/src/stream/screenshot.ts
Normal file
62
bot/src/stream/screenshot.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Screenshot backend (safe, no ban risk, not real-time).
|
||||
*
|
||||
* Periodically grabs a frame from the VNC X display with ffmpeg's x11grab and
|
||||
* posts it to the invoking text channel. Low FPS, but works with a normal bot
|
||||
* account and never touches Discord's selfbot surface.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import type { AppConfig } from "../config.ts";
|
||||
import type { ScreenStreamer, StreamContext } from "./index.ts";
|
||||
|
||||
function grabFrame(display: string, size: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ff = spawn("ffmpeg", [
|
||||
"-loglevel", "error",
|
||||
"-f", "x11grab",
|
||||
"-video_size", size,
|
||||
"-i", display,
|
||||
"-frames:v", "1",
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "png",
|
||||
"pipe:1",
|
||||
]);
|
||||
const chunks: Buffer[] = [];
|
||||
ff.stdout.on("data", (c) => chunks.push(c));
|
||||
ff.on("error", reject);
|
||||
ff.on("close", (code) =>
|
||||
code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(`ffmpeg exited ${code}`)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export class ScreenshotStreamer implements ScreenStreamer {
|
||||
readonly kind = "screenshot" as const;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
constructor(private config: AppConfig) {}
|
||||
|
||||
isActive() {
|
||||
return this.timer !== null;
|
||||
}
|
||||
|
||||
async start(ctx: StreamContext): Promise<string> {
|
||||
if (!ctx.postImage) return "스크린샷을 올릴 텍스트 채널 컨텍스트가 없습니다.";
|
||||
if (this.timer) return "이미 스크린샷 송출 중입니다.";
|
||||
const tick = async () => {
|
||||
try {
|
||||
const png = await grabFrame(this.config.vncDisplay, this.config.vncResolution);
|
||||
await ctx.postImage!(png, "vnc.png");
|
||||
} catch (e) {
|
||||
console.error("[screenshot] grab failed:", e);
|
||||
}
|
||||
};
|
||||
this.timer = setInterval(tick, this.config.screenshotIntervalSec * 1000);
|
||||
void tick();
|
||||
return `📸 ${this.config.screenshotIntervalSec}초마다 VNC 스크린샷을 이 채널에 올립니다.`;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
116
bot/src/stream/selfbot.ts
Normal file
116
bot/src/stream/selfbot.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Selfbot live-stream backend (default).
|
||||
*
|
||||
* Streams the VNC X display (:1) into the voice channel as a real Discord
|
||||
* "Go Live" broadcast. Discord blocks video from *bot* accounts, so this path
|
||||
* requires a USER account token (a "selfbot"), which violates Discord ToS and
|
||||
* can get the account banned. Use a throwaway/burner account, never your main.
|
||||
*
|
||||
* Dependencies are optional (native): install with
|
||||
* bun add discord.js-selfbot-v13 @dank074/discord-video-stream
|
||||
* They are dynamically imported so the core bot installs/runs without them.
|
||||
*
|
||||
* Library API targets @dank074/discord-video-stream v6 (Streamer / prepareStream
|
||||
* / playStream). If a different major is installed, the import guard below will
|
||||
* point you at the docs rather than crash cryptically.
|
||||
*/
|
||||
import type { AppConfig } from "../config.ts";
|
||||
import type { ScreenStreamer, StreamContext } from "./index.ts";
|
||||
|
||||
export class SelfbotStreamer implements ScreenStreamer {
|
||||
readonly kind = "selfbot" as const;
|
||||
private config: AppConfig;
|
||||
private streamer: any = null;
|
||||
private controller: AbortController | null = null;
|
||||
private active = false;
|
||||
|
||||
constructor(config: AppConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
private async loadLib() {
|
||||
let selfbot: any, videoStream: any;
|
||||
try {
|
||||
selfbot = await import("discord.js-selfbot-v13");
|
||||
// Optional native dep; resolved at runtime only. Version/name can vary by
|
||||
// upstream release, so we don't hard-bind its types at compile time.
|
||||
// @ts-ignore - optional dependency, may be absent until `bun add`ed
|
||||
videoStream = await import("@dank074/discord-video-stream");
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
"셀프봇 송출 의존성이 없습니다. 설치: bun add discord.js-selfbot-v13 @dank074/discord-video-stream\n" +
|
||||
`원본 오류: ${(e as Error).message}`,
|
||||
);
|
||||
}
|
||||
if (!videoStream.Streamer || !videoStream.prepareStream || !videoStream.playStream) {
|
||||
throw new Error(
|
||||
"@dank074/discord-video-stream v6 API(Streamer/prepareStream/playStream)를 찾지 못했습니다. " +
|
||||
"package.json 버전을 ^4.2.1(=v6 npm 태그)로 맞추거나 docs를 확인하세요.",
|
||||
);
|
||||
}
|
||||
return { selfbot, videoStream };
|
||||
}
|
||||
|
||||
async start(ctx: StreamContext): Promise<string> {
|
||||
if (this.active) return "이미 송출 중입니다.";
|
||||
if (!this.config.selfbotToken) {
|
||||
return "DISCORD_SELFBOT_TOKEN이 설정되지 않았습니다 (.env). 버너 계정 토큰을 넣어주세요.";
|
||||
}
|
||||
const { selfbot, videoStream } = await this.loadLib();
|
||||
const { Streamer, prepareStream, playStream, Utils } = videoStream;
|
||||
|
||||
this.streamer = new Streamer(new selfbot.Client());
|
||||
await this.streamer.client.login(this.config.selfbotToken);
|
||||
await this.streamer.joinVoice(ctx.guildId, ctx.voiceChannelId);
|
||||
|
||||
// Grab the VNC X display with ffmpeg's x11grab and let the library
|
||||
// encode/transport it. NVENC (RTX 5050) is used if available.
|
||||
const input = `x11grab:${this.config.vncDisplay}`;
|
||||
const { command, output } = prepareStream(
|
||||
input,
|
||||
{
|
||||
width: parseInt(this.config.vncResolution.split("x")[0] ?? "1920", 10),
|
||||
height: parseInt(this.config.vncResolution.split("x")[1] ?? "1080", 10),
|
||||
frameRate: this.config.vncFramerate,
|
||||
bitrateVideo: this.config.vncBitrateKbps,
|
||||
videoCodec: Utils?.normalizeVideoCodec ? Utils.normalizeVideoCodec("H264") : "H264",
|
||||
// x11grab needs to be set as the input format for ffmpeg
|
||||
customHeaders: undefined,
|
||||
inputFormat: "x11grab",
|
||||
inputSize: this.config.vncResolution,
|
||||
},
|
||||
(this.controller = new AbortController()).signal,
|
||||
);
|
||||
|
||||
command.on("error", (err: Error) => {
|
||||
if (!this.controller?.signal.aborted) console.error("[selfbot] ffmpeg error:", err);
|
||||
});
|
||||
|
||||
this.active = true;
|
||||
// Fire-and-forget; resolves when the stream ends.
|
||||
playStream(output, this.streamer, { type: "go-live" })
|
||||
.catch((err: Error) => console.error("[selfbot] playStream:", err))
|
||||
.finally(() => {
|
||||
this.active = false;
|
||||
});
|
||||
|
||||
return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live).";
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.controller?.abort();
|
||||
this.controller = null;
|
||||
try {
|
||||
this.streamer?.leaveVoice?.();
|
||||
this.streamer?.client?.destroy?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.streamer = null;
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
169
bot/src/voice.ts
Normal file
169
bot/src/voice.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Discord voice I/O.
|
||||
*
|
||||
* - Joins the caller's voice channel.
|
||||
* - Receives each speaker's Opus stream, decodes to PCM, and on end-of-speech
|
||||
* forwards the utterance (as a WAV) to the brain bridge.
|
||||
* - Plays the brain's spoken reply back into the channel.
|
||||
*
|
||||
* No AI logic here — capture in, audio out. The brain lives in bridge/.
|
||||
*/
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
joinVoiceChannel,
|
||||
createAudioPlayer,
|
||||
createAudioResource,
|
||||
EndBehaviorType,
|
||||
StreamType,
|
||||
VoiceConnection,
|
||||
VoiceConnectionStatus,
|
||||
entersState,
|
||||
type AudioPlayer,
|
||||
} from "@discordjs/voice";
|
||||
import prism from "prism-media";
|
||||
import type { VoiceBasedChannel } from "discord.js";
|
||||
import { converse, decodeWav } from "./bridge.ts";
|
||||
import { config } from "./config.ts";
|
||||
|
||||
const DISCORD_RATE = 48000;
|
||||
const DISCORD_CHANNELS = 2;
|
||||
|
||||
/** Build a minimal PCM16 mono WAV around raw little-endian samples. */
|
||||
function pcm16MonoToWav(pcm: Buffer, sampleRate: number): Buffer {
|
||||
const header = Buffer.alloc(44);
|
||||
const dataLen = pcm.length;
|
||||
header.write("RIFF", 0);
|
||||
header.writeUInt32LE(36 + dataLen, 4);
|
||||
header.write("WAVE", 8);
|
||||
header.write("fmt ", 12);
|
||||
header.writeUInt32LE(16, 16);
|
||||
header.writeUInt16LE(1, 20); // PCM
|
||||
header.writeUInt16LE(1, 22); // mono
|
||||
header.writeUInt32LE(sampleRate, 24);
|
||||
header.writeUInt32LE(sampleRate * 2, 28); // byte rate (mono * 2 bytes)
|
||||
header.writeUInt16LE(2, 32); // block align
|
||||
header.writeUInt16LE(16, 34); // bits per sample
|
||||
header.write("data", 36);
|
||||
header.writeUInt32LE(dataLen, 40);
|
||||
return Buffer.concat([header, pcm]);
|
||||
}
|
||||
|
||||
/** Downmix interleaved stereo PCM16 to mono PCM16. */
|
||||
function stereoToMono(stereo: Buffer): Buffer {
|
||||
const samples = stereo.length / 4; // 2 ch * 2 bytes
|
||||
const mono = Buffer.alloc(samples * 2);
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const l = stereo.readInt16LE(i * 4);
|
||||
const r = stereo.readInt16LE(i * 4 + 2);
|
||||
mono.writeInt16LE((l + r) >> 1, i * 2);
|
||||
}
|
||||
return mono;
|
||||
}
|
||||
|
||||
export class VoiceSession {
|
||||
readonly guildId: string;
|
||||
private connection: VoiceConnection;
|
||||
private player: AudioPlayer;
|
||||
private listening = new Set<string>();
|
||||
/** Optional callback to surface transcripts/replies to a text channel. */
|
||||
onTurn?: (info: { user: string; transcript: string; reply: string }) => void;
|
||||
|
||||
constructor(channel: VoiceBasedChannel) {
|
||||
this.guildId = channel.guild.id;
|
||||
this.connection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: channel.guild.id,
|
||||
adapterCreator: channel.guild.voiceAdapterCreator,
|
||||
selfDeaf: false, // we need to hear users
|
||||
selfMute: false,
|
||||
});
|
||||
this.player = createAudioPlayer();
|
||||
this.connection.subscribe(this.player);
|
||||
this.attachReceiver();
|
||||
}
|
||||
|
||||
async ready(): Promise<void> {
|
||||
await entersState(this.connection, VoiceConnectionStatus.Ready, 20_000);
|
||||
}
|
||||
|
||||
private attachReceiver() {
|
||||
const receiver = this.connection.receiver;
|
||||
receiver.speaking.on("start", (userId: string) => {
|
||||
if (this.listening.has(userId)) return;
|
||||
this.listening.add(userId);
|
||||
this.captureUtterance(userId).finally(() => this.listening.delete(userId));
|
||||
});
|
||||
}
|
||||
|
||||
private async captureUtterance(userId: string): Promise<void> {
|
||||
const opusStream = this.connection.receiver.subscribe(userId, {
|
||||
end: { behavior: EndBehaviorType.AfterSilence, duration: config.silenceMs },
|
||||
});
|
||||
const decoder = new prism.opus.Decoder({
|
||||
frameSize: 960,
|
||||
channels: DISCORD_CHANNELS,
|
||||
rate: DISCORD_RATE,
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
const pcmStream = opusStream.pipe(decoder);
|
||||
pcmStream.on("data", (c: Buffer) => chunks.push(c));
|
||||
|
||||
await new Promise<void>((resolve) => pcmStream.once("end", () => resolve()));
|
||||
|
||||
if (!chunks.length) return;
|
||||
const mono = stereoToMono(Buffer.concat(chunks));
|
||||
// Ignore blips shorter than ~300ms (likely noise / key clicks).
|
||||
if (mono.length < DISCORD_RATE * 0.3 * 2) return;
|
||||
const wav = pcm16MonoToWav(mono, DISCORD_RATE);
|
||||
|
||||
try {
|
||||
const result = await converse(wav);
|
||||
if (result.transcript) {
|
||||
this.onTurn?.({ user: userId, transcript: result.transcript, reply: result.reply });
|
||||
}
|
||||
const audio = decodeWav(result.audio_b64);
|
||||
if (audio) this.play(audio);
|
||||
} catch (err) {
|
||||
console.error("[voice] converse failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Play a WAV buffer into the channel. */
|
||||
play(wav: Buffer) {
|
||||
const resource = createAudioResource(Readable.from(wav), {
|
||||
inputType: StreamType.Arbitrary,
|
||||
});
|
||||
this.player.play(resource);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
try {
|
||||
this.connection.destroy();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One session per guild. */
|
||||
const sessions = new Map<string, VoiceSession>();
|
||||
|
||||
export async function joinChannel(channel: VoiceBasedChannel): Promise<VoiceSession> {
|
||||
sessions.get(channel.guild.id)?.destroy();
|
||||
const session = new VoiceSession(channel);
|
||||
sessions.set(channel.guild.id, session);
|
||||
await session.ready();
|
||||
return session;
|
||||
}
|
||||
|
||||
export function leaveGuild(guildId: string): boolean {
|
||||
const s = sessions.get(guildId);
|
||||
if (!s) return false;
|
||||
s.destroy();
|
||||
sessions.delete(guildId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getSession(guildId: string): VoiceSession | undefined {
|
||||
return sessions.get(guildId);
|
||||
}
|
||||
17
bot/tsconfig.json
Normal file
17
bot/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user