Discord: add voice channel support

This commit is contained in:
Shadow
2026-02-16 18:57:27 -06:00
parent 5a26d1c622
commit 3d24b92d85
9 changed files with 1100 additions and 25 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus.
- Discord: allow reusable interactive components with `components.reusable=true` so buttons, selects, and forms can be used multiple times before expiring. Thanks @thewilloftheshadow.
- Discord: add per-button `allowedUsers` allowlist for interactive components to restrict who can click buttons. Thanks @thewilloftheshadow.
- Discord: add voice channel join/leave/status via `/vc`, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow.
### Fixes

View File

@@ -130,6 +130,8 @@
"@aws-sdk/client-bedrock": "^3.990.0",
"@buape/carbon": "0.0.0-beta-20260216184201",
"@clack/prompts": "^1.0.1",
"@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.19.0",
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.5",
@@ -221,6 +223,7 @@
"tough-cookie": "4.1.3"
},
"onlyBuiltDependencies": [
"@discordjs/opus",
"@lydell/node-pty",
"@matrix-org/matrix-sdk-crypto-nodejs",
"@napi-rs/canvas",

227
pnpm-lock.yaml generated
View File

@@ -24,10 +24,16 @@ importers:
version: 3.990.0
'@buape/carbon':
specifier: 0.0.0-beta-20260216184201
version: 0.0.0-beta-20260216184201(hono@4.11.9)
version: 0.0.0-beta-20260216184201(@discordjs/opus@0.9.0)(hono@4.11.9)
'@clack/prompts':
specifier: ^1.0.1
version: 1.0.1
'@discordjs/opus':
specifier: ^0.9.0
version: 0.9.0
'@discordjs/voice':
specifier: ^0.19.0
version: 0.19.0(@discordjs/opus@0.9.0)
'@grammyjs/runner':
specifier: ^2.0.3
version: 2.0.3(grammy@1.40.0)
@@ -879,6 +885,14 @@ packages:
'@d-fischer/typed-event-emitter@3.3.3':
resolution: {integrity: sha512-OvSEOa8icfdWDqcRtjSEZtgJTFOFNgTjje7zaL0+nAtu2/kZtRCSK5wUMrI/aXtCH8o0Qz2vA8UqkhWUTARFQQ==}
'@discordjs/node-pre-gyp@0.4.5':
resolution: {integrity: sha512-YJOVVZ545x24mHzANfYoy0BJX5PDyeZlpiJjDkUBM/V/Ao7TFX9lcUvCN4nr0tbr5ubeaXxtEBILUrHtTphVeQ==}
hasBin: true
'@discordjs/opus@0.9.0':
resolution: {integrity: sha512-NEE76A96FtQ5YuoAVlOlB3ryMPrkXbUCTQICHGKb8ShtjXyubGicjRMouHtP1RpuDdm16cDa+oI3aAMo1zQRUQ==}
engines: {node: '>=12.0.0'}
'@discordjs/voice@0.19.0':
resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==}
engines: {node: '>=22.12.0'}
@@ -3127,6 +3141,9 @@ packages:
resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67}
version: 2.0.1
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@@ -3149,6 +3166,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -3204,6 +3225,11 @@ packages:
aproba@2.1.0:
resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
are-we-there-yet@2.0.0:
resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==}
engines: {node: '>=10'}
deprecated: This package is no longer supported.
are-we-there-yet@3.0.1:
resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -3330,6 +3356,9 @@ packages:
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
@@ -3461,6 +3490,9 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
@@ -3847,6 +3879,9 @@ packages:
resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==}
engines: {node: '>=14.14'}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3860,6 +3895,11 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gauge@3.0.2:
resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==}
engines: {node: '>=10'}
deprecated: This package is no longer supported.
gauge@4.0.4:
resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -3911,6 +3951,10 @@ packages:
resolution: {integrity: sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA==}
engines: {node: 20 || >=22}
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
google-auth-library@10.5.0:
resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==}
engines: {node: '>=18'}
@@ -4021,6 +4065,10 @@ packages:
resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==}
engines: {node: '>=0.8', npm: '>=1.3.7'}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@@ -4050,6 +4098,10 @@ packages:
resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==}
engines: {node: '>=20.19.0'}
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -4419,6 +4471,10 @@ packages:
magicast@0.5.2:
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
@@ -4498,6 +4554,9 @@ packages:
resolution: {integrity: sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==}
engines: {node: 20 || >=22}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -4567,6 +4626,9 @@ packages:
resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
engines: {node: '>= 0.4.0'}
node-addon-api@5.1.0:
resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==}
node-addon-api@8.5.0:
resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
engines: {node: ^18 || ^20 || >= 21}
@@ -4618,6 +4680,11 @@ packages:
resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==}
engines: {node: '>=4.4.0'}
nopt@5.0.0:
resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==}
engines: {node: '>=6'}
hasBin: true
nostr-tools@2.23.1:
resolution: {integrity: sha512-Q5SJ1omrseBFXtLwqDhufpFLA6vX3rS/IuBCc974qaYX6YKGwEPxa/ZsyxruUOr+b+5EpWL2hFmCB5AueYrfBw==}
peerDependencies:
@@ -4629,6 +4696,10 @@ packages:
nostr-wasm@0.1.0:
resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==}
npmlog@5.0.1:
resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==}
deprecated: This package is no longer supported.
npmlog@6.0.2:
resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -4814,6 +4885,10 @@ packages:
partial-json@0.1.7:
resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==}
path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -5070,6 +5145,11 @@ packages:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rimraf@5.0.10:
resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
hasBin: true
@@ -5131,6 +5211,10 @@ packages:
selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
@@ -6315,13 +6399,13 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
'@buape/carbon@0.0.0-beta-20260216184201(hono@4.11.9)':
'@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.9.0)(hono@4.11.9)':
dependencies:
'@types/node': 25.2.3
discord-api-types: 0.38.37
optionalDependencies:
'@cloudflare/workers-types': 4.20260120.0
'@discordjs/voice': 0.19.0
'@discordjs/voice': 0.19.0(@discordjs/opus@0.9.0)
'@hono/node-server': 1.19.9(hono@4.11.9)
'@types/bun': 1.3.9
'@types/ws': 8.18.1
@@ -6415,11 +6499,34 @@ snapshots:
dependencies:
tslib: 2.8.1
'@discordjs/voice@0.19.0':
'@discordjs/node-pre-gyp@0.4.5':
dependencies:
detect-libc: 2.1.2
https-proxy-agent: 5.0.1
make-dir: 3.1.0
node-fetch: 2.7.0
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.7.4
tar: 7.5.9
transitivePeerDependencies:
- encoding
- supports-color
'@discordjs/opus@0.9.0':
dependencies:
'@discordjs/node-pre-gyp': 0.4.5
node-addon-api: 5.1.0
transitivePeerDependencies:
- encoding
- supports-color
'@discordjs/voice@0.19.0(@discordjs/opus@0.9.0)':
dependencies:
'@types/ws': 8.18.1
discord-api-types: 0.38.39
prism-media: 1.3.5
prism-media: 1.3.5(@discordjs/opus@0.9.0)
tslib: 2.8.1
ws: 8.19.0
transitivePeerDependencies:
@@ -6429,7 +6536,6 @@ snapshots:
- node-opus
- opusscript
- utf-8-validate
optional: true
'@emnapi/core@1.8.1':
dependencies:
@@ -6764,7 +6870,7 @@ snapshots:
'@larksuiteoapi/node-sdk@1.59.0':
dependencies:
axios: 1.13.5
axios: 1.13.5(debug@4.4.3)
lodash.identity: 3.0.0
lodash.merge: 4.6.2
lodash.pickby: 4.6.0
@@ -6780,7 +6886,7 @@ snapshots:
dependencies:
'@types/node': 24.10.13
optionalDependencies:
axios: 1.13.5
axios: 1.13.5(debug@4.4.3)
transitivePeerDependencies:
- debug
@@ -6983,7 +7089,7 @@ snapshots:
'@azure/core-auth': 1.10.1
'@azure/msal-node': 3.8.7
'@microsoft/agents-activity': 1.2.3
axios: 1.13.5
axios: 1.13.5(debug@4.4.3)
jsonwebtoken: 9.0.3
jwks-rsa: 3.2.2
object-path: 0.11.8
@@ -7882,7 +7988,7 @@ snapshots:
'@slack/types': 2.20.0
'@slack/web-api': 7.14.1
'@types/express': 5.0.6
axios: 1.13.5
axios: 1.13.5(debug@4.4.3)
express: 5.2.1
path-to-regexp: 8.3.0
raw-body: 3.0.2
@@ -7928,7 +8034,7 @@ snapshots:
'@slack/types': 2.20.0
'@types/node': 25.2.3
'@types/retry': 0.12.0
axios: 1.13.5
axios: 1.13.5(debug@4.4.3)
eventemitter3: 5.0.4
form-data: 2.5.4
is-electron: 2.2.2
@@ -8671,6 +8777,8 @@ snapshots:
curve25519-js: 0.0.4
protobufjs: 6.8.8
abbrev@1.1.1: {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
@@ -8691,6 +8799,12 @@ snapshots:
acorn@8.15.0: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
agent-base@7.1.4: {}
ajv-formats@3.0.1(ajv@8.18.0):
@@ -8743,6 +8857,11 @@ snapshots:
aproba@2.1.0: {}
are-we-there-yet@2.0.0:
dependencies:
delegates: 1.0.0
readable-stream: 3.6.2
are-we-there-yet@3.0.1:
dependencies:
delegates: 1.0.0
@@ -8816,14 +8935,6 @@ snapshots:
aws4@1.13.2: {}
axios@1.13.5:
dependencies:
follow-redirects: 1.15.11
form-data: 2.5.4
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axios@1.13.5(debug@4.4.3):
dependencies:
follow-redirects: 1.15.11(debug@4.4.3)
@@ -8895,6 +9006,11 @@ snapshots:
bowser@2.14.1: {}
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.0.2:
dependencies:
balanced-match: 1.0.2
@@ -9038,6 +9154,8 @@ snapshots:
commander@14.0.3: {}
concat-map@0.0.1: {}
console-control-strings@1.1.0: {}
content-disposition@0.5.4:
@@ -9403,8 +9521,6 @@ snapshots:
flatbuffers@24.12.23: {}
follow-redirects@1.15.11: {}
follow-redirects@1.15.11(debug@4.4.3):
optionalDependencies:
debug: 4.4.3
@@ -9441,6 +9557,8 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
@@ -9449,6 +9567,18 @@ snapshots:
function-bind@1.1.2: {}
gauge@3.0.2:
dependencies:
aproba: 2.1.0
color-support: 1.1.3
console-control-strings: 1.1.0
has-unicode: 2.0.1
object-assign: 4.1.1
signal-exit: 3.0.7
string-width: 4.2.3
strip-ansi: 6.0.1
wide-align: 1.1.5
gauge@4.0.4:
dependencies:
aproba: 2.1.0
@@ -9532,6 +9662,15 @@ snapshots:
minipass: 7.1.2
path-scurry: 2.0.1
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
minimatch: 3.1.2
once: 1.4.0
path-is-absolute: 1.0.1
google-auth-library@10.5.0:
dependencies:
base64-js: 1.5.1
@@ -9661,6 +9800,13 @@ snapshots:
jsprim: 1.4.2
sshpk: 1.18.0
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -9691,6 +9837,11 @@ snapshots:
import-without-cache@0.2.5: {}
inflight@1.0.6:
dependencies:
once: 1.4.0
wrappy: 1.0.2
inherits@2.0.4: {}
ini@1.3.8: {}
@@ -10053,6 +10204,10 @@ snapshots:
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-dir@3.1.0:
dependencies:
semver: 6.3.1
make-dir@4.0.0:
dependencies:
semver: 7.7.4
@@ -10110,6 +10265,10 @@ snapshots:
dependencies:
brace-expansion: 5.0.2
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.2
@@ -10178,6 +10337,8 @@ snapshots:
netmask@2.0.2: {}
node-addon-api@5.1.0: {}
node-addon-api@8.5.0: {}
node-api-headers@1.8.0: {}
@@ -10261,6 +10422,10 @@ snapshots:
node-wav@0.0.2:
optional: true
nopt@5.0.0:
dependencies:
abbrev: 1.1.1
nostr-tools@2.23.1(typescript@5.9.3):
dependencies:
'@noble/ciphers': 2.1.1
@@ -10275,6 +10440,13 @@ snapshots:
nostr-wasm@0.1.0: {}
npmlog@5.0.1:
dependencies:
are-we-there-yet: 2.0.0
console-control-strings: 1.1.0
gauge: 3.0.2
set-blocking: 2.0.0
npmlog@6.0.2:
dependencies:
are-we-there-yet: 3.0.1
@@ -10499,6 +10671,8 @@ snapshots:
partial-json@0.1.7: {}
path-is-absolute@1.0.1: {}
path-key@3.1.1: {}
path-scurry@1.11.1:
@@ -10584,8 +10758,9 @@ snapshots:
dependencies:
parse-ms: 4.0.0
prism-media@1.3.5:
optional: true
prism-media@1.3.5(@discordjs/opus@0.9.0):
optionalDependencies:
'@discordjs/opus': 0.9.0
process-nextick-args@2.0.1: {}
@@ -10797,6 +10972,10 @@ snapshots:
retry@0.13.1: {}
rimraf@3.0.2:
dependencies:
glob: 7.2.3
rimraf@5.0.10:
dependencies:
glob: 10.5.0
@@ -10919,6 +11098,8 @@ snapshots:
dependencies:
parseley: 0.12.1
semver@6.3.1: {}
semver@7.7.4: {}
send@0.19.2:

View File

@@ -91,6 +91,20 @@ export type DiscordIntentsConfig = {
guildMembers?: boolean;
};
export type DiscordVoiceAutoJoinConfig = {
/** Guild ID that owns the voice channel. */
guildId: string;
/** Voice channel ID to join. */
channelId: string;
};
export type DiscordVoiceConfig = {
/** Enable Discord voice channel conversations (default: true). */
enabled?: boolean;
/** Voice channels to auto-join on startup. */
autoJoin?: DiscordVoiceAutoJoinConfig[];
};
export type DiscordExecApprovalConfig = {
/** Enable exec approval forwarding to Discord DMs. Default: false. */
enabled?: boolean;
@@ -196,6 +210,8 @@ export type DiscordAccountConfig = {
ui?: DiscordUiConfig;
/** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */
intents?: DiscordIntentsConfig;
/** Voice channel conversation settings. */
voice?: DiscordVoiceConfig;
/** PluralKit identity resolution for proxied messages. */
pluralkit?: DiscordPluralKitConfig;
/** Outbound response prefix override for this channel/account. */

View File

@@ -268,6 +268,21 @@ const DiscordUiSchema = z
.strict()
.optional();
const DiscordVoiceAutoJoinSchema = z
.object({
guildId: z.string().min(1),
channelId: z.string().min(1),
})
.strict();
const DiscordVoiceSchema = z
.object({
enabled: z.boolean().optional(),
autoJoin: z.array(DiscordVoiceAutoJoinSchema).optional(),
})
.strict()
.optional();
export const DiscordAccountSchema = z
.object({
name: z.string().optional(),
@@ -341,6 +356,7 @@ export const DiscordAccountSchema = z
})
.strict()
.optional(),
voice: DiscordVoiceSchema,
pluralkit: z
.object({
enabled: z.boolean().optional(),

View File

@@ -14,7 +14,8 @@ export function resolveDiscordGatewayIntents(
GatewayIntents.MessageContent |
GatewayIntents.DirectMessages |
GatewayIntents.GuildMessageReactions |
GatewayIntents.DirectMessageReactions;
GatewayIntents.DirectMessageReactions |
GatewayIntents.GuildVoiceStates;
if (intentsConfig?.presence) {
intents |= GatewayIntents.GuildPresences;
}

View File

@@ -5,6 +5,7 @@ import {
type BaseMessageInteractiveComponent,
type Modal,
} from "@buape/carbon";
import { VoicePlugin } from "@buape/carbon/voice";
import { Routes } from "discord-api-types/v10";
import { inspect } from "node:util";
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
@@ -38,6 +39,8 @@ import { fetchDiscordApplicationId } from "../probe.js";
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
import { normalizeDiscordToken } from "../token.js";
import { createDiscordVoiceCommand } from "../voice/command.js";
import { DiscordVoiceManager, DiscordVoiceReadyListener } from "../voice/manager.js";
import {
createAgentComponentButton,
createAgentSelectMenu,
@@ -229,6 +232,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const sessionPrefix = "discord:slash";
const ephemeralDefault = true;
const voiceEnabled = Boolean(discordCfg.voice) && discordCfg.voice?.enabled !== false;
if (token) {
if (guildEntries && Object.keys(guildEntries).length > 0) {
@@ -413,6 +417,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
),
);
}
const voiceManagerRef: { current: DiscordVoiceManager | null } = { current: null };
const commands = commandSpecs.map((spec) =>
createDiscordNativeCommand({
command: spec,
@@ -423,6 +428,19 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
ephemeralDefault,
}),
);
if (nativeEnabled && voiceEnabled) {
commands.push(
createDiscordVoiceCommand({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
groupPolicy,
useAccessGroups,
getManager: () => voiceManagerRef.current,
ephemeralDefault,
}),
);
}
// Initialize exec approvals handler if enabled
const execApprovalsConfig = discordCfg.execApprovals ?? {};
@@ -491,6 +509,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
}
const clientPlugins = [createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime })];
if (voiceEnabled) {
clientPlugins.push(new VoicePlugin());
}
const client = new Client(
{
baseUrl: "http://localhost",
@@ -506,7 +528,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
components,
modals,
},
[createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime })],
clientPlugins,
);
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
@@ -514,6 +536,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const logger = createSubsystemLogger("discord/monitor");
const guildHistories = new Map<string, HistoryEntry[]>();
let botUserId: string | undefined;
let voiceManager: DiscordVoiceManager | null = null;
if (nativeDisabledExplicit) {
await clearDiscordNativeCommands({
@@ -530,6 +553,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`));
}
if (voiceEnabled) {
voiceManager = new DiscordVoiceManager({
client,
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
runtime,
botUserId,
});
voiceManagerRef.current = voiceManager;
registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager));
void voiceManager.autoJoin();
}
const messageHandler = createDiscordMessageHandler({
cfg,
discordConfig: discordCfg,
@@ -665,6 +702,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
gatewayEmitter?.removeListener("debug", onGatewayDebug);
abortSignal?.removeEventListener("abort", onAbort);
if (voiceManager) {
await voiceManager.destroy();
voiceManagerRef.current = null;
}
if (execApprovalsHandler) {
await execApprovalsHandler.stop();
}

View File

@@ -0,0 +1,295 @@
import {
ChannelType as CarbonChannelType,
Command,
CommandWithSubcommands,
type CommandInteraction,
} from "@buape/carbon";
import {
ApplicationCommandOptionType,
ChannelType as DiscordChannelType,
} from "discord-api-types/v10";
import type { OpenClawConfig } from "../../config/config.js";
import type { DiscordAccountConfig } from "../../config/types.js";
import type { DiscordVoiceManager } from "./manager.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import {
allowListMatches,
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
} from "../monitor/allow-list.js";
import { resolveDiscordChannelInfo } from "../monitor/message-utils.js";
import { resolveDiscordSenderIdentity } from "../monitor/sender-identity.js";
import { resolveDiscordThreadParentInfo } from "../monitor/threading.js";
const VOICE_CHANNEL_TYPES: DiscordChannelType[] = [
DiscordChannelType.GuildVoice,
DiscordChannelType.GuildStageVoice,
];
type VoiceCommandContext = {
cfg: OpenClawConfig;
discordConfig: DiscordAccountConfig;
accountId: string;
groupPolicy: "open" | "disabled" | "allowlist";
useAccessGroups: boolean;
getManager: () => DiscordVoiceManager | null;
ephemeralDefault: boolean;
};
async function authorizeVoiceCommand(
interaction: CommandInteraction,
params: VoiceCommandContext,
): Promise<{ ok: boolean; message?: string; guildId?: string }> {
const channel = interaction.channel;
if (!interaction.guild) {
return { ok: false, message: "Voice commands are only available in guilds." };
}
const user = interaction.user;
if (!user) {
return { ok: false, message: "Unable to resolve command user." };
}
const channelId = channel?.id ?? "";
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelInfo = channelId
? await resolveDiscordChannelInfo(interaction.client, channelId)
: null;
const isThreadChannel =
channelInfo?.type === CarbonChannelType.PublicThread ||
channelInfo?.type === CarbonChannelType.PrivateThread ||
channelInfo?.type === CarbonChannelType.AnnouncementThread;
let parentId: string | undefined;
let parentName: string | undefined;
let parentSlug: string | undefined;
if (isThreadChannel && channelId) {
const parentInfo = await resolveDiscordThreadParentInfo({
client: interaction.client,
threadChannel: {
id: channelId,
name: channelName,
parentId:
"parentId" in (channel ?? {})
? ((channel as { parentId?: string }).parentId ?? undefined)
: undefined,
parent: undefined,
},
channelInfo,
});
parentId = parentInfo.id;
parentName = parentInfo.name;
parentSlug = parentName ? normalizeDiscordSlug(parentName) : undefined;
}
const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined,
guildEntries: params.discordConfig.guilds,
});
const channelConfig = channelId
? resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId,
channelName,
channelSlug,
parentId,
parentName,
parentSlug,
scope: isThreadChannel ? "thread" : "channel",
})
: null;
if (channelConfig?.enabled === false) {
return { ok: false, message: "This channel is disabled." };
}
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
if (
!isDiscordGroupAllowedByPolicy({
groupPolicy: params.groupPolicy,
guildAllowlisted: Boolean(guildInfo),
channelAllowlistConfigured,
channelAllowed,
}) ||
channelConfig?.allowed === false
) {
return { ok: false, message: "This channel is not allowlisted for commands." };
}
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
const sender = resolveDiscordSenderIdentity({ author: user, member: interaction.rawData.member });
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds,
sender,
});
const ownerAllowList = normalizeDiscordAllowList(
params.discordConfig.allowFrom ?? params.discordConfig.dm?.allowFrom ?? [],
["discord:", "user:", "pk:"],
);
const ownerOk = ownerAllowList
? allowListMatches(ownerAllowList, {
id: sender.id,
name: sender.name,
tag: sender.tag,
})
: false;
const authorizers = params.useAccessGroups
? [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: hasAccessRestrictions, allowed: memberAllowed },
]
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: params.useAccessGroups,
authorizers,
modeWhenAccessGroupsOff: "configured",
});
if (!commandAuthorized) {
return { ok: false, message: "You are not authorized to use this command." };
}
return { ok: true, guildId: interaction.guild.id };
}
export function createDiscordVoiceCommand(params: VoiceCommandContext): CommandWithSubcommands {
class JoinCommand extends Command {
name = "join";
description = "Join a voice channel";
defer = true;
ephemeral = params.ephemeralDefault;
options = [
{
name: "channel",
description: "Voice channel to join",
type: ApplicationCommandOptionType.Channel,
required: true,
channel_types: VOICE_CHANNEL_TYPES,
},
];
async run(interaction: CommandInteraction) {
const access = await authorizeVoiceCommand(interaction, params);
if (!access.ok) {
await interaction.reply({ content: access.message ?? "Not authorized.", ephemeral: true });
return;
}
const channel = await interaction.options.getChannel("channel", true);
if (!channel || !("id" in channel)) {
await interaction.reply({ content: "Voice channel not found.", ephemeral: true });
return;
}
if (!isVoiceChannelType(channel.type)) {
await interaction.reply({ content: "That is not a voice channel.", ephemeral: true });
return;
}
const guildId = access.guildId ?? ("guildId" in channel ? channel.guildId : undefined);
if (!guildId) {
await interaction.reply({
content: "Unable to resolve guild for this voice channel.",
ephemeral: true,
});
return;
}
const manager = params.getManager();
if (!manager) {
await interaction.reply({
content: "Voice manager is not available yet.",
ephemeral: true,
});
return;
}
const result = await manager.join({ guildId, channelId: channel.id });
await interaction.reply({ content: result.message, ephemeral: true });
}
}
class LeaveCommand extends Command {
name = "leave";
description = "Leave the current voice channel";
defer = true;
ephemeral = params.ephemeralDefault;
async run(interaction: CommandInteraction) {
const access = await authorizeVoiceCommand(interaction, params);
if (!access.ok) {
await interaction.reply({ content: access.message ?? "Not authorized.", ephemeral: true });
return;
}
const guildId = access.guildId;
if (!guildId) {
await interaction.reply({
content: "Unable to resolve guild for this command.",
ephemeral: true,
});
return;
}
const manager = params.getManager();
if (!manager) {
await interaction.reply({
content: "Voice manager is not available yet.",
ephemeral: true,
});
return;
}
const result = await manager.leave({ guildId });
await interaction.reply({ content: result.message, ephemeral: true });
}
}
class StatusCommand extends Command {
name = "status";
description = "Show active voice sessions";
defer = true;
ephemeral = params.ephemeralDefault;
async run(interaction: CommandInteraction) {
const access = await authorizeVoiceCommand(interaction, params);
if (!access.ok) {
await interaction.reply({ content: access.message ?? "Not authorized.", ephemeral: true });
return;
}
const manager = params.getManager();
if (!manager) {
await interaction.reply({
content: "Voice manager is not available yet.",
ephemeral: true,
});
return;
}
const sessions = manager.status();
if (sessions.length === 0) {
await interaction.reply({ content: "No active voice sessions.", ephemeral: true });
return;
}
const lines = sessions.map((entry) => `• <#${entry.channelId}> (guild ${entry.guildId})`);
await interaction.reply({ content: lines.join("\n"), ephemeral: true });
}
}
return new (class extends CommandWithSubcommands {
name = "vc";
description = "Voice channel controls";
subcommands = [new JoinCommand(), new LeaveCommand(), new StatusCommand()];
})();
}
function isVoiceChannelType(type: CarbonChannelType) {
return type === CarbonChannelType.GuildVoice || type === CarbonChannelType.GuildStageVoice;
}

View File

@@ -0,0 +1,521 @@
import type { VoicePlugin } from "@buape/carbon/voice";
import type { Readable } from "node:stream";
import { ChannelType, type Client, ReadyListener } from "@buape/carbon";
import { OpusDecoder } from "@discordjs/opus";
import {
AudioPlayerStatus,
EndBehaviorType,
VoiceConnectionStatus,
createAudioPlayer,
createAudioResource,
entersState,
joinVoiceChannel,
type AudioPlayer,
type VoiceConnection,
} from "@discordjs/voice";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { MsgContext } from "../../auto-reply/templating.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { DiscordAccountConfig } from "../../config/types.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveAgentDir } from "../../agents/agent-scope.js";
import { agentCommand } from "../../commands/agent.js";
import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
buildProviderRegistry,
createMediaAttachmentCache,
normalizeMediaAttachments,
runCapability,
} from "../../media-understanding/runner.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { parseTtsDirectives } from "../../tts/tts-core.js";
import { textToSpeech, resolveTtsConfig } from "../../tts/tts.js";
const SAMPLE_RATE = 48_000;
const CHANNELS = 2;
const BIT_DEPTH = 16;
const MIN_SEGMENT_SECONDS = 0.35;
const SILENCE_DURATION_MS = 1_000;
const PLAYBACK_READY_TIMEOUT_MS = 15_000;
const SPEAKING_READY_TIMEOUT_MS = 60_000;
const logger = createSubsystemLogger("discord/voice");
type VoiceOperationResult = {
ok: boolean;
message: string;
channelId?: string;
guildId?: string;
};
type VoiceSessionEntry = {
guildId: string;
channelId: string;
route: ReturnType<typeof resolveAgentRoute>;
connection: VoiceConnection;
player: AudioPlayer;
playbackQueue: Promise<void>;
processingQueue: Promise<void>;
activeSpeakers: Set<string>;
stop: () => void;
};
function buildWavBuffer(pcm: Buffer): Buffer {
const blockAlign = (CHANNELS * BIT_DEPTH) / 8;
const byteRate = SAMPLE_RATE * blockAlign;
const header = Buffer.alloc(44);
header.write("RIFF", 0);
header.writeUInt32LE(36 + pcm.length, 4);
header.write("WAVE", 8);
header.write("fmt ", 12);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(CHANNELS, 22);
header.writeUInt32LE(SAMPLE_RATE, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(BIT_DEPTH, 34);
header.write("data", 36);
header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
async function decodeOpusStream(stream: Readable): Promise<Buffer> {
const decoder = new OpusDecoder(SAMPLE_RATE, CHANNELS);
const chunks: Buffer[] = [];
try {
for await (const chunk of stream) {
if (!chunk || !(chunk instanceof Buffer) || chunk.length === 0) {
continue;
}
const decoded = decoder.decode(chunk);
if (decoded && decoded.length > 0) {
chunks.push(Buffer.from(decoded));
}
}
} catch (err) {
if (shouldLogVerbose()) {
logVerbose(`discord voice: opus decode failed: ${String(err)}`);
}
}
return chunks.length > 0 ? Buffer.concat(chunks) : Buffer.alloc(0);
}
function estimateDurationSeconds(pcm: Buffer): number {
const bytesPerSample = (BIT_DEPTH / 8) * CHANNELS;
if (bytesPerSample <= 0) {
return 0;
}
return pcm.length / (bytesPerSample * SAMPLE_RATE);
}
async function writeWavFile(pcm: Buffer): Promise<{ path: string; durationSeconds: number }> {
const tempDir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "discord-voice-"));
const filePath = path.join(tempDir, `segment-${randomUUID()}.wav`);
const wav = buildWavBuffer(pcm);
await fs.writeFile(filePath, wav);
scheduleTempCleanup(tempDir);
return { path: filePath, durationSeconds: estimateDurationSeconds(pcm) };
}
function scheduleTempCleanup(tempDir: string, delayMs: number = 30 * 60 * 1000): void {
const timer = setTimeout(() => {
fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}, delayMs);
timer.unref();
}
async function transcribeAudio(params: {
cfg: OpenClawConfig;
agentId: string;
filePath: string;
}): Promise<string | undefined> {
const ctx: MsgContext = {
MediaPath: params.filePath,
MediaType: "audio/wav",
};
const attachments = normalizeMediaAttachments(ctx);
if (attachments.length === 0) {
return undefined;
}
const cache = createMediaAttachmentCache(attachments);
const providerRegistry = buildProviderRegistry();
try {
const result = await runCapability({
capability: "audio",
cfg: params.cfg,
ctx,
attachments: cache,
media: attachments,
agentDir: resolveAgentDir(params.cfg, params.agentId),
providerRegistry,
config: params.cfg.tools?.media?.audio,
});
const output = result.outputs.find((entry) => entry.kind === "audio.transcription");
const text = output?.text?.trim();
return text || undefined;
} finally {
await cache.cleanup();
}
}
export class DiscordVoiceManager {
private sessions = new Map<string, VoiceSessionEntry>();
private botUserId?: string;
private readonly voiceEnabled: boolean;
private autoJoinTask: Promise<void> | null = null;
constructor(
private params: {
client: Client;
cfg: OpenClawConfig;
discordConfig: DiscordAccountConfig;
accountId: string;
runtime: RuntimeEnv;
botUserId?: string;
},
) {
this.botUserId = params.botUserId;
this.voiceEnabled =
Boolean(params.discordConfig.voice) && params.discordConfig.voice?.enabled !== false;
}
setBotUserId(id?: string) {
if (id) {
this.botUserId = id;
}
}
isEnabled() {
return this.voiceEnabled;
}
async autoJoin(): Promise<void> {
if (!this.voiceEnabled) {
return;
}
if (this.autoJoinTask) {
return this.autoJoinTask;
}
this.autoJoinTask = (async () => {
const entries = this.params.discordConfig.voice?.autoJoin ?? [];
const seenGuilds = new Set<string>();
for (const entry of entries) {
const guildId = entry.guildId.trim();
if (!guildId) {
continue;
}
if (seenGuilds.has(guildId)) {
logger.warn(
`discord voice: autoJoin has multiple entries for guild ${guildId}; skipping`,
);
continue;
}
seenGuilds.add(guildId);
await this.join({
guildId: entry.guildId,
channelId: entry.channelId,
});
}
})().finally(() => {
this.autoJoinTask = null;
});
return this.autoJoinTask;
}
status(): VoiceOperationResult[] {
return Array.from(this.sessions.values()).map((session) => ({
ok: true,
message: `connected: guild ${session.guildId} channel ${session.channelId}`,
guildId: session.guildId,
channelId: session.channelId,
}));
}
async join(params: { guildId: string; channelId: string }): Promise<VoiceOperationResult> {
if (!this.voiceEnabled) {
return {
ok: false,
message: "Discord voice is disabled (channels.discord.voice.enabled).",
};
}
const guildId = params.guildId.trim();
const channelId = params.channelId.trim();
if (!guildId || !channelId) {
return { ok: false, message: "Missing guildId or channelId." };
}
const existing = this.sessions.get(guildId);
if (existing && existing.channelId === channelId) {
return { ok: true, message: `Already connected to <#${channelId}>.`, guildId, channelId };
}
if (existing) {
await this.leave({ guildId });
}
const channelInfo = await this.params.client.fetchChannel(channelId).catch(() => null);
if (!channelInfo || ("type" in channelInfo && !isVoiceChannel(channelInfo.type))) {
return { ok: false, message: `Channel ${channelId} is not a voice channel.` };
}
const channelGuildId = "guildId" in channelInfo ? channelInfo.guildId : undefined;
if (channelGuildId && channelGuildId !== guildId) {
return { ok: false, message: "Voice channel is not in this guild." };
}
const voicePlugin = this.params.client.getPlugin<VoicePlugin>("voice");
if (!voicePlugin) {
return { ok: false, message: "Discord voice plugin is not available." };
}
const adapterCreator = voicePlugin.getGatewayAdapterCreator(guildId);
const connection = joinVoiceChannel({
channelId,
guildId,
adapterCreator,
selfDeaf: false,
selfMute: false,
});
try {
await entersState(connection, VoiceConnectionStatus.Ready, PLAYBACK_READY_TIMEOUT_MS);
} catch (err) {
connection.destroy();
return { ok: false, message: `Failed to join voice channel: ${String(err)}` };
}
const route = resolveAgentRoute({
cfg: this.params.cfg,
channel: "discord",
accountId: this.params.accountId,
guildId,
peer: { kind: "channel", id: channelId },
});
const player = createAudioPlayer();
connection.subscribe(player);
const entry: VoiceSessionEntry = {
guildId,
channelId,
route,
connection,
player,
playbackQueue: Promise.resolve(),
processingQueue: Promise.resolve(),
activeSpeakers: new Set(),
stop: () => {
player.stop();
connection.destroy();
},
};
const speakingHandler = (userId: string) => {
void this.handleSpeakingStart(entry, userId);
};
connection.receiver.speaking.on("start", speakingHandler);
connection.on(VoiceConnectionStatus.Disconnected, async () => {
try {
await Promise.race([
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
]);
} catch {
this.sessions.delete(guildId);
connection.destroy();
}
});
connection.on(VoiceConnectionStatus.Destroyed, () => {
this.sessions.delete(guildId);
});
player.on("error", (err) => {
logger.warn(`discord voice: playback error: ${String(err)}`);
});
this.sessions.set(guildId, entry);
return {
ok: true,
message: `Joined <#${channelId}>.`,
guildId,
channelId,
};
}
async leave(params: { guildId: string; channelId?: string }): Promise<VoiceOperationResult> {
const guildId = params.guildId.trim();
const entry = this.sessions.get(guildId);
if (!entry) {
return { ok: false, message: "Not connected to a voice channel." };
}
if (params.channelId && params.channelId !== entry.channelId) {
return { ok: false, message: "Not connected to that voice channel." };
}
entry.stop();
this.sessions.delete(guildId);
return {
ok: true,
message: `Left <#${entry.channelId}>.`,
guildId,
channelId: entry.channelId,
};
}
async destroy(): Promise<void> {
for (const entry of this.sessions.values()) {
entry.stop();
}
this.sessions.clear();
}
private enqueueProcessing(entry: VoiceSessionEntry, task: () => Promise<void>) {
entry.processingQueue = entry.processingQueue
.then(task)
.catch((err) => logger.warn(`discord voice: processing failed: ${String(err)}`));
}
private enqueuePlayback(entry: VoiceSessionEntry, task: () => Promise<void>) {
entry.playbackQueue = entry.playbackQueue
.then(task)
.catch((err) => logger.warn(`discord voice: playback failed: ${String(err)}`));
}
private async handleSpeakingStart(entry: VoiceSessionEntry, userId: string) {
if (!userId || entry.activeSpeakers.has(userId)) {
return;
}
if (this.botUserId && userId === this.botUserId) {
return;
}
entry.activeSpeakers.add(userId);
if (entry.player.state.status === AudioPlayerStatus.Playing) {
entry.player.stop(true);
}
const stream = entry.connection.receiver.subscribe(userId, {
end: {
behavior: EndBehaviorType.AfterSilence,
duration: SILENCE_DURATION_MS,
},
});
try {
const pcm = await decodeOpusStream(stream);
if (pcm.length === 0) {
return;
}
const { path: wavPath, durationSeconds } = await writeWavFile(pcm);
if (durationSeconds < MIN_SEGMENT_SECONDS) {
return;
}
this.enqueueProcessing(entry, async () => {
await this.processSegment({ entry, wavPath, userId, durationSeconds });
});
} finally {
entry.activeSpeakers.delete(userId);
}
}
private async processSegment(params: {
entry: VoiceSessionEntry;
wavPath: string;
userId: string;
durationSeconds: number;
}) {
const { entry, wavPath, userId } = params;
const transcript = await transcribeAudio({
cfg: this.params.cfg,
agentId: entry.route.agentId,
filePath: wavPath,
});
if (!transcript) {
return;
}
const speakerLabel = await this.resolveSpeakerLabel(entry.guildId, userId);
const prompt = speakerLabel ? `${speakerLabel}: ${transcript}` : transcript;
const result = await agentCommand(
{
message: prompt,
sessionKey: entry.route.sessionKey,
agentId: entry.route.agentId,
messageChannel: "discord",
deliver: false,
},
this.params.runtime,
);
const replyText = (result.payloads ?? [])
.map((payload) => payload.text)
.filter((text) => typeof text === "string" && text.trim())
.join("\n")
.trim();
if (!replyText) {
return;
}
const ttsConfig = resolveTtsConfig(this.params.cfg);
const directive = parseTtsDirectives(replyText, ttsConfig.modelOverrides);
const speakText = directive.overrides.ttsText ?? directive.cleanedText.trim();
if (!speakText) {
return;
}
const ttsResult = await textToSpeech({
text: speakText,
cfg: this.params.cfg,
channel: "discord",
overrides: directive.overrides,
});
if (!ttsResult.success || !ttsResult.audioPath) {
logger.warn(`discord voice: TTS failed: ${ttsResult.error ?? "unknown error"}`);
return;
}
this.enqueuePlayback(entry, async () => {
const resource = createAudioResource(ttsResult.audioPath);
entry.player.play(resource);
await entersState(entry.player, AudioPlayerStatus.Playing, PLAYBACK_READY_TIMEOUT_MS).catch(
() => undefined,
);
await entersState(entry.player, AudioPlayerStatus.Idle, SPEAKING_READY_TIMEOUT_MS).catch(
() => undefined,
);
});
}
private async resolveSpeakerLabel(guildId: string, userId: string): Promise<string | undefined> {
try {
const member = await this.params.client.fetchMember(guildId, userId);
return member.nickname ?? member.user?.globalName ?? member.user?.username ?? userId;
} catch {
try {
const user = await this.params.client.fetchUser(userId);
return user.globalName ?? user.username ?? userId;
} catch {
return userId;
}
}
}
}
export class DiscordVoiceReadyListener extends ReadyListener {
constructor(private manager: DiscordVoiceManager) {
super();
}
async handle() {
await this.manager.autoJoin();
}
}
function isVoiceChannel(type: ChannelType) {
return type === ChannelType.GuildVoice || type === ChannelType.GuildStageVoice;
}