Compare commits
78 Commits
fix/fish-s
...
matrix-wit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b92925e0 | ||
|
|
73957ca92b | ||
|
|
da10ca1585 | ||
|
|
5d017dae5a | ||
|
|
30fd7001f2 | ||
|
|
da4b124480 | ||
|
|
710c681283 | ||
|
|
e45228ac37 | ||
|
|
a0180f364d | ||
|
|
d69f246ba7 | ||
|
|
a81989048d | ||
|
|
b56e9964f5 | ||
|
|
ddd7fc1513 | ||
|
|
4ebf55f1db | ||
|
|
cc24ede586 | ||
|
|
eb3b84f3d2 | ||
|
|
304244f2be | ||
|
|
f067ea25b4 | ||
|
|
fa51294f65 | ||
|
|
a4d1c4d522 | ||
|
|
6e17c463ae | ||
|
|
63797e841d | ||
|
|
fdb171cb15 | ||
|
|
6f9861bb9b | ||
|
|
759068304e | ||
|
|
ded578b1fa | ||
|
|
dcb8d16591 | ||
|
|
06c17a333e | ||
|
|
409a16060b | ||
|
|
7720106624 | ||
|
|
c613769d22 | ||
|
|
87343c374e | ||
|
|
67be9aed28 | ||
|
|
b48d5d96d3 | ||
|
|
d8cc7db5e6 | ||
|
|
dfbf6ac263 | ||
|
|
121ae6036b | ||
|
|
6e1ad31b49 | ||
|
|
0330b483ad | ||
|
|
9a2bf57e1c | ||
|
|
439044068a | ||
|
|
4c3b4aeb76 | ||
|
|
1e8b291374 | ||
|
|
95f82154f7 | ||
|
|
7bc3998451 | ||
|
|
d029ceab1c | ||
|
|
c331bdc27d | ||
|
|
b0b42b4e14 | ||
|
|
e5514d4854 | ||
|
|
20bc89d96c | ||
|
|
199fef2a5e | ||
|
|
d9a2ac7e72 | ||
|
|
a16934b2ab | ||
|
|
14a072f5fa | ||
|
|
574b848863 | ||
|
|
2e6c58bf75 | ||
|
|
a5d89e6eb1 | ||
|
|
61907ddf3e | ||
|
|
1eab8fa9b0 | ||
|
|
2cf444be02 | ||
|
|
7870ce8177 | ||
|
|
e9d691d472 | ||
|
|
ac2fcfe96a | ||
|
|
627fa3083b | ||
|
|
e4877656ca | ||
|
|
d91f0ceeb3 | ||
|
|
9b71382efb | ||
|
|
dd82d32d85 | ||
|
|
5a42f7cabd | ||
|
|
f2c25c5f40 | ||
|
|
7e08de4a5f | ||
|
|
c9d02f0132 | ||
|
|
0bd99717be | ||
|
|
154c49511c | ||
|
|
34462b3221 | ||
|
|
0372bdf6fe | ||
|
|
cd8309cc31 | ||
|
|
5d9a5b7958 |
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
|
||||
|
||||
## Release Channels (Naming)
|
||||
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
|
||||
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
|
||||
- dev: moving head on `main` (no tag; git checkout main).
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -5,14 +5,23 @@ Docs: https://docs.clawd.bot
|
||||
## 2026.1.20-1
|
||||
|
||||
### Changes
|
||||
- Deps: update workspace + memory-lancedb dependencies.
|
||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
||||
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
|
||||
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
|
||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
|
||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
|
||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
||||
### Fixes
|
||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
||||
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
|
||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
||||
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
||||
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
|
||||
|
||||
## 2026.1.19-3
|
||||
|
||||
@@ -25,6 +34,8 @@ Docs: https://docs.clawd.bot
|
||||
### Fixes
|
||||
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
||||
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
|
||||
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
|
||||
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
|
||||
|
||||
## 2026.1.19-2
|
||||
|
||||
|
||||
11
README.md
11
README.md
@@ -71,6 +71,15 @@ clawdbot agent --message "Ship checklist" --thinking high
|
||||
|
||||
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
|
||||
|
||||
## Development channels
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
|
||||
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
|
||||
|
||||
Switch channels (git + npm): `clawdbot update --channel stable|beta|dev`.
|
||||
Details: [Development channels](https://docs.clawd.bot/install/development-channels).
|
||||
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
@@ -492,5 +501,5 @@ Thanks to all clawtributors:
|
||||
<a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a>
|
||||
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a>
|
||||
<a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a>
|
||||
</p>
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
{
|
||||
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
|
||||
"originHash" : "2e6f580ad7d1e839d513aa883350369bf2e4193fad872030fdaea7827f34d8ef",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -27,6 +45,24 @@
|
||||
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||
"version" : "0.99.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-math",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
||||
"state" : {
|
||||
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "textual",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/textual",
|
||||
"state" : {
|
||||
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.clawdbot.android.chat.ChatMessage
|
||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||
import com.clawdbot.android.chat.ChatSessionEntry
|
||||
import com.clawdbot.android.chat.OutgoingAttachment
|
||||
import com.clawdbot.android.gateway.DeviceAuthStore
|
||||
import com.clawdbot.android.gateway.DeviceIdentityStore
|
||||
import com.clawdbot.android.gateway.GatewayClientInfo
|
||||
import com.clawdbot.android.gateway.GatewayConnectOptions
|
||||
@@ -62,6 +63,7 @@ class NodeRuntime(context: Context) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val prefs = SecurePrefs(appContext)
|
||||
private val deviceAuthStore = DeviceAuthStore(prefs)
|
||||
val canvas = CanvasController()
|
||||
val camera = CameraCaptureManager(appContext)
|
||||
val location = LocationCaptureManager(appContext)
|
||||
@@ -153,6 +155,7 @@ class NodeRuntime(context: Context) {
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { name, remote, mainSessionKey ->
|
||||
operatorConnected = true
|
||||
operatorStatusText = "Connected"
|
||||
@@ -188,6 +191,7 @@ class NodeRuntime(context: Context) {
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
nodeConnected = true
|
||||
nodeStatusText = "Connected"
|
||||
|
||||
@@ -189,6 +189,18 @@ class SecurePrefs(context: Context) {
|
||||
prefs.edit { putString(key, fingerprint.trim()) }
|
||||
}
|
||||
|
||||
fun getString(key: String): String? {
|
||||
return prefs.getString(key, null)
|
||||
}
|
||||
|
||||
fun putString(key: String, value: String) {
|
||||
prefs.edit { putString(key, value) }
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
prefs.edit { remove(key) }
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.clawdbot.android.gateway
|
||||
|
||||
import com.clawdbot.android.SecurePrefs
|
||||
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) {
|
||||
fun loadToken(deviceId: String, role: String): String? {
|
||||
val key = tokenKey(deviceId, role)
|
||||
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveToken(deviceId: String, role: String, token: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
}
|
||||
|
||||
fun clearToken(deviceId: String, role: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.remove(key)
|
||||
}
|
||||
|
||||
private fun tokenKey(deviceId: String, role: String): String {
|
||||
val normalizedDevice = deviceId.trim().lowercase()
|
||||
val normalizedRole = role.trim().lowercase()
|
||||
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ data class GatewayConnectOptions(
|
||||
class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
private val deviceAuthStore: DeviceAuthStore,
|
||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
@@ -177,6 +178,7 @@ class GatewaySession(
|
||||
private val connectDeferred = CompletableDeferred<Unit>()
|
||||
private val closedDeferred = CompletableDeferred<Unit>()
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private val connectNonceDeferred = CompletableDeferred<String?>()
|
||||
private val client: OkHttpClient = buildClient()
|
||||
private var socket: WebSocket? = null
|
||||
private val loggerTag = "ClawdbotGateway"
|
||||
@@ -253,7 +255,8 @@ class GatewaySession(
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
scope.launch {
|
||||
try {
|
||||
sendConnect()
|
||||
val nonce = awaitConnectNonce()
|
||||
sendConnect(nonce)
|
||||
} catch (err: Throwable) {
|
||||
connectDeferred.completeExceptionally(err)
|
||||
closeQuietly()
|
||||
@@ -288,16 +291,30 @@ class GatewaySession(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendConnect() {
|
||||
val payload = buildConnectParams()
|
||||
private suspend fun sendConnect(connectNonce: String?) {
|
||||
val identity = identityStore.loadOrCreate()
|
||||
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
|
||||
val trimmedToken = token?.trim().orEmpty()
|
||||
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
|
||||
val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank()
|
||||
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
|
||||
val res = request("connect", payload, timeoutMs = 8_000)
|
||||
if (!res.ok) {
|
||||
val msg = res.error?.message ?: "connect failed"
|
||||
if (canFallbackToShared) {
|
||||
deviceAuthStore.clearToken(identity.deviceId, options.role)
|
||||
}
|
||||
throw IllegalStateException(msg)
|
||||
}
|
||||
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
||||
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
||||
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
|
||||
val authObj = obj["auth"].asObjectOrNull()
|
||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
||||
if (!deviceToken.isNullOrBlank()) {
|
||||
deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken)
|
||||
}
|
||||
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
|
||||
val sessionDefaults =
|
||||
@@ -308,7 +325,12 @@ class GatewaySession(
|
||||
connectDeferred.complete(Unit)
|
||||
}
|
||||
|
||||
private fun buildConnectParams(): JsonObject {
|
||||
private fun buildConnectParams(
|
||||
identity: DeviceIdentity,
|
||||
connectNonce: String?,
|
||||
authToken: String,
|
||||
authPassword: String?,
|
||||
): JsonObject {
|
||||
val client = options.client
|
||||
val locale = Locale.getDefault().toLanguageTag()
|
||||
val clientObj =
|
||||
@@ -323,22 +345,20 @@ class GatewaySession(
|
||||
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
}
|
||||
|
||||
val authToken = token?.trim().orEmpty()
|
||||
val authPassword = password?.trim().orEmpty()
|
||||
val password = authPassword?.trim().orEmpty()
|
||||
val authJson =
|
||||
when {
|
||||
authToken.isNotEmpty() ->
|
||||
buildJsonObject {
|
||||
put("token", JsonPrimitive(authToken))
|
||||
}
|
||||
authPassword.isNotEmpty() ->
|
||||
password.isNotEmpty() ->
|
||||
buildJsonObject {
|
||||
put("password", JsonPrimitive(authPassword))
|
||||
put("password", JsonPrimitive(password))
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
val identity = identityStore.loadOrCreate()
|
||||
val signedAtMs = System.currentTimeMillis()
|
||||
val payload =
|
||||
buildDeviceAuthPayload(
|
||||
@@ -349,6 +369,7 @@ class GatewaySession(
|
||||
scopes = options.scopes,
|
||||
signedAtMs = signedAtMs,
|
||||
token = if (authToken.isNotEmpty()) authToken else null,
|
||||
nonce = connectNonce,
|
||||
)
|
||||
val signature = identityStore.signPayload(payload, identity)
|
||||
val publicKey = identityStore.publicKeyBase64Url(identity)
|
||||
@@ -359,6 +380,9 @@ class GatewaySession(
|
||||
put("publicKey", JsonPrimitive(publicKey))
|
||||
put("signature", JsonPrimitive(signature))
|
||||
put("signedAt", JsonPrimitive(signedAtMs))
|
||||
if (!connectNonce.isNullOrBlank()) {
|
||||
put("nonce", JsonPrimitive(connectNonce))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
@@ -416,6 +440,13 @@ class GatewaySession(
|
||||
val event = frame["event"].asStringOrNull() ?: return
|
||||
val payloadJson =
|
||||
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
|
||||
if (event == "connect.challenge") {
|
||||
val nonce = extractConnectNonce(payloadJson)
|
||||
if (!connectNonceDeferred.isCompleted) {
|
||||
connectNonceDeferred.complete(nonce)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
|
||||
handleInvokeEvent(payloadJson)
|
||||
return
|
||||
@@ -423,6 +454,21 @@ class GatewaySession(
|
||||
onEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private suspend fun awaitConnectNonce(): String? {
|
||||
if (isLoopbackHost(endpoint.host)) return null
|
||||
return try {
|
||||
withTimeout(2_000) { connectNonceDeferred.await() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractConnectNonce(payloadJson: String?): String? {
|
||||
if (payloadJson.isNullOrBlank()) return null
|
||||
val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null
|
||||
return obj["nonce"].asStringOrNull()
|
||||
}
|
||||
|
||||
private fun handleInvokeEvent(payloadJson: String) {
|
||||
val payload =
|
||||
try {
|
||||
@@ -544,19 +590,26 @@ class GatewaySession(
|
||||
scopes: List<String>,
|
||||
signedAtMs: Long,
|
||||
token: String?,
|
||||
nonce: String?,
|
||||
): String {
|
||||
val scopeString = scopes.joinToString(",")
|
||||
val authToken = token.orEmpty()
|
||||
return listOf(
|
||||
"v1",
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopeString,
|
||||
signedAtMs.toString(),
|
||||
authToken,
|
||||
).joinToString("|")
|
||||
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
|
||||
val parts =
|
||||
mutableListOf(
|
||||
version,
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopeString,
|
||||
signedAtMs.toString(),
|
||||
authToken,
|
||||
)
|
||||
if (!nonce.isNullOrBlank()) {
|
||||
parts.add(nonce)
|
||||
}
|
||||
return parts.joinToString("|")
|
||||
}
|
||||
|
||||
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
|
||||
|
||||
@@ -84,5 +84,7 @@ private fun sha256Hex(data: ByteArray): String {
|
||||
}
|
||||
|
||||
private fun normalizeFingerprint(raw: String): String {
|
||||
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
val stripped = raw.trim()
|
||||
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
|
||||
return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.11-4</string>
|
||||
<string>2026.1.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601113</string>
|
||||
<string>20260109</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
Sources/Bridge/BridgeClient.swift
|
||||
Sources/Bridge/BridgeConnectionController.swift
|
||||
Sources/Bridge/BridgeDiscoveryDebugLogView.swift
|
||||
Sources/Bridge/BridgeDiscoveryModel.swift
|
||||
Sources/Bridge/BridgeEndpointID.swift
|
||||
Sources/Bridge/BridgeSession.swift
|
||||
Sources/Bridge/BridgeSettingsStore.swift
|
||||
Sources/Bridge/KeychainStore.swift
|
||||
Sources/Gateway/GatewayConnectionController.swift
|
||||
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
|
||||
Sources/Gateway/GatewayDiscoveryModel.swift
|
||||
Sources/Gateway/GatewaySettingsStore.swift
|
||||
Sources/Gateway/KeychainStore.swift
|
||||
Sources/Camera/CameraController.swift
|
||||
Sources/Chat/ChatSheet.swift
|
||||
Sources/Chat/IOSBridgeChatTransport.swift
|
||||
Sources/Chat/IOSGatewayChatTransport.swift
|
||||
Sources/ClawdbotApp.swift
|
||||
Sources/Location/LocationService.swift
|
||||
Sources/Model/NodeAppModel.swift
|
||||
Sources/RootCanvas.swift
|
||||
Sources/RootTabs.swift
|
||||
@@ -17,6 +15,7 @@ Sources/Screen/ScreenController.swift
|
||||
Sources/Screen/ScreenRecordService.swift
|
||||
Sources/Screen/ScreenTab.swift
|
||||
Sources/Screen/ScreenWebView.swift
|
||||
Sources/SessionKey.swift
|
||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/SettingsTab.swift
|
||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.11-4</string>
|
||||
<string>2026.1.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601113</string>
|
||||
<string>20260109</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -78,6 +78,7 @@ let package = Package(
|
||||
.executableTarget(
|
||||
name: "ClawdbotWizardCLI",
|
||||
dependencies: [
|
||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
||||
],
|
||||
path: "Sources/ClawdbotWizardCLI",
|
||||
|
||||
@@ -426,34 +426,17 @@ extension ChannelsSettings {
|
||||
}
|
||||
|
||||
private func resolveChannelTitle(_ id: String) -> String {
|
||||
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
|
||||
return label
|
||||
}
|
||||
let label = self.store.resolveChannelLabel(id)
|
||||
if label != id { return label }
|
||||
return id.prefix(1).uppercased() + id.dropFirst()
|
||||
}
|
||||
|
||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": "WhatsApp Web"
|
||||
case "telegram": "Telegram Bot"
|
||||
case "discord": "Discord Bot"
|
||||
case "slack": "Slack Bot"
|
||||
case "signal": "Signal REST"
|
||||
case "imessage": "iMessage"
|
||||
default: self.resolveChannelTitle(id)
|
||||
}
|
||||
return self.store.resolveChannelDetailLabel(id)
|
||||
}
|
||||
|
||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": "message"
|
||||
case "telegram": "paperplane"
|
||||
case "discord": "bubble.left.and.bubble.right"
|
||||
case "slack": "number"
|
||||
case "signal": "antenna.radiowaves.left.and.right"
|
||||
case "imessage": "message.fill"
|
||||
default: "message"
|
||||
}
|
||||
return self.store.resolveChannelSystemImage(id)
|
||||
}
|
||||
|
||||
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
||||
|
||||
@@ -153,9 +153,19 @@ struct ChannelsStatusSnapshot: Codable {
|
||||
let application: AnyCodable?
|
||||
}
|
||||
|
||||
struct ChannelUiMetaEntry: Codable {
|
||||
let id: String
|
||||
let label: String
|
||||
let detailLabel: String
|
||||
let systemImage: String?
|
||||
}
|
||||
|
||||
let ts: Double
|
||||
let channelOrder: [String]
|
||||
let channelLabels: [String: String]
|
||||
let channelDetailLabels: [String: String]? = nil
|
||||
let channelSystemImages: [String: String]? = nil
|
||||
let channelMeta: [ChannelUiMetaEntry]? = nil
|
||||
let channels: [String: AnyCodable]
|
||||
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
||||
let channelDefaultAccountId: [String: String]
|
||||
@@ -217,6 +227,47 @@ final class ChannelsStore {
|
||||
var configRoot: [String: Any] = [:]
|
||||
var configLoaded = false
|
||||
|
||||
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
|
||||
self.snapshot?.channelMeta?.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
func resolveChannelLabel(_ id: String) -> String {
|
||||
if let meta = self.channelMetaEntry(id), !meta.label.isEmpty {
|
||||
return meta.label
|
||||
}
|
||||
if let label = self.snapshot?.channelLabels[id], !label.isEmpty {
|
||||
return label
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func resolveChannelDetailLabel(_ id: String) -> String {
|
||||
if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty {
|
||||
return meta.detailLabel
|
||||
}
|
||||
if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
|
||||
return detail
|
||||
}
|
||||
return self.resolveChannelLabel(id)
|
||||
}
|
||||
|
||||
func resolveChannelSystemImage(_ id: String) -> String {
|
||||
if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty {
|
||||
return symbol
|
||||
}
|
||||
if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
|
||||
return symbol
|
||||
}
|
||||
return "message"
|
||||
}
|
||||
|
||||
func orderedChannelIds() -> [String] {
|
||||
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
|
||||
return meta.map { $0.id }
|
||||
}
|
||||
return self.snapshot?.channelOrder ?? []
|
||||
}
|
||||
|
||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||
self.isPreview = isPreview
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ extension CronJobEditor {
|
||||
self.thinking = thinking ?? ""
|
||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||
self.deliver = deliver ?? false
|
||||
self.channel = GatewayAgentChannel(raw: channel)
|
||||
let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.channel = trimmed.isEmpty ? "last" : trimmed
|
||||
self.to = to ?? ""
|
||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||
}
|
||||
@@ -210,7 +211,8 @@ extension CronJobEditor {
|
||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||
payload["deliver"] = self.deliver
|
||||
if self.deliver {
|
||||
payload["channel"] = self.channel.rawValue
|
||||
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { payload["to"] = to }
|
||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||
|
||||
@@ -14,7 +14,7 @@ extension CronJobEditor {
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = "Run diagnostic"
|
||||
self.deliver = true
|
||||
self.channel = .last
|
||||
self.channel = "last"
|
||||
self.to = "+15551230000"
|
||||
self.thinking = "low"
|
||||
self.timeoutSeconds = "90"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import ClawdbotProtocol
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct CronJobEditor: View {
|
||||
let job: CronJob?
|
||||
@Binding var isSaving: Bool
|
||||
@Binding var error: String?
|
||||
@Bindable var channelsStore: ChannelsStore
|
||||
let onCancel: () -> Void
|
||||
let onSave: ([String: AnyCodable]) -> Void
|
||||
|
||||
@@ -45,13 +47,29 @@ struct CronJobEditor: View {
|
||||
@State var systemEventText: String = ""
|
||||
@State var agentMessage: String = ""
|
||||
@State var deliver: Bool = false
|
||||
@State var channel: GatewayAgentChannel = .last
|
||||
@State var channel: String = "last"
|
||||
@State var to: String = ""
|
||||
@State var thinking: String = ""
|
||||
@State var timeoutSeconds: String = ""
|
||||
@State var bestEffortDeliver: Bool = false
|
||||
@State var postPrefix: String = "Cron"
|
||||
|
||||
var channelOptions: [String] {
|
||||
let ordered = self.channelsStore.orderedChannelIds()
|
||||
var options = ["last"] + ordered
|
||||
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty, !options.contains(trimmed) {
|
||||
options.append(trimmed)
|
||||
}
|
||||
var seen = Set<String>()
|
||||
return options.filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
func channelLabel(for id: String) -> String {
|
||||
if id == "last" { return "last" }
|
||||
return self.channelsStore.resolveChannelLabel(id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -333,13 +351,9 @@ struct CronJobEditor: View {
|
||||
GridRow {
|
||||
self.gridLabel("Channel")
|
||||
Picker("", selection: self.$channel) {
|
||||
Text("last").tag(GatewayAgentChannel.last)
|
||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
||||
Text("discord").tag(GatewayAgentChannel.discord)
|
||||
Text("slack").tag(GatewayAgentChannel.slack)
|
||||
Text("signal").tag(GatewayAgentChannel.signal)
|
||||
Text("imessage").tag(GatewayAgentChannel.imessage)
|
||||
ForEach(self.channelOptions, id: \.self) { channel in
|
||||
Text(self.channelLabel(for: channel)).tag(channel)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
@@ -8,13 +8,20 @@ extension CronSettings {
|
||||
self.content
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.onAppear { self.store.start() }
|
||||
.onDisappear { self.store.stop() }
|
||||
.onAppear {
|
||||
self.store.start()
|
||||
self.channelsStore.start()
|
||||
}
|
||||
.onDisappear {
|
||||
self.store.stop()
|
||||
self.channelsStore.stop()
|
||||
}
|
||||
.sheet(isPresented: self.$showEditor) {
|
||||
CronJobEditor(
|
||||
job: self.editingJob,
|
||||
isSaving: self.$isSaving,
|
||||
error: self.$editorError,
|
||||
channelsStore: self.channelsStore,
|
||||
onCancel: {
|
||||
self.showEditor = false
|
||||
self.editingJob = nil
|
||||
|
||||
@@ -47,7 +47,7 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
durationMs: 1234,
|
||||
nextRunAtMs: nil),
|
||||
]
|
||||
return CronSettings(store: store)
|
||||
return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ extension CronSettings {
|
||||
store.selectedJobId = job.id
|
||||
store.runEntries = [run]
|
||||
|
||||
let view = CronSettings(store: store)
|
||||
let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
|
||||
_ = view.body
|
||||
_ = view.jobRow(job)
|
||||
_ = view.jobContextMenu(job)
|
||||
|
||||
@@ -3,13 +3,15 @@ import SwiftUI
|
||||
|
||||
struct CronSettings: View {
|
||||
@Bindable var store: CronJobsStore
|
||||
@Bindable var channelsStore: ChannelsStore
|
||||
@State var showEditor = false
|
||||
@State var editingJob: CronJob?
|
||||
@State var editorError: String?
|
||||
@State var isSaving = false
|
||||
@State var confirmDelete: CronJob?
|
||||
|
||||
init(store: CronJobsStore = .shared) {
|
||||
init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) {
|
||||
self.store = store
|
||||
self.channelsStore = channelsStore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ private struct ExecHostRequest: Codable {
|
||||
var needsScreenRecording: Bool?
|
||||
var agentId: String?
|
||||
var sessionKey: String?
|
||||
var approvalDecision: ExecApprovalDecision?
|
||||
}
|
||||
|
||||
private struct ExecHostRunResult: Codable {
|
||||
@@ -328,8 +329,21 @@ private enum ExecHostExecutor {
|
||||
return false
|
||||
}()
|
||||
|
||||
var approvedByAsk = false
|
||||
if requiresAsk {
|
||||
let approvalDecision = request.approvalDecision
|
||||
if approvalDecision == .deny {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: user denied",
|
||||
reason: "user-denied"))
|
||||
}
|
||||
|
||||
var approvedByAsk = approvalDecision != nil
|
||||
if requiresAsk, approvalDecision == nil {
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
@@ -364,6 +378,13 @@ private enum ExecHostExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
if approvalDecision == .allowAlways, security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
if !pattern.isEmpty {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
||||
}
|
||||
}
|
||||
|
||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
|
||||
@@ -15,6 +15,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case signal
|
||||
case imessage
|
||||
case msteams
|
||||
case bluebubbles
|
||||
case webchat
|
||||
|
||||
init(raw: String?) {
|
||||
|
||||
@@ -20,7 +20,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
public let permissions: [String: AnyCodable]?
|
||||
public let role: String?
|
||||
public let scopes: [String]?
|
||||
public let device: [String: AnyCodable]
|
||||
public let device: [String: AnyCodable]?
|
||||
public let auth: [String: AnyCodable]?
|
||||
public let locale: String?
|
||||
public let useragent: String?
|
||||
@@ -34,7 +34,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
permissions: [String: AnyCodable]?,
|
||||
role: String?,
|
||||
scopes: [String]?,
|
||||
device: [String: AnyCodable],
|
||||
device: [String: AnyCodable]?,
|
||||
auth: [String: AnyCodable]?,
|
||||
locale: String?,
|
||||
useragent: String?
|
||||
@@ -205,6 +205,9 @@ public struct PresenceEntry: Codable, Sendable {
|
||||
public let tags: [String]?
|
||||
public let text: String?
|
||||
public let ts: Int
|
||||
public let deviceid: String?
|
||||
public let roles: [String]?
|
||||
public let scopes: [String]?
|
||||
public let instanceid: String?
|
||||
|
||||
public init(
|
||||
@@ -220,6 +223,9 @@ public struct PresenceEntry: Codable, Sendable {
|
||||
tags: [String]?,
|
||||
text: String?,
|
||||
ts: Int,
|
||||
deviceid: String?,
|
||||
roles: [String]?,
|
||||
scopes: [String]?,
|
||||
instanceid: String?
|
||||
) {
|
||||
self.host = host
|
||||
@@ -234,6 +240,9 @@ public struct PresenceEntry: Codable, Sendable {
|
||||
self.tags = tags
|
||||
self.text = text
|
||||
self.ts = ts
|
||||
self.deviceid = deviceid
|
||||
self.roles = roles
|
||||
self.scopes = scopes
|
||||
self.instanceid = instanceid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -249,6 +258,9 @@ public struct PresenceEntry: Codable, Sendable {
|
||||
case tags
|
||||
case text
|
||||
case ts
|
||||
case deviceid = "deviceId"
|
||||
case roles
|
||||
case scopes
|
||||
case instanceid = "instanceId"
|
||||
}
|
||||
}
|
||||
@@ -1312,6 +1324,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
||||
public let ts: Int
|
||||
public let channelorder: [String]
|
||||
public let channellabels: [String: AnyCodable]
|
||||
public let channeldetaillabels: [String: AnyCodable]?
|
||||
public let channelsystemimages: [String: AnyCodable]?
|
||||
public let channelmeta: [[String: AnyCodable]]?
|
||||
public let channels: [String: AnyCodable]
|
||||
public let channelaccounts: [String: AnyCodable]
|
||||
public let channeldefaultaccountid: [String: AnyCodable]
|
||||
@@ -1320,6 +1335,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
||||
ts: Int,
|
||||
channelorder: [String],
|
||||
channellabels: [String: AnyCodable],
|
||||
channeldetaillabels: [String: AnyCodable]?,
|
||||
channelsystemimages: [String: AnyCodable]?,
|
||||
channelmeta: [[String: AnyCodable]]?,
|
||||
channels: [String: AnyCodable],
|
||||
channelaccounts: [String: AnyCodable],
|
||||
channeldefaultaccountid: [String: AnyCodable]
|
||||
@@ -1327,6 +1345,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
||||
self.ts = ts
|
||||
self.channelorder = channelorder
|
||||
self.channellabels = channellabels
|
||||
self.channeldetaillabels = channeldetaillabels
|
||||
self.channelsystemimages = channelsystemimages
|
||||
self.channelmeta = channelmeta
|
||||
self.channels = channels
|
||||
self.channelaccounts = channelaccounts
|
||||
self.channeldefaultaccountid = channeldefaultaccountid
|
||||
@@ -1335,6 +1356,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
||||
case ts
|
||||
case channelorder = "channelOrder"
|
||||
case channellabels = "channelLabels"
|
||||
case channeldetaillabels = "channelDetailLabels"
|
||||
case channelsystemimages = "channelSystemImages"
|
||||
case channelmeta = "channelMeta"
|
||||
case channels
|
||||
case channelaccounts = "channelAccounts"
|
||||
case channeldefaultaccountid = "channelDefaultAccountId"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
|
||||
struct WizardCliOptions {
|
||||
var url: String?
|
||||
var token: String?
|
||||
@@ -228,6 +231,10 @@ private func parseInt(_ value: Any?) -> Int? {
|
||||
}
|
||||
|
||||
actor GatewayWizardClient {
|
||||
private enum ConnectChallengeError: Error {
|
||||
case timeout
|
||||
}
|
||||
|
||||
private let url: URL
|
||||
private let token: String?
|
||||
private let password: String?
|
||||
@@ -235,6 +242,7 @@ actor GatewayWizardClient {
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private let session = URLSession(configuration: .default)
|
||||
private let connectChallengeTimeoutSeconds: Double = 0.75
|
||||
private var task: URLSessionWebSocketTask?
|
||||
|
||||
init(url: URL, token: String?, password: String?, json: Bool) {
|
||||
@@ -257,7 +265,7 @@ actor GatewayWizardClient {
|
||||
self.task = nil
|
||||
}
|
||||
|
||||
func request(method: String, params: [String: AnyCodable]?) async throws -> ResponseFrame {
|
||||
func request(method: String, params: [String: ProtoAnyCodable]?) async throws -> ResponseFrame {
|
||||
guard let task = self.task else {
|
||||
throw WizardCliError.gatewayError("gateway not connected")
|
||||
}
|
||||
@@ -266,7 +274,7 @@ actor GatewayWizardClient {
|
||||
type: "req",
|
||||
id: id,
|
||||
method: method,
|
||||
params: params.map { AnyCodable($0) })
|
||||
params: params.map { ProtoAnyCodable($0) })
|
||||
let data = try self.encoder.encode(frame)
|
||||
try await task.send(.data(data))
|
||||
|
||||
@@ -309,28 +317,65 @@ actor GatewayWizardClient {
|
||||
}
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
let client: [String: AnyCodable] = [
|
||||
"id": AnyCodable("clawdbot-macos"),
|
||||
"displayName": AnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
|
||||
"version": AnyCodable("dev"),
|
||||
"platform": AnyCodable(platform),
|
||||
"deviceFamily": AnyCodable("Mac"),
|
||||
"mode": AnyCodable("ui"),
|
||||
"instanceId": AnyCodable(UUID().uuidString),
|
||||
let clientId = "clawdbot-macos"
|
||||
let clientMode = "ui"
|
||||
let role = "operator"
|
||||
let scopes: [String] = []
|
||||
let client: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(clientId),
|
||||
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
|
||||
"version": ProtoAnyCodable("dev"),
|
||||
"platform": ProtoAnyCodable(platform),
|
||||
"deviceFamily": ProtoAnyCodable("Mac"),
|
||||
"mode": ProtoAnyCodable(clientMode),
|
||||
"instanceId": ProtoAnyCodable(UUID().uuidString),
|
||||
]
|
||||
|
||||
var params: [String: AnyCodable] = [
|
||||
"minProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"maxProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"client": AnyCodable(client),
|
||||
"caps": AnyCodable([String]()),
|
||||
"locale": AnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
|
||||
"userAgent": AnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||
var params: [String: ProtoAnyCodable] = [
|
||||
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"client": ProtoAnyCodable(client),
|
||||
"caps": ProtoAnyCodable([String]()),
|
||||
"locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
|
||||
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||
"role": ProtoAnyCodable(role),
|
||||
"scopes": ProtoAnyCodable(scopes),
|
||||
]
|
||||
if let token = self.token {
|
||||
params["auth"] = AnyCodable(["token": AnyCodable(token)])
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||
} else if let password = self.password {
|
||||
params["auth"] = AnyCodable(["password": AnyCodable(password)])
|
||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||
}
|
||||
let connectNonce = try await self.waitForConnectChallenge()
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let scopesValue = scopes.joined(separator: ",")
|
||||
var payloadParts = [
|
||||
connectNonce == nil ? "v1" : "v2",
|
||||
identity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopesValue,
|
||||
String(signedAtMs),
|
||||
self.token ?? "",
|
||||
]
|
||||
if let connectNonce {
|
||||
payloadParts.append(connectNonce)
|
||||
}
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||
var device: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(identity.deviceId),
|
||||
"publicKey": ProtoAnyCodable(publicKey),
|
||||
"signature": ProtoAnyCodable(signature),
|
||||
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||
]
|
||||
if let connectNonce {
|
||||
device["nonce"] = ProtoAnyCodable(connectNonce)
|
||||
}
|
||||
params["device"] = ProtoAnyCodable(device)
|
||||
}
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
@@ -338,31 +383,57 @@ actor GatewayWizardClient {
|
||||
type: "req",
|
||||
id: reqId,
|
||||
method: "connect",
|
||||
params: AnyCodable(params))
|
||||
params: ProtoAnyCodable(params))
|
||||
let data = try self.encoder.encode(frame)
|
||||
try await task.send(.data(data))
|
||||
|
||||
let message = try await task.receive()
|
||||
let frameResponse = try decodeFrame(message)
|
||||
guard case let .res(res) = frameResponse, res.id == reqId else {
|
||||
throw WizardCliError.gatewayError("connect failed (unexpected response)")
|
||||
while true {
|
||||
let message = try await task.receive()
|
||||
let frameResponse = try decodeFrame(message)
|
||||
if case let .res(res) = frameResponse, res.id == reqId {
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||
throw WizardCliError.gatewayError(msg)
|
||||
}
|
||||
_ = try self.decodePayload(res, as: HelloOk.self)
|
||||
return
|
||||
}
|
||||
}
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||
throw WizardCliError.gatewayError(msg)
|
||||
}
|
||||
|
||||
private func waitForConnectChallenge() async throws -> String? {
|
||||
guard let task = self.task else { return nil }
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: self.connectChallengeTimeoutSeconds,
|
||||
onTimeout: { ConnectChallengeError.timeout },
|
||||
operation: {
|
||||
while true {
|
||||
let message = try await task.receive()
|
||||
let frame = try decodeFrame(message)
|
||||
if case let .event(evt) = frame, evt.event == "connect.challenge" {
|
||||
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
||||
let nonce = payload["nonce"]?.value as? String {
|
||||
return nonce
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
if error is ConnectChallengeError { return nil }
|
||||
throw error
|
||||
}
|
||||
_ = try self.decodePayload(res, as: HelloOk.self)
|
||||
}
|
||||
}
|
||||
|
||||
private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws {
|
||||
var params: [String: AnyCodable] = [:]
|
||||
var params: [String: ProtoAnyCodable] = [:]
|
||||
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if mode == "local" || mode == "remote" {
|
||||
params["mode"] = AnyCodable(mode)
|
||||
params["mode"] = ProtoAnyCodable(mode)
|
||||
}
|
||||
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
|
||||
params["workspace"] = AnyCodable(workspace)
|
||||
params["workspace"] = ProtoAnyCodable(workspace)
|
||||
}
|
||||
|
||||
let startResponse = try await client.request(method: "wizard.start", params: params)
|
||||
@@ -395,17 +466,17 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
|
||||
|
||||
if let step = decodeWizardStep(nextResult.step) {
|
||||
let answer = try promptAnswer(for: step)
|
||||
var answerPayload: [String: AnyCodable] = [
|
||||
"stepId": AnyCodable(step.id),
|
||||
var answerPayload: [String: ProtoAnyCodable] = [
|
||||
"stepId": ProtoAnyCodable(step.id),
|
||||
]
|
||||
if !(answer is NSNull) {
|
||||
answerPayload["value"] = AnyCodable(answer)
|
||||
answerPayload["value"] = ProtoAnyCodable(answer)
|
||||
}
|
||||
let response = try await client.request(
|
||||
method: "wizard.next",
|
||||
params: [
|
||||
"sessionId": AnyCodable(sessionId),
|
||||
"answer": AnyCodable(answerPayload),
|
||||
"sessionId": ProtoAnyCodable(sessionId),
|
||||
"answer": ProtoAnyCodable(answerPayload),
|
||||
])
|
||||
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
||||
if opts.json {
|
||||
@@ -414,7 +485,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
|
||||
} else {
|
||||
let response = try await client.request(
|
||||
method: "wizard.next",
|
||||
params: ["sessionId": AnyCodable(sessionId)])
|
||||
params: ["sessionId": ProtoAnyCodable(sessionId)])
|
||||
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
||||
if opts.json {
|
||||
dumpResult(response)
|
||||
@@ -424,7 +495,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
|
||||
} catch WizardCliError.cancelled {
|
||||
_ = try? await client.request(
|
||||
method: "wizard.cancel",
|
||||
params: ["sessionId": AnyCodable(sessionId)])
|
||||
params: ["sessionId": ProtoAnyCodable(sessionId)])
|
||||
throw WizardCliError.cancelled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import Testing
|
||||
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
||||
}
|
||||
|
||||
@@ -18,6 +19,7 @@ import Testing
|
||||
#expect(GatewayAgentChannel(raw: nil) == .last)
|
||||
#expect(GatewayAgentChannel(raw: " ") == .last)
|
||||
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
||||
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
|
||||
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
|
||||
public struct DeviceAuthEntry: Codable, Sendable {
|
||||
public let token: String
|
||||
public let role: String
|
||||
public let scopes: [String]
|
||||
public let updatedAtMs: Int
|
||||
|
||||
public init(token: String, role: String, scopes: [String], updatedAtMs: Int) {
|
||||
self.token = token
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
self.updatedAtMs = updatedAtMs
|
||||
}
|
||||
}
|
||||
|
||||
private struct DeviceAuthStoreFile: Codable {
|
||||
var version: Int
|
||||
var deviceId: String
|
||||
var tokens: [String: DeviceAuthEntry]
|
||||
}
|
||||
|
||||
public enum DeviceAuthStore {
|
||||
private static let fileName = "device-auth.json"
|
||||
|
||||
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
|
||||
guard let store = readStore(), store.deviceId == deviceId else { return nil }
|
||||
let role = normalizeRole(role)
|
||||
return store.tokens[role]
|
||||
}
|
||||
|
||||
public static func storeToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String] = []
|
||||
) -> DeviceAuthEntry {
|
||||
let normalizedRole = normalizeRole(role)
|
||||
var next = readStore()
|
||||
if next?.deviceId != deviceId {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
let entry = DeviceAuthEntry(
|
||||
token: token,
|
||||
role: normalizedRole,
|
||||
scopes: normalizeScopes(scopes),
|
||||
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000)
|
||||
)
|
||||
if next == nil {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
next?.tokens[normalizedRole] = entry
|
||||
if let store = next {
|
||||
writeStore(store)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
public static func clearToken(deviceId: String, role: String) {
|
||||
guard var store = readStore(), store.deviceId == deviceId else { return }
|
||||
let normalizedRole = normalizeRole(role)
|
||||
guard store.tokens[normalizedRole] != nil else { return }
|
||||
store.tokens.removeValue(forKey: normalizedRole)
|
||||
writeStore(store)
|
||||
}
|
||||
|
||||
private static func normalizeRole(_ role: String) -> String {
|
||||
role.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func normalizeScopes(_ scopes: [String]) -> [String] {
|
||||
let trimmed = scopes
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
return Array(Set(trimmed)).sorted()
|
||||
}
|
||||
|
||||
private static func fileURL() -> URL {
|
||||
DeviceIdentityPaths.stateDirURL()
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(fileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func readStore() -> DeviceAuthStoreFile? {
|
||||
let url = fileURL()
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
guard decoded.version == 1 else { return nil }
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func writeStore(_ store: DeviceAuthStoreFile) {
|
||||
let url = fileURL()
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(store)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
} catch {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct DeviceIdentity: Codable, Sendable {
|
||||
var deviceId: String
|
||||
var publicKey: String
|
||||
var privateKey: String
|
||||
var createdAtMs: Int
|
||||
public struct DeviceIdentity: Codable, Sendable {
|
||||
public var deviceId: String
|
||||
public var publicKey: String
|
||||
public var privateKey: String
|
||||
public var createdAtMs: Int
|
||||
|
||||
public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int) {
|
||||
self.deviceId = deviceId
|
||||
self.publicKey = publicKey
|
||||
self.privateKey = privateKey
|
||||
self.createdAtMs = createdAtMs
|
||||
}
|
||||
}
|
||||
|
||||
enum DeviceIdentityPaths {
|
||||
@@ -27,10 +34,10 @@ enum DeviceIdentityPaths {
|
||||
}
|
||||
}
|
||||
|
||||
enum DeviceIdentityStore {
|
||||
public enum DeviceIdentityStore {
|
||||
private static let fileName = "device.json"
|
||||
|
||||
static func loadOrCreate() -> DeviceIdentity {
|
||||
public static func loadOrCreate() -> DeviceIdentity {
|
||||
let url = self.fileURL()
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
|
||||
@@ -44,7 +51,7 @@ enum DeviceIdentityStore {
|
||||
return identity
|
||||
}
|
||||
|
||||
static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
|
||||
public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
|
||||
guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil }
|
||||
do {
|
||||
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
|
||||
@@ -76,7 +83,7 @@ enum DeviceIdentityStore {
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? {
|
||||
public static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? {
|
||||
guard let data = Data(base64Encoded: identity.publicKey) else { return nil }
|
||||
return self.base64UrlEncode(data)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ public struct GatewayConnectOptions: Sendable {
|
||||
// Avoid ambiguity with the app's own AnyCodable type.
|
||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
|
||||
private enum ConnectChallengeError: Error {
|
||||
case timeout
|
||||
}
|
||||
|
||||
public actor GatewayChannelActor {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway")
|
||||
private var task: WebSocketTaskBox?
|
||||
@@ -113,6 +117,7 @@ public actor GatewayChannelActor {
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private let connectTimeoutSeconds: Double = 6
|
||||
private let connectChallengeTimeoutSeconds: Double = 0.75
|
||||
private var watchdogTask: Task<Void, Never>?
|
||||
private var tickTask: Task<Void, Never>?
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
@@ -256,6 +261,8 @@ public actor GatewayChannelActor {
|
||||
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
|
||||
let clientId = options.clientId
|
||||
let clientMode = options.clientMode
|
||||
let role = options.role
|
||||
let scopes = options.scopes
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
var client: [String: ProtoAnyCodable] = [
|
||||
@@ -278,8 +285,8 @@ public actor GatewayChannelActor {
|
||||
"caps": ProtoAnyCodable(options.caps),
|
||||
"locale": ProtoAnyCodable(primaryLocale),
|
||||
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||
"role": ProtoAnyCodable(options.role),
|
||||
"scopes": ProtoAnyCodable(options.scopes),
|
||||
"role": ProtoAnyCodable(role),
|
||||
"scopes": ProtoAnyCodable(scopes),
|
||||
]
|
||||
if !options.commands.isEmpty {
|
||||
params["commands"] = ProtoAnyCodable(options.commands)
|
||||
@@ -287,32 +294,44 @@ public actor GatewayChannelActor {
|
||||
if !options.permissions.isEmpty {
|
||||
params["permissions"] = ProtoAnyCodable(options.permissions)
|
||||
}
|
||||
if let token = self.token {
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
||||
let authToken = storedToken ?? self.token
|
||||
let canFallbackToShared = storedToken != nil && self.token != nil
|
||||
if let authToken {
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
||||
} else if let password = self.password {
|
||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||
}
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let scopes = options.scopes.joined(separator: ",")
|
||||
let payload = [
|
||||
"v1",
|
||||
let connectNonce = try await self.waitForConnectChallenge()
|
||||
let scopesValue = scopes.joined(separator: ",")
|
||||
var payloadParts = [
|
||||
connectNonce == nil ? "v1" : "v2",
|
||||
identity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
options.role,
|
||||
scopes,
|
||||
role,
|
||||
scopesValue,
|
||||
String(signedAtMs),
|
||||
self.token ?? "",
|
||||
].joined(separator: "|")
|
||||
authToken ?? "",
|
||||
]
|
||||
if let connectNonce {
|
||||
payloadParts.append(connectNonce)
|
||||
}
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||
params["device"] = ProtoAnyCodable([
|
||||
var device: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(identity.deviceId),
|
||||
"publicKey": ProtoAnyCodable(publicKey),
|
||||
"signature": ProtoAnyCodable(signature),
|
||||
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||
])
|
||||
]
|
||||
if let connectNonce {
|
||||
device["nonce"] = ProtoAnyCodable(connectNonce)
|
||||
}
|
||||
params["device"] = ProtoAnyCodable(device)
|
||||
}
|
||||
|
||||
let frame = RequestFrame(
|
||||
@@ -322,40 +341,22 @@ public actor GatewayChannelActor {
|
||||
params: ProtoAnyCodable(params))
|
||||
let data = try self.encoder.encode(frame)
|
||||
try await self.task?.send(.data(data))
|
||||
guard let msg = try await task?.receive() else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
|
||||
do {
|
||||
let response = try await self.waitForConnectResponse(reqId: reqId)
|
||||
try await self.handleConnectResponse(response, identity: identity, role: role)
|
||||
} catch {
|
||||
if canFallbackToShared {
|
||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
try await self.handleConnectResponse(msg, reqId: reqId)
|
||||
}
|
||||
|
||||
private func handleConnectResponse(_ msg: URLSessionWebSocketTask.Message, reqId: String) async throws {
|
||||
let data: Data? = switch msg {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (empty response)"])
|
||||
}
|
||||
let decoder = JSONDecoder()
|
||||
guard let frame = try? decoder.decode(GatewayFrame.self, from: data) else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
|
||||
}
|
||||
guard case let .res(res) = frame, res.id == reqId else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (unexpected response)"])
|
||||
}
|
||||
private func handleConnectResponse(
|
||||
_ res: ResponseFrame,
|
||||
identity: DeviceIdentity,
|
||||
role: String
|
||||
) async throws {
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
@@ -373,6 +374,17 @@ public actor GatewayChannelActor {
|
||||
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
|
||||
self.tickIntervalMs = Double(tick)
|
||||
}
|
||||
if let auth = ok.auth,
|
||||
let deviceToken = auth["deviceToken"]?.value as? String {
|
||||
let authRole = auth["role"]?.value as? String ?? role
|
||||
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
|
||||
.compactMap { $0.value as? String } ?? []
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: authRole,
|
||||
token: deviceToken,
|
||||
scopes: scopes)
|
||||
}
|
||||
self.lastTick = Date()
|
||||
self.tickTask?.cancel()
|
||||
self.tickTask = Task { [weak self] in
|
||||
@@ -424,6 +436,7 @@ public actor GatewayChannelActor {
|
||||
waiter.resume(returning: .res(res))
|
||||
}
|
||||
case let .event(evt):
|
||||
if evt.event == "connect.challenge" { return }
|
||||
if let seq = evt.seq {
|
||||
if let last = lastSeq, seq > last + 1 {
|
||||
await self.pushHandler?(.seqGap(expected: last + 1, received: seq))
|
||||
@@ -437,6 +450,63 @@ public actor GatewayChannelActor {
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForConnectChallenge() async throws -> String? {
|
||||
guard let task = self.task else { return nil }
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: self.connectChallengeTimeoutSeconds,
|
||||
onTimeout: { ConnectChallengeError.timeout },
|
||||
operation: { [weak self] in
|
||||
guard let self else { return nil }
|
||||
while true {
|
||||
let msg = try await task.receive()
|
||||
guard let data = self.decodeMessageData(msg) else { continue }
|
||||
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
|
||||
if case let .event(evt) = frame, evt.event == "connect.challenge" {
|
||||
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
||||
let nonce = payload["nonce"]?.value as? String {
|
||||
return nonce
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
if error is ConnectChallengeError { return nil }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame {
|
||||
guard let task = self.task else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
|
||||
}
|
||||
while true {
|
||||
let msg = try await task.receive()
|
||||
guard let data = self.decodeMessageData(msg) else { continue }
|
||||
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
|
||||
}
|
||||
if case let .res(res) = frame, res.id == reqId {
|
||||
return res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
|
||||
let data: Data? = switch msg {
|
||||
case let .data(data): data
|
||||
case let .string(text): text.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func watchTicks() async {
|
||||
let tolerance = self.tickIntervalMs * 2
|
||||
while self.connected {
|
||||
|
||||
@@ -108,5 +108,9 @@ private func sha256Hex(_ data: Data) -> String {
|
||||
}
|
||||
|
||||
private func normalizeFingerprint(_ raw: String) -> String {
|
||||
raw.lowercased().filter(\.isHexDigit)
|
||||
let stripped = raw.replacingOccurrences(
|
||||
of: #"(?i)^sha-?256\s*:?\s*"#,
|
||||
with: "",
|
||||
options: .regularExpression)
|
||||
return stripped.lowercased().filter(\.isHexDigit)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ public let GATEWAY_PROTOCOL_VERSION = 3
|
||||
|
||||
public enum ErrorCode: String, Codable, Sendable {
|
||||
case notLinked = "NOT_LINKED"
|
||||
case notPaired = "NOT_PAIRED"
|
||||
case agentTimeout = "AGENT_TIMEOUT"
|
||||
case invalidRequest = "INVALID_REQUEST"
|
||||
case unavailable = "UNAVAILABLE"
|
||||
@@ -15,6 +16,11 @@ public struct ConnectParams: Codable, Sendable {
|
||||
public let maxprotocol: Int
|
||||
public let client: [String: AnyCodable]
|
||||
public let caps: [String]?
|
||||
public let commands: [String]?
|
||||
public let permissions: [String: AnyCodable]?
|
||||
public let role: String?
|
||||
public let scopes: [String]?
|
||||
public let device: [String: AnyCodable]?
|
||||
public let auth: [String: AnyCodable]?
|
||||
public let locale: String?
|
||||
public let useragent: String?
|
||||
@@ -24,6 +30,11 @@ public struct ConnectParams: Codable, Sendable {
|
||||
maxprotocol: Int,
|
||||
client: [String: AnyCodable],
|
||||
caps: [String]?,
|
||||
commands: [String]?,
|
||||
permissions: [String: AnyCodable]?,
|
||||
role: String?,
|
||||
scopes: [String]?,
|
||||
device: [String: AnyCodable]?,
|
||||
auth: [String: AnyCodable]?,
|
||||
locale: String?,
|
||||
useragent: String?
|
||||
@@ -32,6 +43,11 @@ public struct ConnectParams: Codable, Sendable {
|
||||
self.maxprotocol = maxprotocol
|
||||
self.client = client
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.permissions = permissions
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
self.device = device
|
||||
self.auth = auth
|
||||
self.locale = locale
|
||||
self.useragent = useragent
|
||||
@@ -41,6 +57,11 @@ public struct ConnectParams: Codable, Sendable {
|
||||
case maxprotocol = "maxProtocol"
|
||||
case client
|
||||
case caps
|
||||
case commands
|
||||
case permissions
|
||||
case role
|
||||
case scopes
|
||||
case device
|
||||
case auth
|
||||
case locale
|
||||
case useragent = "userAgent"
|
||||
@@ -54,6 +75,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
public let features: [String: AnyCodable]
|
||||
public let snapshot: Snapshot
|
||||
public let canvashosturl: String?
|
||||
public let auth: [String: AnyCodable]?
|
||||
public let policy: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
@@ -63,6 +85,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
features: [String: AnyCodable],
|
||||
snapshot: Snapshot,
|
||||
canvashosturl: String?,
|
||||
auth: [String: AnyCodable]?,
|
||||
policy: [String: AnyCodable]
|
||||
) {
|
||||
self.type = type
|
||||
@@ -71,6 +94,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
self.features = features
|
||||
self.snapshot = snapshot
|
||||
self.canvashosturl = canvashosturl
|
||||
self.auth = auth
|
||||
self.policy = policy
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -80,6 +104,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
case features
|
||||
case snapshot
|
||||
case canvashosturl = "canvasHostUrl"
|
||||
case auth
|
||||
case policy
|
||||
}
|
||||
}
|
||||
@@ -180,6 +205,9 @@ public struct PresenceEntry: Codable, Sendable {
|
||||
public let tags: [String]?
|
||||
public let text: String?
|
||||
public let ts: Int
|
||||
public let deviceid: String?
|
||||
public let roles: [String]?
|
||||
public let scopes: [String]?
|
||||
public let instanceid: String?
|
||||
|
||||
public init(
|
||||
@@ -195,6 +223,9 @@ public struct PresenceEntry: Codable, Sendable {
|
||||
tags: [String]?,
|
||||
text: String?,
|
||||
ts: Int,
|
||||
deviceid: String?,
|
||||
roles: [String]?,
|
||||
scopes: [String]?,
|
||||
instanceid: String?
|
||||
) {
|
||||
self.host = host
|
||||
@@ -209,6 +240,9 @@ public struct PresenceEntry: Codable, Sendable {
|
||||
self.tags = tags
|
||||
self.text = text
|
||||
self.ts = ts
|
||||
self.deviceid = deviceid
|
||||
self.roles = roles
|
||||
self.scopes = scopes
|
||||
self.instanceid = instanceid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -224,6 +258,9 @@ public struct PresenceEntry: Codable, Sendable {
|
||||
case tags
|
||||
case text
|
||||
case ts
|
||||
case deviceid = "deviceId"
|
||||
case roles
|
||||
case scopes
|
||||
case instanceid = "instanceId"
|
||||
}
|
||||
}
|
||||
@@ -706,6 +743,93 @@ public struct NodeInvokeParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeResultParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let nodeid: String
|
||||
public let ok: Bool
|
||||
public let payload: AnyCodable?
|
||||
public let payloadjson: String?
|
||||
public let error: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
nodeid: String,
|
||||
ok: Bool,
|
||||
payload: AnyCodable?,
|
||||
payloadjson: String?,
|
||||
error: [String: AnyCodable]?
|
||||
) {
|
||||
self.id = id
|
||||
self.nodeid = nodeid
|
||||
self.ok = ok
|
||||
self.payload = payload
|
||||
self.payloadjson = payloadjson
|
||||
self.error = error
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case nodeid = "nodeId"
|
||||
case ok
|
||||
case payload
|
||||
case payloadjson = "payloadJSON"
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeEventParams: Codable, Sendable {
|
||||
public let event: String
|
||||
public let payload: AnyCodable?
|
||||
public let payloadjson: String?
|
||||
|
||||
public init(
|
||||
event: String,
|
||||
payload: AnyCodable?,
|
||||
payloadjson: String?
|
||||
) {
|
||||
self.event = event
|
||||
self.payload = payload
|
||||
self.payloadjson = payloadjson
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case event
|
||||
case payload
|
||||
case payloadjson = "payloadJSON"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeRequestEvent: Codable, Sendable {
|
||||
public let id: String
|
||||
public let nodeid: String
|
||||
public let command: String
|
||||
public let paramsjson: String?
|
||||
public let timeoutms: Int?
|
||||
public let idempotencykey: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
nodeid: String,
|
||||
command: String,
|
||||
paramsjson: String?,
|
||||
timeoutms: Int?,
|
||||
idempotencykey: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.nodeid = nodeid
|
||||
self.command = command
|
||||
self.paramsjson = paramsjson
|
||||
self.timeoutms = timeoutms
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case nodeid = "nodeId"
|
||||
case command
|
||||
case paramsjson = "paramsJSON"
|
||||
case timeoutms = "timeoutMs"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsListParams: Codable, Sendable {
|
||||
public let limit: Int?
|
||||
public let activeminutes: Int?
|
||||
@@ -1381,6 +1505,22 @@ public struct ModelsListResult: Codable, Sendable {
|
||||
public struct SkillsStatusParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct SkillsBinsParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct SkillsBinsResult: Codable, Sendable {
|
||||
public let bins: [String]
|
||||
|
||||
public init(
|
||||
bins: [String]
|
||||
) {
|
||||
self.bins = bins
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case bins
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsInstallParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let installid: String
|
||||
@@ -1735,6 +1875,225 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let command: String
|
||||
public let cwd: String?
|
||||
public let host: String?
|
||||
public let security: String?
|
||||
public let ask: String?
|
||||
public let agentid: String?
|
||||
public let resolvedpath: String?
|
||||
public let sessionkey: String?
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
command: String,
|
||||
cwd: String?,
|
||||
host: String?,
|
||||
security: String?,
|
||||
ask: String?,
|
||||
agentid: String?,
|
||||
resolvedpath: String?,
|
||||
sessionkey: String?,
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
self.host = host
|
||||
self.security = security
|
||||
self.ask = ask
|
||||
self.agentid = agentid
|
||||
self.resolvedpath = resolvedpath
|
||||
self.sessionkey = sessionkey
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case command
|
||||
case cwd
|
||||
case host
|
||||
case security
|
||||
case ask
|
||||
case agentid = "agentId"
|
||||
case resolvedpath = "resolvedPath"
|
||||
case sessionkey = "sessionKey"
|
||||
case timeoutms = "timeoutMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ExecApprovalResolveParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let decision: String
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
decision: String
|
||||
) {
|
||||
self.id = id
|
||||
self.decision = decision
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case decision
|
||||
}
|
||||
}
|
||||
|
||||
public struct DevicePairListParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct DevicePairApproveParams: Codable, Sendable {
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
requestid: String
|
||||
) {
|
||||
self.requestid = requestid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case requestid = "requestId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct DevicePairRejectParams: Codable, Sendable {
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
requestid: String
|
||||
) {
|
||||
self.requestid = requestid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case requestid = "requestId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct DeviceTokenRotateParams: Codable, Sendable {
|
||||
public let deviceid: String
|
||||
public let role: String
|
||||
public let scopes: [String]?
|
||||
|
||||
public init(
|
||||
deviceid: String,
|
||||
role: String,
|
||||
scopes: [String]?
|
||||
) {
|
||||
self.deviceid = deviceid
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case deviceid = "deviceId"
|
||||
case role
|
||||
case scopes
|
||||
}
|
||||
}
|
||||
|
||||
public struct DeviceTokenRevokeParams: Codable, Sendable {
|
||||
public let deviceid: String
|
||||
public let role: String
|
||||
|
||||
public init(
|
||||
deviceid: String,
|
||||
role: String
|
||||
) {
|
||||
self.deviceid = deviceid
|
||||
self.role = role
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case deviceid = "deviceId"
|
||||
case role
|
||||
}
|
||||
}
|
||||
|
||||
public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
public let requestid: String
|
||||
public let deviceid: String
|
||||
public let publickey: String
|
||||
public let displayname: String?
|
||||
public let platform: String?
|
||||
public let clientid: String?
|
||||
public let clientmode: String?
|
||||
public let role: String?
|
||||
public let roles: [String]?
|
||||
public let scopes: [String]?
|
||||
public let remoteip: String?
|
||||
public let silent: Bool?
|
||||
public let isrepair: Bool?
|
||||
public let ts: Int
|
||||
|
||||
public init(
|
||||
requestid: String,
|
||||
deviceid: String,
|
||||
publickey: String,
|
||||
displayname: String?,
|
||||
platform: String?,
|
||||
clientid: String?,
|
||||
clientmode: String?,
|
||||
role: String?,
|
||||
roles: [String]?,
|
||||
scopes: [String]?,
|
||||
remoteip: String?,
|
||||
silent: Bool?,
|
||||
isrepair: Bool?,
|
||||
ts: Int
|
||||
) {
|
||||
self.requestid = requestid
|
||||
self.deviceid = deviceid
|
||||
self.publickey = publickey
|
||||
self.displayname = displayname
|
||||
self.platform = platform
|
||||
self.clientid = clientid
|
||||
self.clientmode = clientmode
|
||||
self.role = role
|
||||
self.roles = roles
|
||||
self.scopes = scopes
|
||||
self.remoteip = remoteip
|
||||
self.silent = silent
|
||||
self.isrepair = isrepair
|
||||
self.ts = ts
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case requestid = "requestId"
|
||||
case deviceid = "deviceId"
|
||||
case publickey = "publicKey"
|
||||
case displayname = "displayName"
|
||||
case platform
|
||||
case clientid = "clientId"
|
||||
case clientmode = "clientMode"
|
||||
case role
|
||||
case roles
|
||||
case scopes
|
||||
case remoteip = "remoteIp"
|
||||
case silent
|
||||
case isrepair = "isRepair"
|
||||
case ts
|
||||
}
|
||||
}
|
||||
|
||||
public struct DevicePairResolvedEvent: Codable, Sendable {
|
||||
public let requestid: String
|
||||
public let deviceid: String
|
||||
public let decision: String
|
||||
public let ts: Int
|
||||
|
||||
public init(
|
||||
requestid: String,
|
||||
deviceid: String,
|
||||
decision: String,
|
||||
ts: Int
|
||||
) {
|
||||
self.requestid = requestid
|
||||
self.deviceid = deviceid
|
||||
self.decision = decision
|
||||
self.ts = ts
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case requestid = "requestId"
|
||||
case deviceid = "deviceId"
|
||||
case decision
|
||||
case ts
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatHistoryParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let limit: Int?
|
||||
|
||||
@@ -1,55 +1,203 @@
|
||||
---
|
||||
summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing)."
|
||||
summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing, advanced actions)."
|
||||
read_when:
|
||||
- Setting up BlueBubbles channel
|
||||
- Troubleshooting webhook pairing
|
||||
- Configuring iMessage on macOS
|
||||
---
|
||||
# BlueBubbles (macOS REST)
|
||||
|
||||
Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS server over HTTP.
|
||||
Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **Recommended for iMessage integration** due to its richer API and easier setup compared to the legacy imsg channel.
|
||||
|
||||
## Overview
|
||||
- Runs on macOS via the BlueBubbles helper app (`https://bluebubbles.app`).
|
||||
- Runs on macOS via the BlueBubbles helper app ([bluebubbles.app](https://bluebubbles.app)).
|
||||
- Recommended/tested: macOS Sequoia (15). macOS Tahoe (26) works; edit is currently broken on Tahoe, and group icon updates may report success but not sync.
|
||||
- Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
|
||||
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
|
||||
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
|
||||
- Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
|
||||
- Reactions are surfaced as system events just like Slack/Telegram so agents can “mention” them before replying.
|
||||
- Reactions are surfaced as system events just like Slack/Telegram so agents can "mention" them before replying.
|
||||
- Advanced features: edit, unsend, reply threading, message effects, group management.
|
||||
|
||||
## Quick start
|
||||
1. Install the BlueBubbles server on your Mac (follows the app store instructions at `https://bluebubbles.app/install`).
|
||||
2. In the BlueBubbles config, enable the web API and set a password for `guid`/`password`.
|
||||
3. Configure Clawdbot:
|
||||
1. Install the BlueBubbles server on your Mac (follow the instructions at [bluebubbles.app/install](https://bluebubbles.app/install)).
|
||||
2. In the BlueBubbles config, enable the web API and set a password.
|
||||
3. Run `clawdbot onboard` and select BlueBubbles, or configure manually:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://bluebubbles-host:1234",
|
||||
serverUrl: "http://192.168.1.100:1234",
|
||||
password: "example-password",
|
||||
webhookPath: "/bluebubbles-webhook",
|
||||
actions: { reactions: true }
|
||||
webhookPath: "/bluebubbles-webhook"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
4. Point BlueBubbles webhooks to your gateway (example: `http://your-gateway-host/bluebubbles-webhook?password=<password>`).
|
||||
4. Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`).
|
||||
5. Start the gateway; it will register the webhook handler and start pairing.
|
||||
|
||||
## Configuration notes
|
||||
- `channels.bluebubbles.serverUrl`: base URL of the BlueBubbles REST API.
|
||||
- `channels.bluebubbles.password`: password that BlueBubbles expects on every request (`?password=...` or header).
|
||||
- `channels.bluebubbles.webhookPath`: HTTP path the gateway exposes for BlueBubbles webhooks.
|
||||
- `channels.bluebubbles.dmPolicy` / `groupPolicy` + `allowFrom`/`groupAllowFrom` behave like other channels; pairing/allowlist info is stored in `/pairing`.
|
||||
- `channels.bluebubbles.actions.reactions` toggles whether the gateway enqueues system events for reactions/tapbacks.
|
||||
- `channels.bluebubbles.textChunkLimit` overrides the default 4k limit.
|
||||
- `channels.bluebubbles.mediaMaxMb` controls the max size of inbound attachments saved for analysis (default 8MB).
|
||||
## Onboarding
|
||||
BlueBubbles is available in the interactive setup wizard:
|
||||
```
|
||||
clawdbot onboard
|
||||
```
|
||||
|
||||
## How it works
|
||||
- Outbound replies: `sendMessageBlueBubbles` resolves a chat GUID via `/api/v1/chat/query` and posts to `/api/v1/message/text`. Typing (`/api/v1/chat/<guid>/typing`) and read receipts (`/api/v1/chat/<guid>/read`) are sent before/after responses.
|
||||
- Webhooks: BlueBubbles POSTs JSON payloads with `type` and `data`. The plugin ignores non-message events (typing indicator, read status) and extracts `chatGuid` from `data.chats[0].guid`.
|
||||
- Reactions/tapbacks generate `BlueBubbles reaction added/removed` system events so agents can mention them. Agents can also trigger tapbacks via the `react` action with `messageId`, `emoji`, and a `to`/`chatGuid`.
|
||||
- Attachments are downloaded via the REST API and stored in the inbound media cache; text-less messages are converted into `<media:...>` placeholders so the agent knows something was sent.
|
||||
The wizard prompts for:
|
||||
- **Server URL** (required): BlueBubbles server address (e.g., `http://192.168.1.100:1234`)
|
||||
- **Password** (required): API password from BlueBubbles Server settings
|
||||
- **Webhook path** (optional): Defaults to `/bluebubbles-webhook`
|
||||
- **DM policy**: pairing, allowlist, open, or disabled
|
||||
- **Allow list**: Phone numbers, emails, or chat targets
|
||||
|
||||
You can also add BlueBubbles via CLI:
|
||||
```
|
||||
clawdbot channels add bluebubbles --http-url http://192.168.1.100:1234 --password <password>
|
||||
```
|
||||
|
||||
## Access control (DMs + groups)
|
||||
DMs:
|
||||
- Default: `channels.bluebubbles.dmPolicy = "pairing"`.
|
||||
- Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
|
||||
- Approve via:
|
||||
- `clawdbot pairing list bluebubbles`
|
||||
- `clawdbot pairing approve bluebubbles <CODE>`
|
||||
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
|
||||
|
||||
Groups:
|
||||
- `channels.bluebubbles.groupPolicy = open | allowlist | disabled` (default: `allowlist`).
|
||||
- `channels.bluebubbles.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
|
||||
|
||||
### Mention gating (groups)
|
||||
BlueBubbles supports mention gating for group chats, matching iMessage/WhatsApp behavior:
|
||||
- Uses `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) to detect mentions.
|
||||
- When `requireMention` is enabled for a group, the agent only responds when mentioned.
|
||||
- Control commands from authorized senders bypass mention gating.
|
||||
|
||||
Per-group configuration:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["+15555550123"],
|
||||
groups: {
|
||||
"*": { requireMention: true }, // default for all groups
|
||||
"iMessage;-;chat123": { requireMention: false } // override for specific group
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Command gating
|
||||
- Control commands (e.g., `/config`, `/model`) require authorization.
|
||||
- Uses `allowFrom` and `groupAllowFrom` to determine command authorization.
|
||||
- Authorized senders can run control commands even without mentioning in groups.
|
||||
|
||||
## Typing + read receipts
|
||||
- **Typing indicators**: Sent automatically before and during response generation.
|
||||
- **Read receipts**: Controlled by `channels.bluebubbles.sendReadReceipts` (default: `true`).
|
||||
- **Typing indicators**: Clawdbot sends typing start events; BlueBubbles clears typing automatically on send or timeout (manual stop via DELETE is unreliable).
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
sendReadReceipts: false // disable read receipts
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced actions
|
||||
BlueBubbles supports advanced message actions when enabled in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
actions: {
|
||||
reactions: true, // tapbacks (default: true)
|
||||
edit: true, // edit sent messages (macOS 13+, broken on macOS 26 Tahoe)
|
||||
unsend: true, // unsend messages (macOS 13+)
|
||||
reply: true, // reply threading by message GUID
|
||||
sendWithEffect: true, // message effects (slam, loud, etc.)
|
||||
renameGroup: true, // rename group chats
|
||||
setGroupIcon: true, // set group chat icon/photo (flaky on macOS 26 Tahoe)
|
||||
addParticipant: true, // add participants to groups
|
||||
removeParticipant: true, // remove participants from groups
|
||||
leaveGroup: true, // leave group chats
|
||||
sendAttachment: true // send attachments/media
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Available actions:
|
||||
- **react**: Add/remove tapback reactions (`messageId`, `emoji`, `remove`)
|
||||
- **edit**: Edit a sent message (`messageId`, `text`)
|
||||
- **unsend**: Unsend a message (`messageId`)
|
||||
- **reply**: Reply to a specific message (`messageId`, `text`, `to`)
|
||||
- **sendWithEffect**: Send with iMessage effect (`text`, `to`, `effectId`)
|
||||
- **renameGroup**: Rename a group chat (`chatGuid`, `displayName`)
|
||||
- **setGroupIcon**: Set a group chat's icon/photo (`chatGuid`, `media`) — flaky on macOS 26 Tahoe (API may return success but the icon does not sync).
|
||||
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
||||
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
||||
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
||||
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
|
||||
|
||||
## Block streaming
|
||||
Control whether responses are sent as a single message or streamed in blocks:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
blockStreaming: true // enable block streaming (default behavior)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Media + limits
|
||||
- Inbound attachments are downloaded and stored in the media cache.
|
||||
- Media cap via `channels.bluebubbles.mediaMaxMb` (default: 8 MB).
|
||||
- Outbound text is chunked to `channels.bluebubbles.textChunkLimit` (default: 4000 chars).
|
||||
|
||||
## Configuration reference
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
Provider options:
|
||||
- `channels.bluebubbles.enabled`: Enable/disable the channel.
|
||||
- `channels.bluebubbles.serverUrl`: BlueBubbles REST API base URL.
|
||||
- `channels.bluebubbles.password`: API password.
|
||||
- `channels.bluebubbles.webhookPath`: Webhook endpoint path (default: `/bluebubbles-webhook`).
|
||||
- `channels.bluebubbles.dmPolicy`: `pairing | allowlist | open | disabled` (default: `pairing`).
|
||||
- `channels.bluebubbles.allowFrom`: DM allowlist (handles, emails, E.164 numbers, `chat_id:*`, `chat_guid:*`).
|
||||
- `channels.bluebubbles.groupPolicy`: `open | allowlist | disabled` (default: `allowlist`).
|
||||
- `channels.bluebubbles.groupAllowFrom`: Group sender allowlist.
|
||||
- `channels.bluebubbles.groups`: Per-group config (`requireMention`, etc.).
|
||||
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
|
||||
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
|
||||
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
|
||||
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
|
||||
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
|
||||
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
|
||||
- `channels.bluebubbles.actions`: Enable/disable specific actions.
|
||||
- `channels.bluebubbles.accounts`: Multi-account configuration.
|
||||
|
||||
Related global options:
|
||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
||||
- `messages.responsePrefix`.
|
||||
|
||||
## Addressing / delivery targets
|
||||
Prefer `chat_guid` for stable routing:
|
||||
- `chat_guid:iMessage;-;+15555550123` (preferred for groups)
|
||||
- `chat_id:123`
|
||||
- `chat_identifier:...`
|
||||
- Direct handles: `+15555550123`, `user@example.com`
|
||||
|
||||
## Security
|
||||
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||
@@ -57,8 +205,12 @@ Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS
|
||||
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
|
||||
|
||||
## Troubleshooting
|
||||
- If Voice/typing events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
|
||||
- If typing/read events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
|
||||
- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`.
|
||||
- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it.
|
||||
- Edit/unsend require macOS 13+ and a compatible BlueBubbles server version. On macOS 26 (Tahoe), edit is currently broken due to private API changes.
|
||||
- Group icon updates can be flaky on macOS 26 (Tahoe): the API may return success but the new icon does not sync.
|
||||
- Clawdbot auto-hides known-broken actions based on the BlueBubbles server's macOS version. If edit still appears on macOS 26 (Tahoe), disable it manually with `channels.bluebubbles.actions.edit=false`.
|
||||
- For status/health info: `clawdbot status --all` or `clawdbot status --deep`.
|
||||
|
||||
For general channel workflow reference, see [/channels/index] and the [[plugins|/plugin]] guide.
|
||||
For general channel workflow reference, see [Channels](/channels) and the [Plugins](/plugins) guide.
|
||||
|
||||
@@ -16,8 +16,8 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||
- [iMessage](/channels/imessage) — macOS only; native integration.
|
||||
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
|
||||
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
|
||||
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||
|
||||
@@ -14,6 +14,7 @@ Clawdbot normalizes shared locations from chat channels into:
|
||||
Currently supported:
|
||||
- **Telegram** (location pins + venues + live locations)
|
||||
- **WhatsApp** (locationMessage + liveLocationMessage)
|
||||
- **Matrix** (`m.location` with `geo_uri`)
|
||||
|
||||
## Text formatting
|
||||
Locations are rendered as friendly lines without brackets:
|
||||
@@ -44,3 +45,4 @@ When a location is present, these fields are added to `ctx`:
|
||||
## Channel notes
|
||||
- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`.
|
||||
- **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line.
|
||||
- **Matrix**: `geo_uri` is parsed as a pin location; altitude is ignored and `LocationIsLive` is always false.
|
||||
|
||||
@@ -5,17 +5,26 @@ read_when:
|
||||
---
|
||||
# Matrix (plugin)
|
||||
|
||||
Status: supported via plugin (matrix-js-sdk). Direct messages, rooms, threads, media, reactions, and polls.
|
||||
Matrix is an open, decentralized messaging protocol. Clawdbot connects as a Matrix **user**
|
||||
on any homeserver, so you need a Matrix account for the bot. Once it is logged in, you can DM
|
||||
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
|
||||
but it requires E2EE to be enabled.
|
||||
|
||||
Status: supported via plugin (matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
|
||||
polls (send + poll-start as text), location, and E2EE (with crypto support).
|
||||
|
||||
## Plugin required
|
||||
|
||||
Matrix ships as a plugin and is not bundled with the core install.
|
||||
|
||||
Install via CLI (npm registry):
|
||||
|
||||
```bash
|
||||
clawdbot plugins install @clawdbot/matrix
|
||||
```
|
||||
|
||||
Local checkout (when running from a git repo):
|
||||
|
||||
```bash
|
||||
clawdbot plugins install ./extensions/matrix
|
||||
```
|
||||
@@ -25,27 +34,54 @@ Clawdbot will offer the local install path automatically.
|
||||
|
||||
Details: [Plugins](/plugin)
|
||||
|
||||
## Quick setup (beginner)
|
||||
## Setup
|
||||
|
||||
1) Install the Matrix plugin:
|
||||
- From npm: `clawdbot plugins install @clawdbot/matrix`
|
||||
- From a local checkout: `clawdbot plugins install ./extensions/matrix`
|
||||
2) Configure credentials:
|
||||
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
|
||||
2) Create a Matrix account on a homeserver:
|
||||
- Browse hosting options at [https://matrix.org/ecosystem/hosting/](https://matrix.org/ecosystem/hosting/)
|
||||
- Or host it yourself.
|
||||
3) Get an access token for the bot account:
|
||||
- Use the Matrix login API with `curl` at your home server:
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url https://matrix.example.org/_matrix/client/v3/login \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"type": "m.login.password",
|
||||
"identifier": {
|
||||
"type": "m.id.user",
|
||||
"user": "your-user-name"
|
||||
},
|
||||
"password": "your-password"
|
||||
}'
|
||||
```
|
||||
|
||||
- Replace `matrix.example.org` with your homeserver URL.
|
||||
- Or set `channels.matrix.userId` + `channels.matrix.password`: Clawdbot calls the same
|
||||
login endpoint, stores the access token in `~/.clawdbot/credentials/matrix/credentials.json`,
|
||||
and reuses it on next start.
|
||||
4) Configure credentials:
|
||||
- Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`)
|
||||
- Or config: `channels.matrix.*`
|
||||
- If both are set, config takes precedence.
|
||||
3) Restart the gateway (or finish onboarding).
|
||||
4) DM access defaults to pairing; approve the pairing code on first contact.
|
||||
- With access token: user ID is fetched automatically via `/whoami`.
|
||||
- When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`).
|
||||
5) Restart the gateway (or finish onboarding).
|
||||
6) Start a DM with the bot or invite it to a room from any Matrix client
|
||||
(Element, Beeper, etc.; see https://matrix.org/ecosystem/clients/). Beeper requires E2EE,
|
||||
so set `channels.matrix.encryption: true` and verify the device.
|
||||
|
||||
Runtime note: Matrix requires Node.js (Bun is not supported).
|
||||
Minimal config (access token, user ID auto-fetched):
|
||||
|
||||
Minimal config:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@clawdbot:example.org",
|
||||
accessToken: "syt_***",
|
||||
dm: { policy: "pairing" }
|
||||
}
|
||||
@@ -53,18 +89,57 @@ Minimal config:
|
||||
}
|
||||
```
|
||||
|
||||
## Encryption (E2EE)
|
||||
End-to-end encrypted rooms are **not** supported.
|
||||
- Use unencrypted rooms or disable encryption when creating the room.
|
||||
- If a room is E2EE, the bot will receive encrypted events and won’t reply.
|
||||
E2EE config (end to end encryption enabled):
|
||||
|
||||
## What it is
|
||||
Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and listens to DMs and rooms.
|
||||
- A Matrix user account owned by the Gateway.
|
||||
- Deterministic routing: replies go back to Matrix.
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "syt_***",
|
||||
encryption: true,
|
||||
dm: { policy: "pairing" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Encryption (E2EE)
|
||||
|
||||
End-to-end encryption is **supported** via the Rust crypto SDK.
|
||||
|
||||
Enable with `channels.matrix.encryption: true`:
|
||||
|
||||
- If the crypto module loads, encrypted rooms are decrypted automatically.
|
||||
- Outbound media is encrypted when sending to encrypted rooms.
|
||||
- On first connection, Clawdbot requests device verification from your other sessions.
|
||||
- Verify the device in another Matrix client (Element, etc.) to enable key sharing.
|
||||
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
|
||||
Clawdbot logs a warning.
|
||||
- If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`),
|
||||
allow build scripts for `@matrix-org/matrix-sdk-crypto-nodejs` and run
|
||||
`pnpm rebuild @matrix-org/matrix-sdk-crypto-nodejs` or fetch the binary with
|
||||
`node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`.
|
||||
|
||||
Crypto state is stored per account + access token in
|
||||
`~/.clawdbot/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/crypto/`
|
||||
(SQLite database). Sync state lives alongside it in `bot-storage.json`.
|
||||
If the access token (device) changes, a new store is created and the bot must be
|
||||
re-verified for encrypted rooms.
|
||||
|
||||
**Device verification:**
|
||||
When E2EE is enabled, the bot will request verification from your other sessions on startup.
|
||||
Open Element (or another client) and approve the verification request to establish trust.
|
||||
Once verified, the bot can decrypt messages in encrypted rooms.
|
||||
|
||||
## Routing model
|
||||
|
||||
- Replies always go back to Matrix.
|
||||
- DMs share the agent's main session; rooms map to group sessions.
|
||||
|
||||
## Access control (DMs)
|
||||
|
||||
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
|
||||
- Approve via:
|
||||
- `clawdbot pairing list matrix`
|
||||
@@ -73,58 +148,80 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
|
||||
- `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available.
|
||||
|
||||
## Rooms (groups)
|
||||
|
||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
- Allowlist rooms with `channels.matrix.rooms`:
|
||||
- Allowlist rooms with `channels.matrix.groups` (room IDs, aliases, or names):
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
rooms: {
|
||||
"!roomId:example.org": { requireMention: true }
|
||||
}
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"!roomId:example.org": { allow: true },
|
||||
"#alias:example.org": { allow: true }
|
||||
},
|
||||
groupAllowFrom: ["@owner:example.org"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `requireMention: false` enables auto-reply in that room.
|
||||
- `groups."*"` can set defaults for mention gating across rooms.
|
||||
- `groupAllowFrom` restricts which senders can trigger the bot in rooms (optional).
|
||||
- Per-room `users` allowlists can further restrict senders inside a specific room.
|
||||
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible.
|
||||
- On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
|
||||
- Invites are auto-joined by default; control with `channels.matrix.autoJoin` and `channels.matrix.autoJoinAllowlist`.
|
||||
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||
- Legacy key: `channels.matrix.rooms` (same shape as `groups`).
|
||||
|
||||
## Threads
|
||||
|
||||
- Reply threading is supported.
|
||||
- `channels.matrix.replyToMode` controls replies when tagged:
|
||||
- `channels.matrix.threadReplies` controls whether replies stay in threads:
|
||||
- `off`, `inbound` (default), `always`
|
||||
- `channels.matrix.replyToMode` controls reply-to metadata when not replying in a thread:
|
||||
- `off` (default), `first`, `all`
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Direct messages | ✅ Supported |
|
||||
| Rooms | ✅ Supported |
|
||||
| Threads | ✅ Supported |
|
||||
| Media | ✅ Supported |
|
||||
| Reactions | ✅ Supported |
|
||||
| Polls | ✅ Supported |
|
||||
| E2EE | ✅ Supported (crypto module required) |
|
||||
| Reactions | ✅ Supported (send/read via tools) |
|
||||
| Polls | ✅ Send supported; inbound poll starts are converted to text (responses/ends ignored) |
|
||||
| Location | ✅ Supported (geo URI; altitude ignored) |
|
||||
| Native commands | ✅ Supported |
|
||||
|
||||
## Configuration reference (Matrix)
|
||||
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
Provider options:
|
||||
|
||||
- `channels.matrix.enabled`: enable/disable channel startup.
|
||||
- `channels.matrix.homeserver`: homeserver URL.
|
||||
- `channels.matrix.userId`: Matrix user ID.
|
||||
- `channels.matrix.userId`: Matrix user ID (optional with access token).
|
||||
- `channels.matrix.accessToken`: access token.
|
||||
- `channels.matrix.password`: password for login (token stored).
|
||||
- `channels.matrix.deviceName`: device display name.
|
||||
- `channels.matrix.encryption`: enable E2EE (default: false).
|
||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||
- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages.
|
||||
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
|
||||
- `channels.matrix.rooms`: per-room settings and allowlist.
|
||||
- `channels.matrix.groups`: group allowlist + per-room settings map.
|
||||
- `channels.matrix.rooms`: legacy group allowlist/config.
|
||||
- `channels.matrix.replyToMode`: reply-to mode for threads/tags.
|
||||
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
|
||||
|
||||
@@ -124,6 +124,8 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
|
||||
Only gateways with Bonjour discovery enabled (default) advertise the beacon.
|
||||
|
||||
Wide-Area discovery records include (TXT):
|
||||
- `role` (gateway role hint)
|
||||
- `transport` (transport hint, e.g. `gateway`)
|
||||
- `gatewayPort` (WebSocket port, usually `18789`)
|
||||
- `sshPort` (SSH port; defaults to `22` if not present)
|
||||
- `tailnetDns` (MagicDNS hostname, when available)
|
||||
|
||||
@@ -20,4 +20,5 @@ Notes:
|
||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||
- Output includes per-agent session stores when multiple agents are configured.
|
||||
- Overview includes Gateway + Node service install/runtime status when available.
|
||||
- Overview includes update channel + git SHA (for source checkouts).
|
||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
||||
|
||||
@@ -15,7 +15,9 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
|
||||
|
||||
```bash
|
||||
clawdbot update
|
||||
clawdbot update status
|
||||
clawdbot update --channel beta
|
||||
clawdbot update --channel dev
|
||||
clawdbot update --tag beta
|
||||
clawdbot update --restart
|
||||
clawdbot update --json
|
||||
@@ -25,22 +27,43 @@ clawdbot --update
|
||||
## Options
|
||||
|
||||
- `--restart`: restart the Gateway daemon after a successful update.
|
||||
- `--channel <stable|beta>`: set the update channel for npm installs (persisted in config).
|
||||
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||
|
||||
Note: downgrades require confirmation because older versions can break configuration.
|
||||
|
||||
## `update status`
|
||||
|
||||
Show the active update channel + git tag/branch/SHA (for source checkouts), plus update availability.
|
||||
|
||||
```bash
|
||||
clawdbot update status
|
||||
clawdbot update status --json
|
||||
clawdbot update status --timeout 10
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--json`: print machine-readable status JSON.
|
||||
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
||||
|
||||
## What it does (git checkout)
|
||||
|
||||
Channels:
|
||||
|
||||
- `stable`: checkout the latest non-beta tag, then build + doctor.
|
||||
- `beta`: checkout the latest `-beta` tag, then build + doctor.
|
||||
- `dev`: checkout `main`, then fetch + rebase.
|
||||
|
||||
High-level:
|
||||
|
||||
1. Requires a clean worktree (no uncommitted changes).
|
||||
2. Fetches and rebases against `@{upstream}`.
|
||||
3. Installs deps (pnpm preferred; npm fallback).
|
||||
4. Builds + builds the Control UI.
|
||||
5. Runs `clawdbot doctor` as the final “safe update” check.
|
||||
2. Switches to the selected channel (tag or branch).
|
||||
3. Fetches and rebases against `@{upstream}` (dev only).
|
||||
4. Installs deps (pnpm preferred; npm fallback).
|
||||
5. Builds + builds the Control UI.
|
||||
6. Runs `clawdbot doctor` as the final “safe update” check.
|
||||
|
||||
## `--update` shorthand
|
||||
|
||||
@@ -49,5 +72,6 @@ High-level:
|
||||
## See also
|
||||
|
||||
- `clawdbot doctor` (offers to run update first on git checkouts)
|
||||
- [Development channels](/install/development-channels)
|
||||
- [Updating](/install/updating)
|
||||
- [CLI reference](/cli)
|
||||
|
||||
@@ -149,6 +149,14 @@ Control how group/room messages are handled per channel:
|
||||
slack: {
|
||||
groupPolicy: "allowlist",
|
||||
channels: { "#general": { allow: true } }
|
||||
},
|
||||
matrix: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["@owner:example.org"],
|
||||
groups: {
|
||||
"!roomId:example.org": { allow: true },
|
||||
"#alias:example.org": { allow: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +173,7 @@ Notes:
|
||||
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
|
||||
- Slack: allowlist uses `channels.slack.channels`.
|
||||
- Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported.
|
||||
- Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`).
|
||||
- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive.
|
||||
- Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked.
|
||||
|
||||
@@ -793,6 +793,7 @@
|
||||
"install/index",
|
||||
"install/installer",
|
||||
"install/updating",
|
||||
"install/development-channels",
|
||||
"install/uninstall",
|
||||
"install/ansible",
|
||||
"install/nix",
|
||||
|
||||
@@ -2697,6 +2697,7 @@ Remote client defaults (CLI):
|
||||
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
|
||||
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
|
||||
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
|
||||
- `gateway.remote.tlsFingerprint` pins the gateway TLS cert fingerprint (sha256).
|
||||
|
||||
macOS app behavior:
|
||||
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
|
||||
@@ -2710,7 +2711,8 @@ macOS app behavior:
|
||||
remote: {
|
||||
url: "ws://gateway.tailnet:18789",
|
||||
token: "your-token",
|
||||
password: "your-password"
|
||||
password: "your-password",
|
||||
tlsFingerprint: "sha256:ab12cd34..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ handshake time.
|
||||
|
||||
## Handshake (connect)
|
||||
|
||||
Gateway → Client (pre-connect challenge):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "connect.challenge",
|
||||
"payload": { "nonce": "…", "ts": 1737264000000 }
|
||||
}
|
||||
```
|
||||
|
||||
Client → Gateway:
|
||||
|
||||
```json
|
||||
@@ -43,7 +53,14 @@ Client → Gateway:
|
||||
"permissions": {},
|
||||
"auth": { "token": "…" },
|
||||
"locale": "en-US",
|
||||
"userAgent": "clawdbot-cli/1.2.3"
|
||||
"userAgent": "clawdbot-cli/1.2.3",
|
||||
"device": {
|
||||
"id": "device_fingerprint",
|
||||
"publicKey": "…",
|
||||
"signature": "…",
|
||||
"signedAt": 1737264000000,
|
||||
"nonce": "…"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -99,7 +116,8 @@ When a device token is issued, `hello-ok` also includes:
|
||||
"id": "device_fingerprint",
|
||||
"publicKey": "…",
|
||||
"signature": "…",
|
||||
"signedAt": 1737264000000
|
||||
"signedAt": 1737264000000,
|
||||
"nonce": "…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,11 +153,22 @@ Nodes declare capability claims at connect time:
|
||||
|
||||
The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
|
||||
## Presence
|
||||
|
||||
- `system-presence` returns entries keyed by device identity.
|
||||
- Presence entries include `deviceId`, `roles`, and `scopes` so UIs can show a single row per device
|
||||
even when it connects as both **operator** and **node**.
|
||||
|
||||
### Node helper methods
|
||||
|
||||
- Nodes may call `skills.bins` to fetch the current list of skill executables
|
||||
for auto-allow checks.
|
||||
|
||||
## Exec approvals
|
||||
|
||||
- When an exec request needs approval, the gateway broadcasts `exec.approval.requested`.
|
||||
- Operator clients resolve by calling `exec.approval.resolve` (requires `operator.approvals` scope).
|
||||
|
||||
## Versioning
|
||||
|
||||
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`.
|
||||
@@ -167,12 +196,13 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
- Pairing approvals are required for new device IDs unless local auto-approval
|
||||
is enabled.
|
||||
- All WS clients must include `device` identity during `connect` (operator + node).
|
||||
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
||||
|
||||
## TLS + pinning
|
||||
|
||||
- TLS is supported for WS connections.
|
||||
- Clients may optionally pin the gateway cert fingerprint (see `gateway.tls`
|
||||
config and client TLS settings).
|
||||
config plus `gateway.remote.tlsFingerprint` or CLI `--tls-fingerprint`).
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ Short version: **keep the Gateway loopback-only** unless you’re sure you need
|
||||
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
|
||||
- **Non-loopback binds** (`lan`/`tailnet`/`auto`) must use auth tokens/passwords.
|
||||
- `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth.
|
||||
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
|
||||
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
|
||||
Set it to `false` if you want tokens/passwords instead.
|
||||
- Treat `browser.controlUrl` like an admin API: tailnet-only + token auth.
|
||||
|
||||
@@ -267,6 +267,7 @@ Doctor can generate one for you: `clawdbot doctor --generate-gateway-token`.
|
||||
|
||||
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
||||
protect local WS access.
|
||||
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
|
||||
|
||||
Auth modes:
|
||||
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
||||
|
||||
@@ -79,6 +79,9 @@ This intentionally excludes version managers (nvm/fnm/volta/asdf) and package
|
||||
managers (pnpm/npm) because the daemon does not load your shell init. Runtime
|
||||
variables like `DISPLAY` should live in `~/.clawdbot/.env` (loaded early by the
|
||||
gateway).
|
||||
Exec runs on `host=gateway` merge your login-shell `PATH` into the exec environment,
|
||||
so missing tools usually mean your shell init isn’t exporting them (or set
|
||||
`tools.exec.pathPrepend`). See [/tools/exec](/tools/exec).
|
||||
|
||||
WhatsApp + Telegram channels require **Node**; Bun is unsupported. If your
|
||||
service was installed with Bun or a version-managed Node path, run `clawdbot doctor`
|
||||
|
||||
56
docs/install/development-channels.md
Normal file
56
docs/install/development-channels.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
summary: "Stable, beta, and dev channels: semantics, switching, and tagging"
|
||||
read_when:
|
||||
- You want to switch between stable/beta/dev
|
||||
- You are tagging or publishing prereleases
|
||||
---
|
||||
|
||||
# Development channels
|
||||
|
||||
Clawdbot ships three update channels:
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`). npm dist-tag: `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`). npm dist-tag: `beta`.
|
||||
- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published).
|
||||
|
||||
## Switching channels
|
||||
|
||||
Git checkout:
|
||||
|
||||
```bash
|
||||
clawdbot update --channel stable
|
||||
clawdbot update --channel beta
|
||||
clawdbot update --channel dev
|
||||
```
|
||||
|
||||
- `stable`/`beta` check out the latest matching tag.
|
||||
- `dev` switches to `main` and rebases on the upstream.
|
||||
|
||||
npm/pnpm global install:
|
||||
|
||||
```bash
|
||||
clawdbot update --channel stable
|
||||
clawdbot update --channel beta
|
||||
clawdbot update --channel dev
|
||||
```
|
||||
|
||||
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
|
||||
|
||||
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
|
||||
|
||||
## Tagging best practices
|
||||
|
||||
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
||||
- Beta: use `vYYYY.M.D-beta.N` (increment `N`).
|
||||
- Keep tags immutable: never move or reuse a tag.
|
||||
- Publish dist-tags alongside git tags:
|
||||
- `latest` → stable
|
||||
- `beta` → prerelease
|
||||
- `dev` → main snapshot (optional)
|
||||
|
||||
## macOS app availability
|
||||
|
||||
Beta and dev builds may **not** include a macOS app release. That’s OK:
|
||||
|
||||
- The git tag and npm dist-tag can still be published.
|
||||
- Call out “no macOS build for this beta” in release notes or changelog.
|
||||
@@ -50,20 +50,18 @@ pnpm add -g clawdbot@latest
|
||||
```
|
||||
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
|
||||
|
||||
To stay on the beta channel for CLI updates:
|
||||
To switch update channels (git + npm installs):
|
||||
|
||||
```bash
|
||||
clawdbot update --channel beta
|
||||
```
|
||||
|
||||
Switch back to stable later:
|
||||
|
||||
```bash
|
||||
clawdbot update --channel dev
|
||||
clawdbot update --channel stable
|
||||
```
|
||||
|
||||
Use `--tag <dist-tag|version>` for a one-off install tag/version.
|
||||
|
||||
See [Development channels](/install/development-channels) for channel semantics and release notes.
|
||||
|
||||
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
|
||||
|
||||
Then:
|
||||
@@ -88,7 +86,8 @@ clawdbot update --restart
|
||||
|
||||
It runs a safe-ish update flow:
|
||||
- Requires a clean worktree.
|
||||
- Fetches + rebases against the configured upstream.
|
||||
- Switches to the selected channel (tag or branch).
|
||||
- Fetches + rebases against the configured upstream (dev channel).
|
||||
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
||||
|
||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
|
||||
|
||||
@@ -375,6 +375,8 @@ Notes:
|
||||
- Put config under `channels.<id>` (not `plugins.entries`).
|
||||
- `meta.label` is used for labels in CLI/UI lists.
|
||||
- `meta.aliases` adds alternate ids for normalization and CLI inputs.
|
||||
- `meta.preferOver` lists channel ids to skip auto-enable when both are configured.
|
||||
- `meta.detailLabel` and `meta.systemImage` let UIs show richer channel labels/icons.
|
||||
|
||||
### Write a new messaging channel (step‑by‑step)
|
||||
|
||||
@@ -388,6 +390,8 @@ Model provider docs live under `/providers/*`.
|
||||
2) Define the channel metadata
|
||||
- `meta.label`, `meta.selectionLabel`, `meta.docsPath`, `meta.blurb` control CLI/UI lists.
|
||||
- `meta.docsPath` should point at a docs page like `/channels/<id>`.
|
||||
- `meta.preferOver` lets a plugin replace another channel (auto-enable prefers it).
|
||||
- `meta.detailLabel` and `meta.systemImage` are used by UIs for detail text/icons.
|
||||
|
||||
3) Implement the required adapters
|
||||
- `config.listAccountIds` + `config.resolveAccount`
|
||||
|
||||
@@ -61,3 +61,6 @@ Optional keys:
|
||||
- The manifest is **required for all plugins**, including local filesystem loads.
|
||||
- Runtime still loads the plugin module separately; the manifest is only for
|
||||
discovery + validation.
|
||||
- If your plugin depends on native modules, document the build steps and any
|
||||
package-manager allowlist requirements (for example, pnpm `allow-build-scripts`
|
||||
+ `pnpm rebuild <package>`).
|
||||
|
||||
@@ -120,7 +120,11 @@ CLI: `clawdbot approvals` supports gateway or node editing (see [Approvals CLI](
|
||||
|
||||
## Approval flow
|
||||
|
||||
When a prompt is required, the companion app displays a confirmation dialog with:
|
||||
When a prompt is required, the gateway broadcasts `exec.approval.requested` to operator clients.
|
||||
The Control UI and macOS app resolve it via `exec.approval.resolve`, then the gateway forwards the
|
||||
approved request to the node host.
|
||||
|
||||
The confirmation dialog includes:
|
||||
- command + args
|
||||
- cwd
|
||||
- agent id
|
||||
|
||||
@@ -57,7 +57,8 @@ Example:
|
||||
|
||||
### PATH handling
|
||||
|
||||
- `host=gateway`: uses the Gateway process `PATH`. Daemons install a minimal `PATH`:
|
||||
- `host=gateway`: merges your login-shell `PATH` into the exec environment (unless the exec call
|
||||
already sets `env.PATH`). The daemon itself still runs with a minimal `PATH`:
|
||||
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
||||
|
||||
@@ -58,6 +58,7 @@ They run immediately, are stripped before the model sees the message, and the re
|
||||
Text + native (when enabled):
|
||||
- `/help`
|
||||
- `/commands`
|
||||
- `/skill <name> [input]` (run a skill by name)
|
||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
@@ -102,6 +103,7 @@ Notes:
|
||||
- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`).
|
||||
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
|
||||
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
|
||||
- `/skill <name> [input]` runs a skill by name (useful when native command limits prevent per-skill commands).
|
||||
- By default, skill commands are forwarded to the model as a normal request.
|
||||
- Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model).
|
||||
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
|
||||
|
||||
@@ -9,9 +9,17 @@
|
||||
"id": "bluebubbles",
|
||||
"label": "BlueBubbles",
|
||||
"selectionLabel": "BlueBubbles (macOS app)",
|
||||
"detailLabel": "BlueBubbles",
|
||||
"docsPath": "/channels/bluebubbles",
|
||||
"docsLabel": "bluebubbles",
|
||||
"blurb": "iMessage via the BlueBubbles mac app + REST API.",
|
||||
"aliases": [
|
||||
"bb"
|
||||
],
|
||||
"preferOver": [
|
||||
"imessage"
|
||||
],
|
||||
"systemImage": "bubble.left.and.text.bubble.right",
|
||||
"order": 75
|
||||
},
|
||||
"install": {
|
||||
|
||||
511
extensions/bluebubbles/src/actions.test.ts
Normal file
511
extensions/bluebubbles/src/actions.test.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
const config = cfg?.channels?.bluebubbles ?? {};
|
||||
return {
|
||||
accountId: accountId ?? "default",
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(config.serverUrl && config.password),
|
||||
config,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./reactions.js", () => ({
|
||||
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
|
||||
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
|
||||
}));
|
||||
|
||||
vi.mock("./chat.js", () => ({
|
||||
editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
|
||||
unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
|
||||
renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
|
||||
setGroupIconBlueBubbles: vi.fn().mockResolvedValue(undefined),
|
||||
addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
|
||||
removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
|
||||
leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./attachments.js", () => ({
|
||||
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
|
||||
}));
|
||||
|
||||
describe("bluebubblesMessageActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("listActions", () => {
|
||||
it("returns empty array when account is not enabled", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { bluebubbles: { enabled: false } },
|
||||
};
|
||||
const actions = bluebubblesMessageActions.listActions({ cfg });
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when account is not configured", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { bluebubbles: { enabled: true } },
|
||||
};
|
||||
const actions = bluebubblesMessageActions.listActions({ cfg });
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns react action when enabled and configured", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
const actions = bluebubblesMessageActions.listActions({ cfg });
|
||||
expect(actions).toContain("react");
|
||||
});
|
||||
|
||||
it("excludes react action when reactions are gated off", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
actions: { reactions: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
const actions = bluebubblesMessageActions.listActions({ cfg });
|
||||
expect(actions).not.toContain("react");
|
||||
// Other actions should still be present
|
||||
expect(actions).toContain("edit");
|
||||
expect(actions).toContain("unsend");
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsAction", () => {
|
||||
it("returns true for react action", () => {
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "react" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for all supported actions", () => {
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "edit" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "unsend" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "setGroupIcon" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "sendAttachment" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unsupported actions", () => {
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "delete" })).toBe(false);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "unknown" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractToolSend", () => {
|
||||
it("extracts send params from sendMessage action", () => {
|
||||
const result = bluebubblesMessageActions.extractToolSend({
|
||||
args: {
|
||||
action: "sendMessage",
|
||||
to: "+15551234567",
|
||||
accountId: "test-account",
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
to: "+15551234567",
|
||||
accountId: "test-account",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-sendMessage action", () => {
|
||||
const result = bluebubblesMessageActions.extractToolSend({
|
||||
args: { action: "react", to: "+15551234567" },
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when to is missing", () => {
|
||||
const result = bluebubblesMessageActions.extractToolSend({
|
||||
args: { action: "sendMessage" },
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAction", () => {
|
||||
it("throws for unsupported actions", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "unknownAction",
|
||||
params: {},
|
||||
cfg,
|
||||
accountId: null,
|
||||
}),
|
||||
).rejects.toThrow("is not supported");
|
||||
});
|
||||
|
||||
it("throws when emoji is missing for react action", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { messageId: "msg-123" },
|
||||
cfg,
|
||||
accountId: null,
|
||||
}),
|
||||
).rejects.toThrow(/emoji/i);
|
||||
});
|
||||
|
||||
it("throws when messageId is missing", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { emoji: "❤️" },
|
||||
cfg,
|
||||
accountId: null,
|
||||
}),
|
||||
).rejects.toThrow("messageId");
|
||||
});
|
||||
|
||||
it("throws when chatGuid cannot be resolved", async () => {
|
||||
const { resolveChatGuidForTarget } = await import("./send.js");
|
||||
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(null);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { emoji: "❤️", messageId: "msg-123", to: "+15551234567" },
|
||||
cfg,
|
||||
accountId: null,
|
||||
}),
|
||||
).rejects.toThrow("chatGuid not found");
|
||||
});
|
||||
|
||||
it("sends reaction successfully with chatGuid", async () => {
|
||||
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "❤️",
|
||||
messageId: "msg-123",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
});
|
||||
|
||||
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "❤️",
|
||||
}),
|
||||
);
|
||||
// jsonResult returns { content: [...], details: payload }
|
||||
expect(result).toMatchObject({
|
||||
details: { ok: true, added: "❤️" },
|
||||
});
|
||||
});
|
||||
|
||||
it("sends reaction removal successfully", async () => {
|
||||
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "❤️",
|
||||
messageId: "msg-123",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
remove: true,
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
});
|
||||
|
||||
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remove: true,
|
||||
}),
|
||||
);
|
||||
// jsonResult returns { content: [...], details: payload }
|
||||
expect(result).toMatchObject({
|
||||
details: { ok: true, removed: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves chatGuid from to parameter", async () => {
|
||||
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||
const { resolveChatGuidForTarget } = await import("./send.js");
|
||||
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543");
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "👍",
|
||||
messageId: "msg-456",
|
||||
to: "+15559876543",
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
});
|
||||
|
||||
expect(resolveChatGuidForTarget).toHaveBeenCalled();
|
||||
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatGuid: "iMessage;-;+15559876543",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes partIndex when provided", async () => {
|
||||
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "😂",
|
||||
messageId: "msg-789",
|
||||
chatGuid: "iMessage;-;chat-guid",
|
||||
partIndex: 2,
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
});
|
||||
|
||||
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
partIndex: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts message param for edit action", async () => {
|
||||
const { editBlueBubblesMessage } = await import("./chat.js");
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "edit",
|
||||
params: { messageId: "msg-123", message: "updated" },
|
||||
cfg,
|
||||
accountId: null,
|
||||
});
|
||||
|
||||
expect(editBlueBubblesMessage).toHaveBeenCalledWith(
|
||||
"msg-123",
|
||||
"updated",
|
||||
expect.objectContaining({ cfg, accountId: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts message/target aliases for sendWithEffect", async () => {
|
||||
const { sendMessageBlueBubbles } = await import("./send.js");
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await bluebubblesMessageActions.handleAction({
|
||||
action: "sendWithEffect",
|
||||
params: {
|
||||
message: "peekaboo",
|
||||
target: "+15551234567",
|
||||
effect: "invisible ink",
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
});
|
||||
|
||||
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
||||
"+15551234567",
|
||||
"peekaboo",
|
||||
expect.objectContaining({ effectId: "invisible ink" }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
details: { ok: true, messageId: "msg-123", effect: "invisible ink" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when buffer is missing for setGroupIcon", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "setGroupIcon",
|
||||
params: { chatGuid: "iMessage;-;chat-guid" },
|
||||
cfg,
|
||||
accountId: null,
|
||||
}),
|
||||
).rejects.toThrow(/requires an image/i);
|
||||
});
|
||||
|
||||
it("sets group icon successfully with chatGuid and buffer", async () => {
|
||||
const { setGroupIconBlueBubbles } = await import("./chat.js");
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Base64 encode a simple test buffer
|
||||
const testBuffer = Buffer.from("fake-image-data");
|
||||
const base64Buffer = testBuffer.toString("base64");
|
||||
|
||||
const result = await bluebubblesMessageActions.handleAction({
|
||||
action: "setGroupIcon",
|
||||
params: {
|
||||
chatGuid: "iMessage;-;chat-guid",
|
||||
buffer: base64Buffer,
|
||||
filename: "group-icon.png",
|
||||
contentType: "image/png",
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
});
|
||||
|
||||
expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
|
||||
"iMessage;-;chat-guid",
|
||||
expect.any(Uint8Array),
|
||||
"group-icon.png",
|
||||
expect.objectContaining({ contentType: "image/png" }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
details: { ok: true, chatGuid: "iMessage;-;chat-guid", iconSet: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default filename when not provided for setGroupIcon", async () => {
|
||||
const { setGroupIconBlueBubbles } = await import("./chat.js");
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const base64Buffer = Buffer.from("test").toString("base64");
|
||||
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "setGroupIcon",
|
||||
params: {
|
||||
chatGuid: "iMessage;-;chat-guid",
|
||||
buffer: base64Buffer,
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
});
|
||||
|
||||
expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
|
||||
"iMessage;-;chat-guid",
|
||||
expect.any(Uint8Array),
|
||||
"icon.png",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
BLUEBUBBLES_ACTION_NAMES,
|
||||
BLUEBUBBLES_ACTIONS,
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readBooleanParam,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
@@ -11,8 +14,19 @@ import {
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { isMacOS26OrHigher } from "./probe.js";
|
||||
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { resolveChatGuidForTarget } from "./send.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import {
|
||||
editBlueBubblesMessage,
|
||||
unsendBlueBubblesMessage,
|
||||
renameBlueBubblesChat,
|
||||
setGroupIconBlueBubbles,
|
||||
addBlueBubblesParticipant,
|
||||
removeBlueBubblesParticipant,
|
||||
leaveBlueBubblesChat,
|
||||
} from "./chat.js";
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||
import type { BlueBubblesSendTarget } from "./types.js";
|
||||
|
||||
@@ -32,16 +46,29 @@ function mapTarget(raw: string): BlueBubblesSendTarget {
|
||||
};
|
||||
}
|
||||
|
||||
function readMessageText(params: Record<string, unknown>): string | undefined {
|
||||
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
||||
}
|
||||
|
||||
/** Supported action names for BlueBubbles */
|
||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||
|
||||
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig });
|
||||
if (!account.enabled || !account.configured) return [];
|
||||
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
|
||||
const actions = new Set<ChannelMessageActionName>();
|
||||
if (gate("reactions")) actions.add("react");
|
||||
const macOS26 = isMacOS26OrHigher(account.accountId);
|
||||
for (const action of BLUEBUBBLES_ACTION_NAMES) {
|
||||
const spec = BLUEBUBBLES_ACTIONS[action];
|
||||
if (!spec?.gate) continue;
|
||||
if (spec.unsupportedOnMacOS26 && macOS26) continue;
|
||||
if (gate(spec.gate)) actions.add(action);
|
||||
}
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsAction: ({ action }) => action === "react",
|
||||
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
||||
extractToolSend: ({ args }): ChannelToolSend | null => {
|
||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||
if (action !== "sendMessage") return null;
|
||||
@@ -51,71 +78,301 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
return { to, accountId };
|
||||
},
|
||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||
if (action !== "react") {
|
||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||
}
|
||||
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
|
||||
});
|
||||
if (isEmpty && !remove) {
|
||||
throw new Error("Emoji is required to send a BlueBubbles reaction.");
|
||||
}
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const chatGuid = readStringParam(params, "chatGuid");
|
||||
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
||||
const chatId = readNumberParam(params, "chatId", { integer: true });
|
||||
const to = readStringParam(params, "to");
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
const baseUrl = account.config.serverUrl?.trim();
|
||||
const password = account.config.password?.trim();
|
||||
const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined };
|
||||
|
||||
// Helper to resolve chatGuid from various params
|
||||
const resolveChatGuid = async (): Promise<string> => {
|
||||
const chatGuid = readStringParam(params, "chatGuid");
|
||||
if (chatGuid?.trim()) return chatGuid.trim();
|
||||
|
||||
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
||||
const chatId = readNumberParam(params, "chatId", { integer: true });
|
||||
const to = readStringParam(params, "to");
|
||||
|
||||
const target = chatIdentifier?.trim()
|
||||
? ({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: chatIdentifier.trim(),
|
||||
} as BlueBubblesSendTarget)
|
||||
: typeof chatId === "number"
|
||||
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
||||
: to
|
||||
? mapTarget(to)
|
||||
: null;
|
||||
|
||||
let resolvedChatGuid = chatGuid?.trim() || "";
|
||||
if (!resolvedChatGuid) {
|
||||
const target =
|
||||
chatIdentifier?.trim()
|
||||
? ({ kind: "chat_identifier", chatIdentifier: chatIdentifier.trim() } as BlueBubblesSendTarget)
|
||||
: typeof chatId === "number"
|
||||
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
||||
: to
|
||||
? mapTarget(to)
|
||||
: null;
|
||||
if (!target) {
|
||||
throw new Error("BlueBubbles reaction requires chatGuid, chatIdentifier, chatId, or to.");
|
||||
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
|
||||
}
|
||||
if (!baseUrl || !password) {
|
||||
throw new Error("BlueBubbles reaction requires serverUrl and password.");
|
||||
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`);
|
||||
}
|
||||
resolvedChatGuid =
|
||||
(await resolveChatGuidForTarget({
|
||||
baseUrl,
|
||||
password,
|
||||
target,
|
||||
})) ?? "";
|
||||
}
|
||||
if (!resolvedChatGuid) {
|
||||
throw new Error("BlueBubbles reaction failed: chatGuid not found for target.");
|
||||
|
||||
const resolved = await resolveChatGuidForTarget({ baseUrl, password, target });
|
||||
if (!resolved) {
|
||||
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
// Handle react action
|
||||
if (action === "react") {
|
||||
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
|
||||
});
|
||||
if (isEmpty && !remove) {
|
||||
throw new Error(
|
||||
"BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_guid>.",
|
||||
);
|
||||
}
|
||||
const messageId = readStringParam(params, "messageId");
|
||||
if (!messageId) {
|
||||
throw new Error(
|
||||
"BlueBubbles react requires messageId parameter (the message GUID to react to). " +
|
||||
"Use action=react with messageId=<message_guid>, emoji=<emoji>, and to/chatGuid to identify the chat.",
|
||||
);
|
||||
}
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: resolvedChatGuid,
|
||||
messageGuid: messageId,
|
||||
emoji,
|
||||
remove: remove || undefined,
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
opts,
|
||||
});
|
||||
|
||||
return jsonResult({ ok: true, ...(remove ? { removed: true } : { added: emoji }) });
|
||||
}
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: resolvedChatGuid,
|
||||
messageGuid: messageId,
|
||||
emoji,
|
||||
remove: remove || undefined,
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
opts: {
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
});
|
||||
// Handle edit action
|
||||
if (action === "edit") {
|
||||
// Edit is not supported on macOS 26+
|
||||
if (isMacOS26OrHigher(accountId ?? undefined)) {
|
||||
throw new Error(
|
||||
"BlueBubbles edit is not supported on macOS 26 or higher. " +
|
||||
"Apple removed the ability to edit iMessages in this version.",
|
||||
);
|
||||
}
|
||||
const messageId = readStringParam(params, "messageId");
|
||||
const newText =
|
||||
readStringParam(params, "text") ??
|
||||
readStringParam(params, "newText") ??
|
||||
readStringParam(params, "message");
|
||||
if (!messageId || !newText) {
|
||||
const missing: string[] = [];
|
||||
if (!messageId) missing.push("messageId (the message GUID to edit)");
|
||||
if (!newText) missing.push("text (the new message content)");
|
||||
throw new Error(
|
||||
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
|
||||
`Use action=edit with messageId=<message_guid>, text=<new_content>.`,
|
||||
);
|
||||
}
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
||||
|
||||
if (!remove) {
|
||||
return jsonResult({ ok: true, added: emoji });
|
||||
await editBlueBubblesMessage(messageId, newText, {
|
||||
...opts,
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
||||
});
|
||||
|
||||
return jsonResult({ ok: true, edited: messageId });
|
||||
}
|
||||
return jsonResult({ ok: true, removed: true });
|
||||
|
||||
// Handle unsend action
|
||||
if (action === "unsend") {
|
||||
const messageId = readStringParam(params, "messageId");
|
||||
if (!messageId) {
|
||||
throw new Error(
|
||||
"BlueBubbles unsend requires messageId parameter (the message GUID to unsend). " +
|
||||
"Use action=unsend with messageId=<message_guid>.",
|
||||
);
|
||||
}
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
|
||||
await unsendBlueBubblesMessage(messageId, {
|
||||
...opts,
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
});
|
||||
|
||||
return jsonResult({ ok: true, unsent: messageId });
|
||||
}
|
||||
|
||||
// Handle reply action
|
||||
if (action === "reply") {
|
||||
const messageId = readStringParam(params, "messageId");
|
||||
const text = readMessageText(params);
|
||||
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
||||
if (!messageId || !text || !to) {
|
||||
const missing: string[] = [];
|
||||
if (!messageId) missing.push("messageId (the message GUID to reply to)");
|
||||
if (!text) missing.push("text or message (the reply message content)");
|
||||
if (!to) missing.push("to or target (the chat target)");
|
||||
throw new Error(
|
||||
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
|
||||
`Use action=reply with messageId=<message_guid>, message=<your reply>, target=<chat_target>.`,
|
||||
);
|
||||
}
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
|
||||
const result = await sendMessageBlueBubbles(to, text, {
|
||||
...opts,
|
||||
replyToMessageGuid: messageId,
|
||||
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
});
|
||||
|
||||
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: messageId });
|
||||
}
|
||||
|
||||
// Handle sendWithEffect action
|
||||
if (action === "sendWithEffect") {
|
||||
const text = readMessageText(params);
|
||||
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
||||
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
|
||||
if (!text || !to || !effectId) {
|
||||
const missing: string[] = [];
|
||||
if (!text) missing.push("text or message (the message content)");
|
||||
if (!to) missing.push("to or target (the chat target)");
|
||||
if (!effectId)
|
||||
missing.push(
|
||||
"effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)",
|
||||
);
|
||||
throw new Error(
|
||||
`BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` +
|
||||
`Use action=sendWithEffect with message=<message>, target=<chat_target>, effectId=<effect_name>.`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await sendMessageBlueBubbles(to, text, {
|
||||
...opts,
|
||||
effectId,
|
||||
});
|
||||
|
||||
return jsonResult({ ok: true, messageId: result.messageId, effect: effectId });
|
||||
}
|
||||
|
||||
// Handle renameGroup action
|
||||
if (action === "renameGroup") {
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
|
||||
if (!displayName) {
|
||||
throw new Error("BlueBubbles renameGroup requires displayName or name parameter.");
|
||||
}
|
||||
|
||||
await renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
|
||||
|
||||
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
|
||||
}
|
||||
|
||||
// Handle setGroupIcon action
|
||||
if (action === "setGroupIcon") {
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
const base64Buffer = readStringParam(params, "buffer");
|
||||
const filename =
|
||||
readStringParam(params, "filename") ??
|
||||
readStringParam(params, "name") ??
|
||||
"icon.png";
|
||||
const contentType =
|
||||
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
||||
|
||||
if (!base64Buffer) {
|
||||
throw new Error(
|
||||
"BlueBubbles setGroupIcon requires an image. " +
|
||||
"Use action=setGroupIcon with media=<image_url> or path=<local_file_path> to set the group icon.",
|
||||
);
|
||||
}
|
||||
|
||||
// Decode base64 to buffer
|
||||
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
|
||||
|
||||
await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
|
||||
...opts,
|
||||
contentType: contentType ?? undefined,
|
||||
});
|
||||
|
||||
return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true });
|
||||
}
|
||||
|
||||
// Handle addParticipant action
|
||||
if (action === "addParticipant") {
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
|
||||
if (!address) {
|
||||
throw new Error("BlueBubbles addParticipant requires address or participant parameter.");
|
||||
}
|
||||
|
||||
await addBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
||||
|
||||
return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid });
|
||||
}
|
||||
|
||||
// Handle removeParticipant action
|
||||
if (action === "removeParticipant") {
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
|
||||
if (!address) {
|
||||
throw new Error("BlueBubbles removeParticipant requires address or participant parameter.");
|
||||
}
|
||||
|
||||
await removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
||||
|
||||
return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
|
||||
}
|
||||
|
||||
// Handle leaveGroup action
|
||||
if (action === "leaveGroup") {
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
|
||||
await leaveBlueBubblesChat(resolvedChatGuid, opts);
|
||||
|
||||
return jsonResult({ ok: true, left: resolvedChatGuid });
|
||||
}
|
||||
|
||||
// Handle sendAttachment action
|
||||
if (action === "sendAttachment") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const filename = readStringParam(params, "filename", { required: true });
|
||||
const caption = readStringParam(params, "caption");
|
||||
const contentType =
|
||||
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
||||
|
||||
// Buffer can come from params.buffer (base64) or params.path (file path)
|
||||
const base64Buffer = readStringParam(params, "buffer");
|
||||
const filePath = readStringParam(params, "path") ?? readStringParam(params, "filePath");
|
||||
|
||||
let buffer: Uint8Array;
|
||||
if (base64Buffer) {
|
||||
// Decode base64 to buffer
|
||||
buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
|
||||
} else if (filePath) {
|
||||
// Read file from path (will be handled by caller providing buffer)
|
||||
throw new Error(
|
||||
"BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64.",
|
||||
);
|
||||
} else {
|
||||
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
|
||||
}
|
||||
|
||||
const result = await sendBlueBubblesAttachment({
|
||||
to,
|
||||
buffer,
|
||||
filename,
|
||||
contentType: contentType ?? undefined,
|
||||
caption: caption ?? undefined,
|
||||
opts,
|
||||
});
|
||||
|
||||
return jsonResult({ ok: true, messageId: result.messageId });
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||
},
|
||||
};
|
||||
|
||||
240
extensions/bluebubbles/src/attachments.test.ts
Normal file
240
extensions/bluebubbles/src/attachments.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
const config = cfg?.channels?.bluebubbles ?? {};
|
||||
return {
|
||||
accountId: accountId ?? "default",
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(config.serverUrl && config.password),
|
||||
config,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
describe("downloadBlueBubblesAttachment", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("throws when guid is missing", async () => {
|
||||
const attachment: BlueBubblesAttachment = {};
|
||||
await expect(
|
||||
downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
}),
|
||||
).rejects.toThrow("guid is required");
|
||||
});
|
||||
|
||||
it("throws when guid is empty string", async () => {
|
||||
const attachment: BlueBubblesAttachment = { guid: " " };
|
||||
await expect(
|
||||
downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
}),
|
||||
).rejects.toThrow("guid is required");
|
||||
});
|
||||
|
||||
it("throws when serverUrl is missing", async () => {
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-123" };
|
||||
await expect(downloadBlueBubblesAttachment(attachment, {})).rejects.toThrow(
|
||||
"serverUrl is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when password is missing", async () => {
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-123" };
|
||||
await expect(
|
||||
downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
}),
|
||||
).rejects.toThrow("password is required");
|
||||
});
|
||||
|
||||
it("downloads attachment successfully", async () => {
|
||||
const mockBuffer = new Uint8Array([1, 2, 3, 4]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-123" };
|
||||
const result = await downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
});
|
||||
|
||||
expect(result.buffer).toEqual(mockBuffer);
|
||||
expect(result.contentType).toBe("image/png");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/attachment/att-123/download"),
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes password in URL query", async () => {
|
||||
const mockBuffer = new Uint8Array([1, 2, 3, 4]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/jpeg" }),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-456" };
|
||||
await downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "my-secret-password",
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("password=my-secret-password");
|
||||
});
|
||||
|
||||
it("encodes guid in URL", async () => {
|
||||
const mockBuffer = new Uint8Array([1]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" };
|
||||
await downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("att%2Fwith%2Fspecial%20chars");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: () => Promise.resolve("Attachment not found"),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-missing" };
|
||||
await expect(
|
||||
downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("download failed (404): Attachment not found");
|
||||
});
|
||||
|
||||
it("throws when attachment exceeds max bytes", async () => {
|
||||
const largeBuffer = new Uint8Array(10 * 1024 * 1024);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
||||
await expect(
|
||||
downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
}),
|
||||
).rejects.toThrow("too large");
|
||||
});
|
||||
|
||||
it("uses default max bytes when not specified", async () => {
|
||||
const largeBuffer = new Uint8Array(9 * 1024 * 1024);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
||||
await expect(
|
||||
downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("too large");
|
||||
});
|
||||
|
||||
it("uses attachment mimeType as fallback when response has no content-type", async () => {
|
||||
const mockBuffer = new Uint8Array([1, 2, 3]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = {
|
||||
guid: "att-789",
|
||||
mimeType: "video/mp4",
|
||||
};
|
||||
const result = await downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result.contentType).toBe("video/mp4");
|
||||
});
|
||||
|
||||
it("prefers response content-type over attachment mimeType", async () => {
|
||||
const mockBuffer = new Uint8Array([1, 2, 3]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/webp" }),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = {
|
||||
guid: "att-xyz",
|
||||
mimeType: "image/png",
|
||||
};
|
||||
const result = await downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result.contentType).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("resolves credentials from config when opts not provided", async () => {
|
||||
const mockBuffer = new Uint8Array([1]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-config" };
|
||||
const result = await downloadBlueBubblesAttachment(attachment, {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://config-server:5678",
|
||||
password: "config-password",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("config-server:5678");
|
||||
expect(calledUrl).toContain("password=config-password");
|
||||
expect(result.buffer).toEqual(new Uint8Array([1]));
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { resolveChatGuidForTarget } from "./send.js";
|
||||
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import {
|
||||
blueBubblesFetchWithTimeout,
|
||||
buildBlueBubblesApiUrl,
|
||||
type BlueBubblesAttachment,
|
||||
type BlueBubblesSendTarget,
|
||||
} from "./types.js";
|
||||
|
||||
export type BlueBubblesAttachmentOpts = {
|
||||
@@ -55,3 +59,168 @@ export async function downloadBlueBubblesAttachment(
|
||||
}
|
||||
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
|
||||
}
|
||||
|
||||
export type SendBlueBubblesAttachmentResult = {
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
||||
const parsed = parseBlueBubblesTarget(raw);
|
||||
if (parsed.kind === "handle") {
|
||||
return {
|
||||
kind: "handle",
|
||||
address: normalizeBlueBubblesHandle(parsed.to),
|
||||
service: parsed.service,
|
||||
};
|
||||
}
|
||||
if (parsed.kind === "chat_id") {
|
||||
return { kind: "chat_id", chatId: parsed.chatId };
|
||||
}
|
||||
if (parsed.kind === "chat_guid") {
|
||||
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
||||
}
|
||||
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
||||
}
|
||||
|
||||
function extractMessageId(payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") return "unknown";
|
||||
const record = payload as Record<string, unknown>;
|
||||
const data = record.data && typeof record.data === "object" ? (record.data as Record<string, unknown>) : null;
|
||||
const candidates = [
|
||||
record.messageId,
|
||||
record.guid,
|
||||
record.id,
|
||||
data?.messageId,
|
||||
data?.guid,
|
||||
data?.id,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||
if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate);
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an attachment via BlueBubbles API.
|
||||
* Supports sending media files (images, videos, audio, documents) to a chat.
|
||||
*/
|
||||
export async function sendBlueBubblesAttachment(params: {
|
||||
to: string;
|
||||
buffer: Uint8Array;
|
||||
filename: string;
|
||||
contentType?: string;
|
||||
caption?: string;
|
||||
replyToMessageGuid?: string;
|
||||
replyToPartIndex?: number;
|
||||
opts?: BlueBubblesAttachmentOpts;
|
||||
}): Promise<SendBlueBubblesAttachmentResult> {
|
||||
const { to, buffer, filename, contentType, caption, replyToMessageGuid, replyToPartIndex, opts = {} } =
|
||||
params;
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
|
||||
const target = resolveSendTarget(to);
|
||||
const chatGuid = await resolveChatGuidForTarget({
|
||||
baseUrl,
|
||||
password,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
target,
|
||||
});
|
||||
if (!chatGuid) {
|
||||
throw new Error(
|
||||
"BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
||||
);
|
||||
}
|
||||
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: "/api/v1/message/attachment",
|
||||
password,
|
||||
});
|
||||
|
||||
// Build FormData with the attachment
|
||||
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
const parts: Uint8Array[] = [];
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Helper to add a form field
|
||||
const addField = (name: string, value: string) => {
|
||||
parts.push(encoder.encode(`--${boundary}\r\n`));
|
||||
parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`));
|
||||
parts.push(encoder.encode(`${value}\r\n`));
|
||||
};
|
||||
|
||||
// Helper to add a file field
|
||||
const addFile = (name: string, fileBuffer: Uint8Array, fileName: string, mimeType?: string) => {
|
||||
parts.push(encoder.encode(`--${boundary}\r\n`));
|
||||
parts.push(
|
||||
encoder.encode(
|
||||
`Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`,
|
||||
),
|
||||
);
|
||||
parts.push(encoder.encode(`Content-Type: ${mimeType ?? "application/octet-stream"}\r\n\r\n`));
|
||||
parts.push(fileBuffer);
|
||||
parts.push(encoder.encode("\r\n"));
|
||||
};
|
||||
|
||||
// Add required fields
|
||||
addFile("attachment", buffer, filename, contentType);
|
||||
addField("chatGuid", chatGuid);
|
||||
addField("name", filename);
|
||||
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
||||
addField("method", "private-api");
|
||||
|
||||
const trimmedReplyTo = replyToMessageGuid?.trim();
|
||||
if (trimmedReplyTo) {
|
||||
addField("selectedMessageGuid", trimmedReplyTo);
|
||||
addField(
|
||||
"partIndex",
|
||||
typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0",
|
||||
);
|
||||
}
|
||||
|
||||
// Add optional caption
|
||||
if (caption) {
|
||||
addField("message", caption);
|
||||
addField("text", caption);
|
||||
addField("caption", caption);
|
||||
}
|
||||
|
||||
// Close the multipart body
|
||||
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
||||
|
||||
// Combine all parts into a single buffer
|
||||
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
|
||||
const body = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
body.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
},
|
||||
opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
|
||||
const responseBody = await res.text();
|
||||
if (!responseBody) return { messageId: "ok" };
|
||||
try {
|
||||
const parsed = JSON.parse(responseBody) as unknown;
|
||||
return { messageId: extractMessageId(parsed) };
|
||||
} catch {
|
||||
return { messageId: "ok" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "claw
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
collectBlueBubblesStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
@@ -18,20 +20,30 @@ import {
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
import { probeBlueBubbles } from "./probe.js";
|
||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
||||
import { sendMessageBlueBubbles } from "./send.js";
|
||||
import { normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import {
|
||||
looksLikeBlueBubblesTargetId,
|
||||
normalizeBlueBubblesHandle,
|
||||
normalizeBlueBubblesMessagingTarget,
|
||||
} from "./targets.js";
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||
import { blueBubblesOnboardingAdapter } from "./onboarding.js";
|
||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||
|
||||
const meta = {
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
selectionLabel: "BlueBubbles (macOS app)",
|
||||
detailLabel: "BlueBubbles",
|
||||
docsPath: "/channels/bluebubbles",
|
||||
docsLabel: "bluebubbles",
|
||||
blurb: "iMessage via the BlueBubbles mac app + REST API.",
|
||||
systemImage: "bubble.left.and.text.bubble.right",
|
||||
aliases: ["bb"],
|
||||
order: 75,
|
||||
preferOver: ["imessage"],
|
||||
};
|
||||
|
||||
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
@@ -39,11 +51,27 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
meta,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: false,
|
||||
media: true,
|
||||
reactions: true,
|
||||
edit: true,
|
||||
unsend: true,
|
||||
reply: true,
|
||||
effects: true,
|
||||
groupManagement: true,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
reload: { configPrefixes: ["channels.bluebubbles"] },
|
||||
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
||||
onboarding: blueBubblesOnboardingAdapter,
|
||||
config: {
|
||||
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as ClawdbotConfig),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
@@ -111,6 +139,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
];
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeBlueBubblesMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeBlueBubblesTargetId,
|
||||
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
@@ -152,6 +187,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
enabled: true,
|
||||
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
||||
...(input.password ? { password: input.password } : {}),
|
||||
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
@@ -170,6 +206,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
enabled: true,
|
||||
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
||||
...(input.password ? { password: input.password } : {}),
|
||||
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -199,15 +236,39 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId }) => {
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||
const replyToMessageGuid = typeof replyToId === "string" ? replyToId.trim() : "";
|
||||
const result = await sendMessageBlueBubbles(to, text, {
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||
});
|
||||
return { channel: "bluebubbles", ...result };
|
||||
},
|
||||
sendMedia: async () => {
|
||||
throw new Error("BlueBubbles media delivery is not supported yet.");
|
||||
sendMedia: async (ctx) => {
|
||||
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
|
||||
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
|
||||
mediaPath?: string;
|
||||
mediaBuffer?: Uint8Array;
|
||||
contentType?: string;
|
||||
filename?: string;
|
||||
caption?: string;
|
||||
};
|
||||
const resolvedCaption = caption ?? text;
|
||||
const result = await sendBlueBubblesMedia({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
to,
|
||||
mediaUrl,
|
||||
mediaPath,
|
||||
mediaBuffer,
|
||||
contentType,
|
||||
filename,
|
||||
caption: resolvedCaption ?? undefined,
|
||||
replyToId: replyToId ?? null,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
|
||||
return { channel: "bluebubbles", ...result };
|
||||
},
|
||||
},
|
||||
status: {
|
||||
@@ -218,19 +279,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts) =>
|
||||
accounts.flatMap((account) => {
|
||||
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||
if (!lastError) return [];
|
||||
return [
|
||||
{
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
kind: "runtime",
|
||||
message: `Channel error: ${lastError}`,
|
||||
},
|
||||
];
|
||||
}),
|
||||
collectStatusIssues: collectBlueBubblesStatusIssues,
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
baseUrl: snapshot.baseUrl ?? null,
|
||||
@@ -247,20 +296,25 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
password: account.config.password ?? null,
|
||||
timeoutMs,
|
||||
}),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
baseUrl: account.baseUrl,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
}),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||
const running = runtime?.running ?? false;
|
||||
const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
baseUrl: account.baseUrl,
|
||||
running,
|
||||
connected: probeOk ?? running,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
|
||||
462
extensions/bluebubbles/src/chat.test.ts
Normal file
462
extensions/bluebubbles/src/chat.test.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
const config = cfg?.channels?.bluebubbles ?? {};
|
||||
return {
|
||||
accountId: accountId ?? "default",
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(config.serverUrl && config.password),
|
||||
config,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
describe("chat", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("markBlueBubblesChatRead", () => {
|
||||
it("does nothing when chatGuid is empty", async () => {
|
||||
await markBlueBubblesChatRead("", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when chatGuid is whitespace", async () => {
|
||||
await markBlueBubblesChatRead(" ", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when serverUrl is missing", async () => {
|
||||
await expect(markBlueBubblesChatRead("chat-guid", {})).rejects.toThrow(
|
||||
"serverUrl is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when password is missing", async () => {
|
||||
await expect(
|
||||
markBlueBubblesChatRead("chat-guid", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
}),
|
||||
).rejects.toThrow("password is required");
|
||||
});
|
||||
|
||||
it("marks chat as read successfully", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await markBlueBubblesChatRead("iMessage;-;+15551234567", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/read"),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes password in URL query", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await markBlueBubblesChatRead("chat-123", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "my-secret",
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("password=my-secret");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: () => Promise.resolve("Chat not found"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
markBlueBubblesChatRead("missing-chat", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("read failed (404): Chat not found");
|
||||
});
|
||||
|
||||
it("trims chatGuid before using", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await markBlueBubblesChatRead(" chat-with-spaces ", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/read");
|
||||
expect(calledUrl).not.toContain("%20chat");
|
||||
});
|
||||
|
||||
it("resolves credentials from config", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await markBlueBubblesChatRead("chat-123", {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://config-server:9999",
|
||||
password: "config-pass",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("config-server:9999");
|
||||
expect(calledUrl).toContain("password=config-pass");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendBlueBubblesTyping", () => {
|
||||
it("does nothing when chatGuid is empty", async () => {
|
||||
await sendBlueBubblesTyping("", true, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when chatGuid is whitespace", async () => {
|
||||
await sendBlueBubblesTyping(" ", false, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when serverUrl is missing", async () => {
|
||||
await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow(
|
||||
"serverUrl is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when password is missing", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesTyping("chat-guid", true, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
}),
|
||||
).rejects.toThrow("password is required");
|
||||
});
|
||||
|
||||
it("sends typing start with POST method", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends typing stop with DELETE method", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesTyping("iMessage;-;+15551234567", false, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"),
|
||||
expect.objectContaining({ method: "DELETE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes password in URL query", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesTyping("chat-123", true, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "typing-secret",
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("password=typing-secret");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Internal error"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendBlueBubblesTyping("chat-123", true, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("typing failed (500): Internal error");
|
||||
});
|
||||
|
||||
it("trims chatGuid before using", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesTyping(" trimmed-chat ", true, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("/api/v1/chat/trimmed-chat/typing");
|
||||
});
|
||||
|
||||
it("encodes special characters in chatGuid", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesTyping("iMessage;+;group@chat.com", true, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("iMessage%3B%2B%3Bgroup%40chat.com");
|
||||
});
|
||||
|
||||
it("resolves credentials from config", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesTyping("chat-123", true, {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://typing-server:8888",
|
||||
password: "typing-pass",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("typing-server:8888");
|
||||
expect(calledUrl).toContain("password=typing-pass");
|
||||
});
|
||||
|
||||
it("can start and stop typing in sequence", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesTyping("chat-123", true, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
await sendBlueBubblesTyping("chat-123", false, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch.mock.calls[0][1].method).toBe("POST");
|
||||
expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setGroupIconBlueBubbles", () => {
|
||||
it("throws when chatGuid is empty", async () => {
|
||||
await expect(
|
||||
setGroupIconBlueBubbles("", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("chatGuid");
|
||||
});
|
||||
|
||||
it("throws when buffer is empty", async () => {
|
||||
await expect(
|
||||
setGroupIconBlueBubbles("chat-guid", new Uint8Array(0), "icon.png", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("image buffer");
|
||||
});
|
||||
|
||||
it("throws when serverUrl is missing", async () => {
|
||||
await expect(
|
||||
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}),
|
||||
).rejects.toThrow("serverUrl is required");
|
||||
});
|
||||
|
||||
it("throws when password is missing", async () => {
|
||||
await expect(
|
||||
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
}),
|
||||
).rejects.toThrow("password is required");
|
||||
});
|
||||
|
||||
it("sets group icon successfully", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
|
||||
await setGroupIconBlueBubbles("iMessage;-;chat-guid", buffer, "icon.png", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/chat/iMessage%3B-%3Bchat-guid/icon"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": expect.stringContaining("multipart/form-data"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes password in URL query", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "my-secret",
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("password=my-secret");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Internal error"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("setGroupIcon failed (500): Internal error");
|
||||
});
|
||||
|
||||
it("trims chatGuid before using", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await setGroupIconBlueBubbles(" chat-with-spaces ", new Uint8Array([1]), "icon.png", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/icon");
|
||||
expect(calledUrl).not.toContain("%20chat");
|
||||
});
|
||||
|
||||
it("resolves credentials from config", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://config-server:9999",
|
||||
password: "config-pass",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("config-server:9999");
|
||||
expect(calledUrl).toContain("password=config-pass");
|
||||
});
|
||||
|
||||
it("includes filename in multipart body", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "custom-icon.jpg", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1].body as Uint8Array;
|
||||
const bodyString = new TextDecoder().decode(body);
|
||||
expect(bodyString).toContain('filename="custom-icon.jpg"');
|
||||
expect(bodyString).toContain("image/jpeg");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
@@ -64,3 +65,290 @@ export async function sendBlueBubblesTyping(
|
||||
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a message via BlueBubbles API.
|
||||
* Requires macOS 13 (Ventura) or higher with Private API enabled.
|
||||
*/
|
||||
export async function editBlueBubblesMessage(
|
||||
messageGuid: string,
|
||||
newText: string,
|
||||
opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {},
|
||||
): Promise<void> {
|
||||
const trimmedGuid = messageGuid.trim();
|
||||
if (!trimmedGuid) throw new Error("BlueBubbles edit requires messageGuid");
|
||||
const trimmedText = newText.trim();
|
||||
if (!trimmedText) throw new Error("BlueBubbles edit requires newText");
|
||||
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
|
||||
password,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
editedMessage: trimmedText,
|
||||
backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
|
||||
partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
|
||||
};
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
opts.timeoutMs,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsend (retract) a message via BlueBubbles API.
|
||||
* Requires macOS 13 (Ventura) or higher with Private API enabled.
|
||||
*/
|
||||
export async function unsendBlueBubblesMessage(
|
||||
messageGuid: string,
|
||||
opts: BlueBubblesChatOpts & { partIndex?: number } = {},
|
||||
): Promise<void> {
|
||||
const trimmedGuid = messageGuid.trim();
|
||||
if (!trimmedGuid) throw new Error("BlueBubbles unsend requires messageGuid");
|
||||
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
|
||||
password,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
|
||||
};
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
opts.timeoutMs,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a group chat via BlueBubbles API.
|
||||
*/
|
||||
export async function renameBlueBubblesChat(
|
||||
chatGuid: string,
|
||||
displayName: string,
|
||||
opts: BlueBubblesChatOpts = {},
|
||||
): Promise<void> {
|
||||
const trimmedGuid = chatGuid.trim();
|
||||
if (!trimmedGuid) throw new Error("BlueBubbles rename requires chatGuid");
|
||||
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
|
||||
password,
|
||||
});
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ displayName }),
|
||||
},
|
||||
opts.timeoutMs,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a participant to a group chat via BlueBubbles API.
|
||||
*/
|
||||
export async function addBlueBubblesParticipant(
|
||||
chatGuid: string,
|
||||
address: string,
|
||||
opts: BlueBubblesChatOpts = {},
|
||||
): Promise<void> {
|
||||
const trimmedGuid = chatGuid.trim();
|
||||
if (!trimmedGuid) throw new Error("BlueBubbles addParticipant requires chatGuid");
|
||||
const trimmedAddress = address.trim();
|
||||
if (!trimmedAddress) throw new Error("BlueBubbles addParticipant requires address");
|
||||
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
|
||||
password,
|
||||
});
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ address: trimmedAddress }),
|
||||
},
|
||||
opts.timeoutMs,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a participant from a group chat via BlueBubbles API.
|
||||
*/
|
||||
export async function removeBlueBubblesParticipant(
|
||||
chatGuid: string,
|
||||
address: string,
|
||||
opts: BlueBubblesChatOpts = {},
|
||||
): Promise<void> {
|
||||
const trimmedGuid = chatGuid.trim();
|
||||
if (!trimmedGuid) throw new Error("BlueBubbles removeParticipant requires chatGuid");
|
||||
const trimmedAddress = address.trim();
|
||||
if (!trimmedAddress) throw new Error("BlueBubbles removeParticipant requires address");
|
||||
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
|
||||
password,
|
||||
});
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ address: trimmedAddress }),
|
||||
},
|
||||
opts.timeoutMs,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(`BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a group chat via BlueBubbles API.
|
||||
*/
|
||||
export async function leaveBlueBubblesChat(
|
||||
chatGuid: string,
|
||||
opts: BlueBubblesChatOpts = {},
|
||||
): Promise<void> {
|
||||
const trimmedGuid = chatGuid.trim();
|
||||
if (!trimmedGuid) throw new Error("BlueBubbles leaveChat requires chatGuid");
|
||||
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
|
||||
password,
|
||||
});
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{ method: "POST" },
|
||||
opts.timeoutMs,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a group chat's icon/photo via BlueBubbles API.
|
||||
* Requires Private API to be enabled.
|
||||
*/
|
||||
export async function setGroupIconBlueBubbles(
|
||||
chatGuid: string,
|
||||
buffer: Uint8Array,
|
||||
filename: string,
|
||||
opts: BlueBubblesChatOpts & { contentType?: string } = {},
|
||||
): Promise<void> {
|
||||
const trimmedGuid = chatGuid.trim();
|
||||
if (!trimmedGuid) throw new Error("BlueBubbles setGroupIcon requires chatGuid");
|
||||
if (!buffer || buffer.length === 0) {
|
||||
throw new Error("BlueBubbles setGroupIcon requires image buffer");
|
||||
}
|
||||
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
|
||||
password,
|
||||
});
|
||||
|
||||
// Build multipart form-data
|
||||
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
const parts: Uint8Array[] = [];
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Add file field named "icon" as per API spec
|
||||
parts.push(encoder.encode(`--${boundary}\r\n`));
|
||||
parts.push(
|
||||
encoder.encode(
|
||||
`Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`,
|
||||
),
|
||||
);
|
||||
parts.push(
|
||||
encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`),
|
||||
);
|
||||
parts.push(buffer);
|
||||
parts.push(encoder.encode("\r\n"));
|
||||
|
||||
// Close multipart body
|
||||
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
||||
|
||||
// Combine into single buffer
|
||||
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
|
||||
const body = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
body.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
},
|
||||
opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,24 @@ const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const bluebubblesActionSchema = z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
reactions: z.boolean().default(true),
|
||||
edit: z.boolean().default(true),
|
||||
unsend: z.boolean().default(true),
|
||||
reply: z.boolean().default(true),
|
||||
sendWithEffect: z.boolean().default(true),
|
||||
renameGroup: z.boolean().default(true),
|
||||
setGroupIcon: z.boolean().default(true),
|
||||
addParticipant: z.boolean().default(true),
|
||||
removeParticipant: z.boolean().default(true),
|
||||
leaveGroup: z.boolean().default(true),
|
||||
sendAttachment: z.boolean().default(true),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const bluebubblesGroupConfigSchema = z.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const bluebubblesAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
@@ -22,6 +36,9 @@ const bluebubblesAccountSchema = z.object({
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
|
||||
});
|
||||
|
||||
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
|
||||
|
||||
159
extensions/bluebubbles/src/media-send.ts
Normal file
159
extensions/bluebubbles/src/media-send.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { sendMessageBlueBubbles } from "./send.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
|
||||
const HTTP_URL_RE = /^https?:\/\//i;
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void {
|
||||
if (typeof maxBytes !== "number" || maxBytes <= 0) return;
|
||||
if (sizeBytes <= maxBytes) return;
|
||||
const maxLabel = (maxBytes / MB).toFixed(0);
|
||||
const sizeLabel = (sizeBytes / MB).toFixed(2);
|
||||
throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`);
|
||||
}
|
||||
|
||||
function resolveLocalMediaPath(source: string): string {
|
||||
if (!source.startsWith("file://")) return source;
|
||||
try {
|
||||
return fileURLToPath(source);
|
||||
} catch {
|
||||
throw new Error(`Invalid file:// URL: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFilenameFromSource(source?: string): string | undefined {
|
||||
if (!source) return undefined;
|
||||
if (source.startsWith("file://")) {
|
||||
try {
|
||||
return path.basename(fileURLToPath(source)) || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (HTTP_URL_RE.test(source)) {
|
||||
try {
|
||||
return path.basename(new URL(source).pathname) || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const base = path.basename(source);
|
||||
return base || undefined;
|
||||
}
|
||||
|
||||
export async function sendBlueBubblesMedia(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
mediaUrl?: string;
|
||||
mediaPath?: string;
|
||||
mediaBuffer?: Uint8Array;
|
||||
contentType?: string;
|
||||
filename?: string;
|
||||
caption?: string;
|
||||
replyToId?: string | null;
|
||||
accountId?: string;
|
||||
}) {
|
||||
const {
|
||||
cfg,
|
||||
to,
|
||||
mediaUrl,
|
||||
mediaPath,
|
||||
mediaBuffer,
|
||||
contentType,
|
||||
filename,
|
||||
caption,
|
||||
replyToId,
|
||||
accountId,
|
||||
} = params;
|
||||
const core = getBlueBubblesRuntime();
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
cfg.channels?.bluebubbles?.accounts?.[accountId]?.mediaMaxMb ??
|
||||
cfg.channels?.bluebubbles?.mediaMaxMb,
|
||||
accountId,
|
||||
});
|
||||
|
||||
let buffer: Uint8Array;
|
||||
let resolvedContentType = contentType ?? undefined;
|
||||
let resolvedFilename = filename ?? undefined;
|
||||
|
||||
if (mediaBuffer) {
|
||||
assertMediaWithinLimit(mediaBuffer.byteLength, maxBytes);
|
||||
buffer = mediaBuffer;
|
||||
if (!resolvedContentType) {
|
||||
const hint = mediaPath ?? mediaUrl;
|
||||
const detected = await core.media.detectMime({
|
||||
buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer),
|
||||
filePath: hint,
|
||||
});
|
||||
resolvedContentType = detected ?? undefined;
|
||||
}
|
||||
if (!resolvedFilename) {
|
||||
resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl);
|
||||
}
|
||||
} else {
|
||||
const source = mediaPath ?? mediaUrl;
|
||||
if (!source) {
|
||||
throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer.");
|
||||
}
|
||||
if (HTTP_URL_RE.test(source)) {
|
||||
const fetched = await core.channel.media.fetchRemoteMedia({
|
||||
url: source,
|
||||
maxBytes: typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined,
|
||||
});
|
||||
buffer = fetched.buffer;
|
||||
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
|
||||
resolvedFilename = resolvedFilename ?? fetched.fileName;
|
||||
} else {
|
||||
const localPath = resolveLocalMediaPath(source);
|
||||
const fs = await import("node:fs/promises");
|
||||
if (typeof maxBytes === "number" && maxBytes > 0) {
|
||||
const stats = await fs.stat(localPath);
|
||||
assertMediaWithinLimit(stats.size, maxBytes);
|
||||
}
|
||||
const data = await fs.readFile(localPath);
|
||||
assertMediaWithinLimit(data.byteLength, maxBytes);
|
||||
buffer = new Uint8Array(data);
|
||||
if (!resolvedContentType) {
|
||||
const detected = await core.media.detectMime({
|
||||
buffer: data,
|
||||
filePath: localPath,
|
||||
});
|
||||
resolvedContentType = detected ?? undefined;
|
||||
}
|
||||
if (!resolvedFilename) {
|
||||
resolvedFilename = resolveFilenameFromSource(localPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const attachmentResult = await sendBlueBubblesAttachment({
|
||||
to,
|
||||
buffer,
|
||||
filename: resolvedFilename ?? "attachment",
|
||||
contentType: resolvedContentType ?? undefined,
|
||||
replyToMessageGuid: replyToId?.trim() || undefined,
|
||||
opts: {
|
||||
cfg,
|
||||
accountId,
|
||||
},
|
||||
});
|
||||
|
||||
const trimmedCaption = caption?.trim();
|
||||
if (trimmedCaption) {
|
||||
await sendMessageBlueBubbles(to, trimmedCaption, {
|
||||
cfg,
|
||||
accountId,
|
||||
replyToMessageGuid: replyToId?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return attachmentResult;
|
||||
}
|
||||
1644
extensions/bluebubbles/src/monitor.test.ts
Normal file
1644
extensions/bluebubbles/src/monitor.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,13 @@ import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import { resolveAckReaction } from "../../../src/agents/identity.js";
|
||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { fetchBlueBubblesServerInfo } from "./probe.js";
|
||||
|
||||
export type BlueBubblesRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
@@ -25,6 +29,7 @@ export type BlueBubblesMonitorOptions = {
|
||||
|
||||
const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
|
||||
const DEFAULT_TEXT_LIMIT = 4000;
|
||||
const invalidAckReactions = new Set<string>();
|
||||
|
||||
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
|
||||
|
||||
@@ -34,6 +39,35 @@ function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv
|
||||
}
|
||||
}
|
||||
|
||||
function logGroupAllowlistHint(params: {
|
||||
runtime: BlueBubblesRuntimeEnv;
|
||||
reason: string;
|
||||
entry: string | null;
|
||||
chatName?: string;
|
||||
accountId?: string;
|
||||
}): void {
|
||||
const log = params.runtime.log ?? console.log;
|
||||
const nameHint = params.chatName ? ` (group name: ${params.chatName})` : "";
|
||||
const accountHint = params.accountId
|
||||
? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)`
|
||||
: "";
|
||||
if (params.entry) {
|
||||
log(
|
||||
`[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` +
|
||||
`"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`,
|
||||
);
|
||||
log(
|
||||
`[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
log(
|
||||
`[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` +
|
||||
`channels.bluebubbles.groupPolicy="open" or adding a group id to ` +
|
||||
`channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`,
|
||||
);
|
||||
}
|
||||
|
||||
type WebhookTarget = {
|
||||
account: ResolvedBlueBubblesAccount;
|
||||
config: ClawdbotConfig;
|
||||
@@ -183,6 +217,21 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatReplyContext(message: {
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
}): string | null {
|
||||
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
|
||||
const sender = message.replyToSender?.trim() || "unknown sender";
|
||||
const idPart = message.replyToId ? ` id:${message.replyToId}` : "";
|
||||
const body = message.replyToBody?.trim();
|
||||
if (!body) {
|
||||
return `[Replying to ${sender}${idPart}]\n[/Replying]`;
|
||||
}
|
||||
return `[Replying to ${sender}${idPart}]\n${body}\n[/Replying]`;
|
||||
}
|
||||
|
||||
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||
if (!record) return undefined;
|
||||
const value = record[key];
|
||||
@@ -194,6 +243,77 @@ function readNumberLike(record: Record<string, unknown> | null, key: string): nu
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractReplyMetadata(message: Record<string, unknown>): {
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
} {
|
||||
const replyRaw =
|
||||
message["replyTo"] ??
|
||||
message["reply_to"] ??
|
||||
message["replyToMessage"] ??
|
||||
message["reply_to_message"] ??
|
||||
message["repliedMessage"] ??
|
||||
message["quotedMessage"] ??
|
||||
message["associatedMessage"] ??
|
||||
message["reply"];
|
||||
const replyRecord = asRecord(replyRaw);
|
||||
const replyHandle = asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
|
||||
const replySenderRaw =
|
||||
readString(replyHandle, "address") ??
|
||||
readString(replyHandle, "handle") ??
|
||||
readString(replyHandle, "id") ??
|
||||
readString(replyRecord, "senderId") ??
|
||||
readString(replyRecord, "sender") ??
|
||||
readString(replyRecord, "from");
|
||||
const normalizedSender = replySenderRaw
|
||||
? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim()
|
||||
: undefined;
|
||||
|
||||
const replyToBody =
|
||||
readString(replyRecord, "text") ??
|
||||
readString(replyRecord, "body") ??
|
||||
readString(replyRecord, "message") ??
|
||||
readString(replyRecord, "subject") ??
|
||||
undefined;
|
||||
|
||||
const directReplyId =
|
||||
readString(message, "replyToMessageGuid") ??
|
||||
readString(message, "replyToGuid") ??
|
||||
readString(message, "replyGuid") ??
|
||||
readString(message, "selectedMessageGuid") ??
|
||||
readString(message, "selectedMessageId") ??
|
||||
readString(message, "replyToMessageId") ??
|
||||
readString(message, "replyId") ??
|
||||
readString(replyRecord, "guid") ??
|
||||
readString(replyRecord, "id") ??
|
||||
readString(replyRecord, "messageId");
|
||||
|
||||
const associatedType =
|
||||
readNumberLike(message, "associatedMessageType") ??
|
||||
readNumberLike(message, "associated_message_type");
|
||||
const associatedGuid =
|
||||
readString(message, "associatedMessageGuid") ??
|
||||
readString(message, "associated_message_guid") ??
|
||||
readString(message, "associatedMessageId");
|
||||
const isReactionAssociation =
|
||||
typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
|
||||
|
||||
const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
|
||||
const threadOriginatorGuid = readString(message, "threadOriginatorGuid");
|
||||
const messageGuid = readString(message, "guid");
|
||||
const fallbackReplyId =
|
||||
!replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid
|
||||
? threadOriginatorGuid
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
|
||||
replyToBody: replyToBody?.trim() || undefined,
|
||||
replyToSender: normalizedSender || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const chats = message["chats"];
|
||||
if (!Array.isArray(chats) || chats.length === 0) return null;
|
||||
@@ -201,6 +321,108 @@ function readFirstChatRecord(message: Record<string, unknown>): Record<string, u
|
||||
return asRecord(first);
|
||||
}
|
||||
|
||||
function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
|
||||
if (typeof entry === "string" || typeof entry === "number") {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw) return null;
|
||||
const normalized = normalizeBlueBubblesHandle(raw) || raw;
|
||||
return normalized ? { id: normalized } : null;
|
||||
}
|
||||
const record = asRecord(entry);
|
||||
if (!record) return null;
|
||||
const nestedHandle =
|
||||
asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
|
||||
const idRaw =
|
||||
readString(record, "address") ??
|
||||
readString(record, "handle") ??
|
||||
readString(record, "id") ??
|
||||
readString(record, "phoneNumber") ??
|
||||
readString(record, "phone_number") ??
|
||||
readString(record, "email") ??
|
||||
readString(nestedHandle, "address") ??
|
||||
readString(nestedHandle, "handle") ??
|
||||
readString(nestedHandle, "id");
|
||||
const nameRaw =
|
||||
readString(record, "displayName") ??
|
||||
readString(record, "name") ??
|
||||
readString(record, "title") ??
|
||||
readString(nestedHandle, "displayName") ??
|
||||
readString(nestedHandle, "name");
|
||||
const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
|
||||
if (!normalizedId) return null;
|
||||
const name = nameRaw?.trim() || undefined;
|
||||
return { id: normalizedId, name };
|
||||
}
|
||||
|
||||
function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) return [];
|
||||
const seen = new Set<string>();
|
||||
const output: BlueBubblesParticipant[] = [];
|
||||
for (const entry of raw) {
|
||||
const normalized = normalizeParticipantEntry(entry);
|
||||
if (!normalized?.id) continue;
|
||||
const key = normalized.id.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function formatGroupMembers(params: {
|
||||
participants?: BlueBubblesParticipant[];
|
||||
fallback?: BlueBubblesParticipant;
|
||||
}): string | undefined {
|
||||
const seen = new Set<string>();
|
||||
const ordered: BlueBubblesParticipant[] = [];
|
||||
for (const entry of params.participants ?? []) {
|
||||
if (!entry?.id) continue;
|
||||
const key = entry.id.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
ordered.push(entry);
|
||||
}
|
||||
if (ordered.length === 0 && params.fallback?.id) {
|
||||
ordered.push(params.fallback);
|
||||
}
|
||||
if (ordered.length === 0) return undefined;
|
||||
return ordered
|
||||
.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
|
||||
const guid = chatGuid?.trim();
|
||||
if (!guid) return undefined;
|
||||
const parts = guid.split(";");
|
||||
if (parts.length >= 3) {
|
||||
if (parts[1] === "+") return true;
|
||||
if (parts[1] === "-") return false;
|
||||
}
|
||||
if (guid.includes(";+;")) return true;
|
||||
if (guid.includes(";-;")) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatGroupAllowlistEntry(params: {
|
||||
chatGuid?: string;
|
||||
chatId?: number;
|
||||
chatIdentifier?: string;
|
||||
}): string | null {
|
||||
const guid = params.chatGuid?.trim();
|
||||
if (guid) return `chat_guid:${guid}`;
|
||||
const chatId = params.chatId;
|
||||
if (typeof chatId === "number" && Number.isFinite(chatId)) return `chat_id:${chatId}`;
|
||||
const identifier = params.chatIdentifier?.trim();
|
||||
if (identifier) return `chat_identifier:${identifier}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
type BlueBubblesParticipant = {
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type NormalizedWebhookMessage = {
|
||||
text: string;
|
||||
senderId: string;
|
||||
@@ -215,6 +437,10 @@ type NormalizedWebhookMessage = {
|
||||
fromMe?: boolean;
|
||||
attachments?: BlueBubblesAttachment[];
|
||||
balloonBundleId?: string;
|
||||
participants?: BlueBubblesParticipant[];
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
};
|
||||
|
||||
type NormalizedWebhookReaction = {
|
||||
@@ -252,6 +478,31 @@ function maskSecret(value: string): string {
|
||||
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
||||
}
|
||||
|
||||
function resolveBlueBubblesAckReaction(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
core: BlueBubblesCoreRuntime;
|
||||
runtime: BlueBubblesRuntimeEnv;
|
||||
}): string | null {
|
||||
const raw = resolveAckReaction(params.cfg, params.agentId).trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
normalizeBlueBubblesReactionInput(raw);
|
||||
return raw;
|
||||
} catch {
|
||||
const key = raw.toLowerCase();
|
||||
if (!invalidAckReactions.has(key)) {
|
||||
invalidAckReactions.add(key);
|
||||
logVerbose(
|
||||
params.core,
|
||||
params.runtime,
|
||||
`ack reaction skipped (unsupported for BlueBubbles): ${raw}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const dataRaw = payload.data ?? payload.payload ?? payload.event;
|
||||
const data =
|
||||
@@ -331,13 +582,18 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
||||
: Array.isArray(chatsParticipants)
|
||||
? chatsParticipants
|
||||
: [];
|
||||
const normalizedParticipants = normalizeParticipantList(participants);
|
||||
const participantsCount = participants.length;
|
||||
const isGroup =
|
||||
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
||||
const explicitIsGroup =
|
||||
readBoolean(message, "isGroup") ??
|
||||
readBoolean(message, "is_group") ??
|
||||
readBoolean(chat, "isGroup") ??
|
||||
readBoolean(message, "group") ??
|
||||
(participantsCount > 2 ? true : false);
|
||||
readBoolean(message, "group");
|
||||
const isGroup =
|
||||
typeof groupFromChatGuid === "boolean"
|
||||
? groupFromChatGuid
|
||||
: explicitIsGroup ?? (participantsCount > 2 ? true : false);
|
||||
|
||||
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
||||
const messageId =
|
||||
@@ -360,6 +616,7 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
||||
|
||||
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
||||
if (!normalizedSender) return null;
|
||||
const replyMetadata = extractReplyMetadata(message);
|
||||
|
||||
return {
|
||||
text,
|
||||
@@ -375,6 +632,10 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
||||
fromMe,
|
||||
attachments: extractAttachments(message),
|
||||
balloonBundleId,
|
||||
participants: normalizedParticipants,
|
||||
replyToId: replyMetadata.replyToId,
|
||||
replyToBody: replyMetadata.replyToBody,
|
||||
replyToSender: replyMetadata.replyToSender,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -451,12 +712,16 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
|
||||
? chatsParticipants
|
||||
: [];
|
||||
const participantsCount = participants.length;
|
||||
const isGroup =
|
||||
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
||||
const explicitIsGroup =
|
||||
readBoolean(message, "isGroup") ??
|
||||
readBoolean(message, "is_group") ??
|
||||
readBoolean(chat, "isGroup") ??
|
||||
readBoolean(message, "group") ??
|
||||
(participantsCount > 2 ? true : false);
|
||||
readBoolean(message, "group");
|
||||
const isGroup =
|
||||
typeof groupFromChatGuid === "boolean"
|
||||
? groupFromChatGuid
|
||||
: explicitIsGroup ?? (participantsCount > 2 ? true : false);
|
||||
|
||||
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
||||
const timestampRaw =
|
||||
@@ -637,6 +902,8 @@ async function processMessage(
|
||||
): Promise<void> {
|
||||
const { account, config, runtime, core, statusSink } = target;
|
||||
if (message.fromMe) return;
|
||||
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
||||
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
||||
|
||||
const text = message.text.trim();
|
||||
const attachments = message.attachments ?? [];
|
||||
@@ -648,7 +915,7 @@ async function processMessage(
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`msg sender=${message.senderId} group=${message.isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
|
||||
`msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
|
||||
);
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
@@ -667,15 +934,35 @@ async function processMessage(
|
||||
]
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const groupAllowEntry = formatGroupAllowlistEntry({
|
||||
chatGuid: message.chatGuid,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
});
|
||||
const groupName = message.chatName?.trim() || undefined;
|
||||
|
||||
if (message.isGroup) {
|
||||
if (isGroup) {
|
||||
if (groupPolicy === "disabled") {
|
||||
logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
|
||||
logGroupAllowlistHint({
|
||||
runtime,
|
||||
reason: "groupPolicy=disabled",
|
||||
entry: groupAllowEntry,
|
||||
chatName: groupName,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (effectiveGroupAllowFrom.length === 0) {
|
||||
logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
|
||||
logGroupAllowlistHint({
|
||||
runtime,
|
||||
reason: "groupPolicy=allowlist (empty allowlist)",
|
||||
entry: groupAllowEntry,
|
||||
chatName: groupName,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
@@ -696,6 +983,13 @@ async function processMessage(
|
||||
runtime,
|
||||
`drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
|
||||
);
|
||||
logGroupAllowlistHint({
|
||||
runtime,
|
||||
reason: "groupPolicy=allowlist (not allowlisted)",
|
||||
entry: groupAllowEntry,
|
||||
chatName: groupName,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -767,7 +1061,7 @@ async function processMessage(
|
||||
const chatId = message.chatId ?? undefined;
|
||||
const chatGuid = message.chatGuid ?? undefined;
|
||||
const chatIdentifier = message.chatIdentifier ?? undefined;
|
||||
const peerId = message.isGroup
|
||||
const peerId = isGroup
|
||||
? chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")
|
||||
: message.senderId;
|
||||
|
||||
@@ -776,11 +1070,84 @@ async function processMessage(
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: message.isGroup ? "group" : "dm",
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: peerId,
|
||||
},
|
||||
});
|
||||
|
||||
// Mention gating for group chats (parity with iMessage/WhatsApp)
|
||||
const messageText = text;
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
|
||||
const wasMentioned = isGroup
|
||||
? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes)
|
||||
: true;
|
||||
const canDetectMention = mentionRegexes.length > 0;
|
||||
const requireMention = core.channel.groups.resolveRequireMention({
|
||||
cfg: config,
|
||||
channel: "bluebubbles",
|
||||
groupId: peerId,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
// Command gating (parity with iMessage/WhatsApp)
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
|
||||
const ownerAllowedForCommands =
|
||||
effectiveAllowFrom.length > 0
|
||||
? isAllowedBlueBubblesSender({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
sender: message.senderId,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatGuid: message.chatGuid ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
})
|
||||
: false;
|
||||
const groupAllowedForCommands =
|
||||
effectiveGroupAllowFrom.length > 0
|
||||
? isAllowedBlueBubblesSender({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
sender: message.senderId,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatGuid: message.chatGuid ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
})
|
||||
: false;
|
||||
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
|
||||
const commandAuthorized = isGroup
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
||||
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
||||
],
|
||||
})
|
||||
: dmAuthorized;
|
||||
|
||||
// Block control commands from unauthorized senders in groups
|
||||
if (isGroup && hasControlCmd && !commandAuthorized) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`bluebubbles: drop control command from unauthorized sender ${message.senderId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow control commands to bypass mention gating when authorized (parity with iMessage)
|
||||
const shouldBypassMention =
|
||||
isGroup &&
|
||||
requireMention &&
|
||||
!wasMentioned &&
|
||||
commandAuthorized &&
|
||||
hasControlCmd;
|
||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
|
||||
|
||||
// Skip group messages that require mention but weren't mentioned
|
||||
if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
|
||||
logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = account.config.serverUrl?.trim();
|
||||
const password = account.config.password?.trim();
|
||||
const maxBytes =
|
||||
@@ -833,9 +1200,18 @@ async function processMessage(
|
||||
}
|
||||
}
|
||||
const rawBody = text.trim() || placeholder;
|
||||
const fromLabel = message.isGroup
|
||||
const replyContext = formatReplyContext(message);
|
||||
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
|
||||
const fromLabel = isGroup
|
||||
? `group:${peerId}`
|
||||
: message.senderName || `user:${message.senderId}`;
|
||||
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
||||
const groupMembers = isGroup
|
||||
? formatGroupMembers({
|
||||
participants: message.participants,
|
||||
fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined,
|
||||
})
|
||||
: undefined;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
@@ -850,12 +1226,12 @@ async function processMessage(
|
||||
timestamp: message.timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
body: baseBody,
|
||||
});
|
||||
let chatGuidForActions = chatGuid;
|
||||
if (!chatGuidForActions && baseUrl && password) {
|
||||
const target =
|
||||
message.isGroup && (chatId || chatIdentifier)
|
||||
const target =
|
||||
isGroup && (chatId || chatIdentifier)
|
||||
? chatId
|
||||
? { kind: "chat_id", chatId }
|
||||
: { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" }
|
||||
@@ -870,7 +1246,51 @@ async function processMessage(
|
||||
}
|
||||
}
|
||||
|
||||
if (chatGuidForActions && baseUrl && password) {
|
||||
const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions";
|
||||
const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false;
|
||||
const ackReactionValue = resolveBlueBubblesAckReaction({
|
||||
cfg: config,
|
||||
agentId: route.agentId,
|
||||
core,
|
||||
runtime,
|
||||
});
|
||||
const shouldAckReaction = () => {
|
||||
if (!ackReactionValue) return false;
|
||||
if (ackReactionScope === "all") return true;
|
||||
if (ackReactionScope === "direct") return !isGroup;
|
||||
if (ackReactionScope === "group-all") return isGroup;
|
||||
if (ackReactionScope === "group-mentions") {
|
||||
if (!isGroup) return false;
|
||||
if (!requireMention) return false;
|
||||
if (!canDetectMention) return false;
|
||||
return effectiveWasMentioned;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const ackMessageId = message.messageId?.trim() || "";
|
||||
const ackReactionPromise =
|
||||
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
|
||||
? sendBlueBubblesReaction({
|
||||
chatGuid: chatGuidForActions,
|
||||
messageGuid: ackMessageId,
|
||||
emoji: ackReactionValue,
|
||||
opts: { cfg: config, accountId: account.accountId },
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
|
||||
);
|
||||
return false;
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
// Respect sendReadReceipts config (parity with WhatsApp)
|
||||
const sendReadReceipts = account.config.sendReadReceipts !== false;
|
||||
if (chatGuidForActions && baseUrl && password && sendReadReceipts) {
|
||||
try {
|
||||
await markBlueBubblesChatRead(chatGuidForActions, {
|
||||
cfg: config,
|
||||
@@ -880,11 +1300,13 @@ async function processMessage(
|
||||
} catch (err) {
|
||||
runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`);
|
||||
}
|
||||
} else if (!sendReadReceipts) {
|
||||
logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)");
|
||||
} else {
|
||||
logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
|
||||
}
|
||||
|
||||
const outboundTarget = message.isGroup
|
||||
const outboundTarget = isGroup
|
||||
? formatBlueBubblesChatTarget({
|
||||
chatId,
|
||||
chatGuid: chatGuidForActions ?? chatGuid,
|
||||
@@ -894,6 +1316,15 @@ async function processMessage(
|
||||
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
||||
: message.senderId;
|
||||
|
||||
const maybeEnqueueOutboundMessageId = (messageId?: string) => {
|
||||
const trimmed = messageId?.trim();
|
||||
if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
|
||||
core.system.enqueueSystemEvent(`BlueBubbles sent message id: ${trimmed}`, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
|
||||
});
|
||||
};
|
||||
|
||||
const ctxPayload = {
|
||||
Body: body,
|
||||
BodyForAgent: body,
|
||||
@@ -906,12 +1337,17 @@ async function processMessage(
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaType: mediaTypes[0],
|
||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
From: message.isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
|
||||
From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
|
||||
To: `bluebubbles:${outboundTarget}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: message.isGroup ? "group" : "direct",
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
ReplyToId: message.replyToId,
|
||||
ReplyToBody: message.replyToBody,
|
||||
ReplyToSender: message.replyToSender,
|
||||
GroupSubject: groupSubject,
|
||||
GroupMembers: groupMembers,
|
||||
SenderName: message.senderName || undefined,
|
||||
SenderId: message.senderId,
|
||||
Provider: "bluebubbles",
|
||||
@@ -920,26 +1356,42 @@ async function processMessage(
|
||||
Timestamp: message.timestamp,
|
||||
OriginatingChannel: "bluebubbles",
|
||||
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
||||
WasMentioned: effectiveWasMentioned,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
};
|
||||
|
||||
if (chatGuidForActions && baseUrl && password) {
|
||||
logVerbose(core, runtime, `typing start (pre-dispatch) chatGuid=${chatGuidForActions}`);
|
||||
try {
|
||||
await sendBlueBubblesTyping(chatGuidForActions, true, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
let sentMessage = false;
|
||||
try {
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
if (mediaList.length > 0) {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? payload.text : undefined;
|
||||
first = false;
|
||||
const result = await sendBlueBubblesMedia({
|
||||
cfg: config,
|
||||
to: outboundTarget,
|
||||
mediaUrl,
|
||||
caption: caption ?? undefined,
|
||||
replyToId: payload.replyToId ?? null,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
maybeEnqueueOutboundMessageId(result.messageId);
|
||||
sentMessage = true;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const textLimit =
|
||||
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
||||
? account.config.textChunkLimit
|
||||
@@ -948,10 +1400,15 @@ async function processMessage(
|
||||
if (!chunks.length && payload.text) chunks.push(payload.text);
|
||||
if (!chunks.length) return;
|
||||
for (const chunk of chunks) {
|
||||
await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||
const replyToMessageGuid =
|
||||
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
||||
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||
});
|
||||
maybeEnqueueOutboundMessageId(result.messageId);
|
||||
sentMessage = true;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
}
|
||||
},
|
||||
@@ -969,31 +1426,48 @@ async function processMessage(
|
||||
}
|
||||
},
|
||||
onIdle: () => {
|
||||
if (!chatGuidForActions) return;
|
||||
if (!baseUrl || !password) return;
|
||||
logVerbose(core, runtime, `typing stop chatGuid=${chatGuidForActions}`);
|
||||
void sendBlueBubblesTyping(chatGuidForActions, false, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
}).catch((err) => {
|
||||
runtime.error?.(`[bluebubbles] typing stop failed: ${String(err)}`);
|
||||
});
|
||||
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (chatGuidForActions && baseUrl && password) {
|
||||
logVerbose(core, runtime, `typing stop (finalize) chatGuid=${chatGuidForActions}`);
|
||||
void sendBlueBubblesTyping(chatGuidForActions, false, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
}).catch((err) => {
|
||||
runtime.error?.(`[bluebubbles] typing stop failed: ${String(err)}`);
|
||||
if (
|
||||
removeAckAfterReply &&
|
||||
sentMessage &&
|
||||
ackReactionPromise &&
|
||||
ackReactionValue &&
|
||||
chatGuidForActions &&
|
||||
ackMessageId
|
||||
) {
|
||||
void ackReactionPromise.then((didAck) => {
|
||||
if (!didAck) return;
|
||||
sendBlueBubblesReaction({
|
||||
chatGuid: chatGuidForActions,
|
||||
messageGuid: ackMessageId,
|
||||
emoji: ackReactionValue,
|
||||
remove: true,
|
||||
opts: { cfg: config, accountId: account.accountId },
|
||||
}).catch((err) => {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (chatGuidForActions && baseUrl && password && !sentMessage) {
|
||||
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1077,11 +1551,22 @@ async function processReaction(
|
||||
|
||||
export async function monitorBlueBubblesProvider(
|
||||
options: BlueBubblesMonitorOptions,
|
||||
): Promise<{ stop: () => void }> {
|
||||
): Promise<void> {
|
||||
const { account, config, runtime, abortSignal, statusSink } = options;
|
||||
const core = getBlueBubblesRuntime();
|
||||
const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH;
|
||||
|
||||
// Fetch and cache server info (for macOS version detection in action gating)
|
||||
const serverInfo = await fetchBlueBubblesServerInfo({
|
||||
baseUrl: account.baseUrl,
|
||||
password: account.config.password,
|
||||
accountId: account.accountId,
|
||||
timeoutMs: 5000,
|
||||
}).catch(() => null);
|
||||
if (serverInfo?.os_version) {
|
||||
runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);
|
||||
}
|
||||
|
||||
const unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
@@ -1091,21 +1576,22 @@ export async function monitorBlueBubblesProvider(
|
||||
statusSink,
|
||||
});
|
||||
|
||||
const stop = () => {
|
||||
unregister();
|
||||
};
|
||||
return await new Promise((resolve) => {
|
||||
const stop = () => {
|
||||
unregister();
|
||||
resolve();
|
||||
};
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
stop();
|
||||
} else {
|
||||
abortSignal?.addEventListener("abort", stop, { once: true });
|
||||
}
|
||||
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
|
||||
);
|
||||
|
||||
return { stop };
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
|
||||
|
||||
334
extensions/bluebubbles/src/onboarding.ts
Normal file
334
extensions/bluebubbles/src/onboarding.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import type { ClawdbotConfig, DmPolicy, WizardPrompter } from "clawdbot/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import { addWildcardAllowFrom, promptAccountId } from "../../../src/channels/plugins/onboarding/helpers.js";
|
||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||
import {
|
||||
listBlueBubblesAccountIds,
|
||||
resolveBlueBubblesAccount,
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { normalizeBlueBubblesServerUrl } from "./types.js";
|
||||
import { parseBlueBubblesAllowTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
|
||||
const channel = "bluebubbles" as const;
|
||||
|
||||
function setBlueBubblesDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
|
||||
const allowFrom =
|
||||
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.bluebubbles?.allowFrom) : undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
bluebubbles: {
|
||||
...cfg.channels?.bluebubbles,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setBlueBubblesAllowFrom(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
allowFrom: string[],
|
||||
): ClawdbotConfig {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
bluebubbles: {
|
||||
...cfg.channels?.bluebubbles,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
bluebubbles: {
|
||||
...cfg.channels?.bluebubbles,
|
||||
accounts: {
|
||||
...cfg.channels?.bluebubbles?.accounts,
|
||||
[accountId]: {
|
||||
...cfg.channels?.bluebubbles?.accounts?.[accountId],
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseBlueBubblesAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function promptBlueBubblesAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const accountId =
|
||||
params.accountId && normalizeAccountId(params.accountId)
|
||||
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
|
||||
: resolveDefaultBlueBubblesAccountId(params.cfg);
|
||||
const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId });
|
||||
const existing = resolved.config.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist BlueBubbles DMs by handle or chat target.",
|
||||
"Examples:",
|
||||
"- +15555550123",
|
||||
"- user@example.com",
|
||||
"- chat_id:123",
|
||||
"- chat_guid:iMessage;-;+15555550123",
|
||||
"Multiple entries: comma- or newline-separated.",
|
||||
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
||||
].join("\n"),
|
||||
"BlueBubbles allowlist",
|
||||
);
|
||||
const entry = await params.prompter.text({
|
||||
message: "BlueBubbles allowFrom (handle or chat_id)",
|
||||
placeholder: "+15555550123, user@example.com, chat_id:123",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
const parts = parseBlueBubblesAllowFromInput(raw);
|
||||
for (const part of parts) {
|
||||
if (part === "*") continue;
|
||||
const parsed = parseBlueBubblesAllowTarget(part);
|
||||
if (parsed.kind === "handle" && !parsed.handle) {
|
||||
return `Invalid entry: ${part}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const parts = parseBlueBubblesAllowFromInput(String(entry));
|
||||
const unique = [...new Set(parts)];
|
||||
return setBlueBubblesAllowFrom(params.cfg, accountId, unique);
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "BlueBubbles",
|
||||
channel,
|
||||
policyKey: "channels.bluebubbles.dmPolicy",
|
||||
allowFromKey: "channels.bluebubbles.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptBlueBubblesAllowFrom,
|
||||
};
|
||||
|
||||
export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listBlueBubblesAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveBlueBubblesAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`BlueBubbles: ${configured ? "configured" : "needs setup"}`],
|
||||
selectionHint: configured ? "configured" : "iMessage via BlueBubbles app",
|
||||
quickstartScore: configured ? 1 : 0,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
||||
const blueBubblesOverride = accountOverrides.bluebubbles?.trim();
|
||||
const defaultAccountId = resolveDefaultBlueBubblesAccountId(cfg);
|
||||
let accountId = blueBubblesOverride
|
||||
? normalizeAccountId(blueBubblesOverride)
|
||||
: defaultAccountId;
|
||||
if (shouldPromptAccountIds && !blueBubblesOverride) {
|
||||
accountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "BlueBubbles",
|
||||
currentId: accountId,
|
||||
listAccountIds: listBlueBubblesAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId });
|
||||
|
||||
// Prompt for server URL
|
||||
let serverUrl = resolvedAccount.config.serverUrl?.trim();
|
||||
if (!serverUrl) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).",
|
||||
"Find this in the BlueBubbles Server app under Connection.",
|
||||
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
||||
].join("\n"),
|
||||
"BlueBubbles server URL",
|
||||
);
|
||||
const entered = await prompter.text({
|
||||
message: "BlueBubbles server URL",
|
||||
placeholder: "http://192.168.1.100:1234",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) return "Required";
|
||||
try {
|
||||
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
||||
new URL(normalized);
|
||||
return undefined;
|
||||
} catch {
|
||||
return "Invalid URL format";
|
||||
}
|
||||
},
|
||||
});
|
||||
serverUrl = String(entered).trim();
|
||||
} else {
|
||||
const keepUrl = await prompter.confirm({
|
||||
message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keepUrl) {
|
||||
const entered = await prompter.text({
|
||||
message: "BlueBubbles server URL",
|
||||
placeholder: "http://192.168.1.100:1234",
|
||||
initialValue: serverUrl,
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) return "Required";
|
||||
try {
|
||||
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
||||
new URL(normalized);
|
||||
return undefined;
|
||||
} catch {
|
||||
return "Invalid URL format";
|
||||
}
|
||||
},
|
||||
});
|
||||
serverUrl = String(entered).trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt for password
|
||||
let password = resolvedAccount.config.password?.trim();
|
||||
if (!password) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Enter the BlueBubbles server password.",
|
||||
"Find this in the BlueBubbles Server app under Settings.",
|
||||
].join("\n"),
|
||||
"BlueBubbles password",
|
||||
);
|
||||
const entered = await prompter.text({
|
||||
message: "BlueBubbles password",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
password = String(entered).trim();
|
||||
} else {
|
||||
const keepPassword = await prompter.confirm({
|
||||
message: "BlueBubbles password already set. Keep it?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keepPassword) {
|
||||
const entered = await prompter.text({
|
||||
message: "BlueBubbles password",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
password = String(entered).trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt for webhook path (optional)
|
||||
const existingWebhookPath = resolvedAccount.config.webhookPath?.trim();
|
||||
const wantsWebhook = await prompter.confirm({
|
||||
message: "Configure a custom webhook path? (default: /bluebubbles-webhook)",
|
||||
initialValue: Boolean(existingWebhookPath && existingWebhookPath !== "/bluebubbles-webhook"),
|
||||
});
|
||||
let webhookPath = "/bluebubbles-webhook";
|
||||
if (wantsWebhook) {
|
||||
const entered = await prompter.text({
|
||||
message: "Webhook path",
|
||||
placeholder: "/bluebubbles-webhook",
|
||||
initialValue: existingWebhookPath || "/bluebubbles-webhook",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) return "Required";
|
||||
if (!trimmed.startsWith("/")) return "Path must start with /";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
webhookPath = String(entered).trim();
|
||||
}
|
||||
|
||||
// Apply config
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
bluebubbles: {
|
||||
...next.channels?.bluebubbles,
|
||||
enabled: true,
|
||||
serverUrl,
|
||||
password,
|
||||
webhookPath,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
bluebubbles: {
|
||||
...next.channels?.bluebubbles,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.bluebubbles?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.bluebubbles?.accounts?.[accountId],
|
||||
enabled: next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ?? true,
|
||||
serverUrl,
|
||||
password,
|
||||
webhookPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"Configure the webhook URL in BlueBubbles Server:",
|
||||
"1. Open BlueBubbles Server → Settings → Webhooks",
|
||||
"2. Add your Clawdbot gateway URL + webhook path",
|
||||
" Example: https://your-gateway-host:3000/bluebubbles-webhook",
|
||||
"3. Enable the webhook and save",
|
||||
"",
|
||||
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
||||
].join("\n"),
|
||||
"BlueBubbles next steps",
|
||||
);
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
bluebubbles: { ...cfg.channels?.bluebubbles, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -6,6 +6,97 @@ export type BlueBubblesProbe = {
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export type BlueBubblesServerInfo = {
|
||||
os_version?: string;
|
||||
server_version?: string;
|
||||
private_api?: boolean;
|
||||
helper_connected?: boolean;
|
||||
proxy_service?: string;
|
||||
detected_icloud?: string;
|
||||
computer_id?: string;
|
||||
};
|
||||
|
||||
/** Cache server info by account ID to avoid repeated API calls */
|
||||
const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function buildCacheKey(accountId?: string): string {
|
||||
return accountId?.trim() || "default";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch server info from BlueBubbles API and cache it.
|
||||
* Returns cached result if available and not expired.
|
||||
*/
|
||||
export async function fetchBlueBubblesServerInfo(params: {
|
||||
baseUrl?: string | null;
|
||||
password?: string | null;
|
||||
accountId?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BlueBubblesServerInfo | null> {
|
||||
const baseUrl = params.baseUrl?.trim();
|
||||
const password = params.password?.trim();
|
||||
if (!baseUrl || !password) return null;
|
||||
|
||||
const cacheKey = buildCacheKey(params.accountId);
|
||||
const cached = serverInfoCache.get(cacheKey);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.info;
|
||||
}
|
||||
|
||||
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
|
||||
try {
|
||||
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000);
|
||||
if (!res.ok) return null;
|
||||
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
const data = payload?.data as BlueBubblesServerInfo | undefined;
|
||||
if (data) {
|
||||
serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
|
||||
}
|
||||
return data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached server info synchronously (for use in listActions).
|
||||
* Returns null if not cached or expired.
|
||||
*/
|
||||
export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {
|
||||
const cacheKey = buildCacheKey(accountId);
|
||||
const cached = serverInfoCache.get(cacheKey);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.info;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
|
||||
*/
|
||||
export function parseMacOSMajorVersion(version?: string | null): number | null {
|
||||
if (!version) return null;
|
||||
const match = /^(\d+)/.exec(version.trim());
|
||||
return match ? Number.parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the cached server info indicates macOS 26 or higher.
|
||||
* Returns false if no cached info is available (fail open for action listing).
|
||||
*/
|
||||
export function isMacOS26OrHigher(accountId?: string): boolean {
|
||||
const info = getCachedBlueBubblesServerInfo(accountId);
|
||||
if (!info?.os_version) return false;
|
||||
const major = parseMacOSMajorVersion(info.os_version);
|
||||
return major !== null && major >= 26;
|
||||
}
|
||||
|
||||
/** Clear the server info cache (for testing) */
|
||||
export function clearServerInfoCache(): void {
|
||||
serverInfoCache.clear();
|
||||
}
|
||||
|
||||
export async function probeBlueBubbles(params: {
|
||||
baseUrl?: string | null;
|
||||
password?: string | null;
|
||||
|
||||
393
extensions/bluebubbles/src/reactions.test.ts
Normal file
393
extensions/bluebubbles/src/reactions.test.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
const config = cfg?.channels?.bluebubbles ?? {};
|
||||
return {
|
||||
accountId: accountId ?? "default",
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(config.serverUrl && config.password),
|
||||
config,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
describe("reactions", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("sendBlueBubblesReaction", () => {
|
||||
it("throws when chatGuid is empty", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesReaction({
|
||||
chatGuid: "",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "love",
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("chatGuid");
|
||||
});
|
||||
|
||||
it("throws when messageGuid is empty", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "",
|
||||
emoji: "love",
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("messageGuid");
|
||||
});
|
||||
|
||||
it("throws when emoji is empty", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "",
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("emoji or name");
|
||||
});
|
||||
|
||||
it("throws when serverUrl is missing", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "love",
|
||||
opts: {},
|
||||
}),
|
||||
).rejects.toThrow("serverUrl is required");
|
||||
});
|
||||
|
||||
it("throws when password is missing", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "love",
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("password is required");
|
||||
});
|
||||
|
||||
it("throws for unsupported reaction type", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "unsupported",
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Unsupported BlueBubbles reaction");
|
||||
});
|
||||
|
||||
describe("reaction type normalization", () => {
|
||||
const testCases = [
|
||||
{ input: "love", expected: "love" },
|
||||
{ input: "like", expected: "like" },
|
||||
{ input: "dislike", expected: "dislike" },
|
||||
{ input: "laugh", expected: "laugh" },
|
||||
{ input: "emphasize", expected: "emphasize" },
|
||||
{ input: "question", expected: "question" },
|
||||
{ input: "heart", expected: "love" },
|
||||
{ input: "thumbs_up", expected: "like" },
|
||||
{ input: "thumbs-down", expected: "dislike" },
|
||||
{ input: "thumbs_down", expected: "dislike" },
|
||||
{ input: "haha", expected: "laugh" },
|
||||
{ input: "lol", expected: "laugh" },
|
||||
{ input: "emphasis", expected: "emphasize" },
|
||||
{ input: "exclaim", expected: "emphasize" },
|
||||
{ input: "❤️", expected: "love" },
|
||||
{ input: "❤", expected: "love" },
|
||||
{ input: "♥️", expected: "love" },
|
||||
{ input: "😍", expected: "love" },
|
||||
{ input: "👍", expected: "like" },
|
||||
{ input: "👎", expected: "dislike" },
|
||||
{ input: "😂", expected: "laugh" },
|
||||
{ input: "🤣", expected: "laugh" },
|
||||
{ input: "😆", expected: "laugh" },
|
||||
{ input: "‼️", expected: "emphasize" },
|
||||
{ input: "‼", expected: "emphasize" },
|
||||
{ input: "❗", expected: "emphasize" },
|
||||
{ input: "❓", expected: "question" },
|
||||
{ input: "❔", expected: "question" },
|
||||
{ input: "LOVE", expected: "love" },
|
||||
{ input: "Like", expected: "like" },
|
||||
];
|
||||
|
||||
for (const { input, expected } of testCases) {
|
||||
it(`normalizes "${input}" to "${expected}"`, async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: input,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("sends reaction successfully", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
messageGuid: "msg-uuid-123",
|
||||
emoji: "love",
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/message/react"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.chatGuid).toBe("iMessage;-;+15551234567");
|
||||
expect(body.selectedMessageGuid).toBe("msg-uuid-123");
|
||||
expect(body.reaction).toBe("love");
|
||||
expect(body.partIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("includes password in URL query", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "like",
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "my-react-password",
|
||||
},
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("password=my-react-password");
|
||||
});
|
||||
|
||||
it("sends reaction removal with dash prefix", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "love",
|
||||
remove: true,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-love");
|
||||
});
|
||||
|
||||
it("strips leading dash from emoji when remove flag is set", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "-love",
|
||||
remove: true,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-love");
|
||||
});
|
||||
|
||||
it("uses custom partIndex when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "laugh",
|
||||
partIndex: 3,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.partIndex).toBe(3);
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: () => Promise.resolve("Invalid reaction type"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "like",
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("reaction failed (400): Invalid reaction type");
|
||||
});
|
||||
|
||||
it("resolves credentials from config", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "emphasize",
|
||||
opts: {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://react-server:7777",
|
||||
password: "react-pass",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("react-server:7777");
|
||||
expect(calledUrl).toContain("password=react-pass");
|
||||
});
|
||||
|
||||
it("trims chatGuid and messageGuid", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: " chat-with-spaces ",
|
||||
messageGuid: " msg-with-spaces ",
|
||||
emoji: "question",
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.chatGuid).toBe("chat-with-spaces");
|
||||
expect(body.selectedMessageGuid).toBe("msg-with-spaces");
|
||||
});
|
||||
|
||||
describe("reaction removal aliases", () => {
|
||||
it("handles emoji-based removal", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "👍",
|
||||
remove: true,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-like");
|
||||
});
|
||||
|
||||
it("handles text alias removal", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "haha",
|
||||
remove: true,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-laugh");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ function resolveAccount(params: BlueBubblesReactionOpts) {
|
||||
return { baseUrl, password };
|
||||
}
|
||||
|
||||
function normalizeReactionInput(emoji: string, remove?: boolean): string {
|
||||
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
|
||||
const trimmed = emoji.trim();
|
||||
if (!trimmed) throw new Error("BlueBubbles reaction requires an emoji or name.");
|
||||
let raw = trimmed.toLowerCase();
|
||||
@@ -85,7 +85,7 @@ export async function sendBlueBubblesReaction(params: {
|
||||
const messageGuid = params.messageGuid.trim();
|
||||
if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid.");
|
||||
if (!messageGuid) throw new Error("BlueBubbles reaction requires messageGuid.");
|
||||
const reaction = normalizeReactionInput(params.emoji, params.remove);
|
||||
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
|
||||
const { baseUrl, password } = resolveAccount(params.opts ?? {});
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
|
||||
690
extensions/bluebubbles/src/send.test.ts
Normal file
690
extensions/bluebubbles/src/send.test.ts
Normal file
@@ -0,0 +1,690 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
|
||||
import type { BlueBubblesSendTarget } from "./types.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
const config = cfg?.channels?.bluebubbles ?? {};
|
||||
return {
|
||||
accountId: accountId ?? "default",
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(config.serverUrl && config.password),
|
||||
config,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
describe("send", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("resolveChatGuidForTarget", () => {
|
||||
it("returns chatGuid directly for chat_guid target", async () => {
|
||||
const target: BlueBubblesSendTarget = {
|
||||
kind: "chat_guid",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
};
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
expect(result).toBe("iMessage;-;+15551234567");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queries chats to resolve chat_id target", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{ id: 123, guid: "iMessage;-;chat123", participants: [] },
|
||||
{ id: 456, guid: "iMessage;-;chat456", participants: [] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 456 };
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
|
||||
expect(result).toBe("iMessage;-;chat456");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/chat/query"),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("queries chats to resolve chat_identifier target", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
identifier: "chat123@group.imessage",
|
||||
guid: "iMessage;-;chat123",
|
||||
participants: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = {
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat123@group.imessage",
|
||||
};
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
|
||||
expect(result).toBe("iMessage;-;chat123");
|
||||
});
|
||||
|
||||
it("resolves handle target by matching participant", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15559999999",
|
||||
participants: [{ address: "+15559999999" }],
|
||||
},
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = {
|
||||
kind: "handle",
|
||||
address: "+15551234567",
|
||||
service: "imessage",
|
||||
};
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
|
||||
expect(result).toBe("iMessage;-;+15551234567");
|
||||
});
|
||||
|
||||
it("prefers direct chat guid when handle also appears in a group chat", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;+;group-123",
|
||||
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
|
||||
},
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = {
|
||||
kind: "handle",
|
||||
address: "+15551234567",
|
||||
service: "imessage",
|
||||
};
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
|
||||
expect(result).toBe("iMessage;-;+15551234567");
|
||||
});
|
||||
|
||||
it("returns null when chat not found", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 999 };
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("handles API error gracefully", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 123 };
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("paginates through chats to find match", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: Array(500)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
id: i,
|
||||
guid: `chat-${i}`,
|
||||
participants: [],
|
||||
})),
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [{ id: 555, guid: "found-chat", participants: [] }],
|
||||
}),
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 555 };
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
|
||||
expect(result).toBe("found-chat");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("normalizes handle addresses for matching", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;test@example.com",
|
||||
participants: [{ address: "Test@Example.COM" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = {
|
||||
kind: "handle",
|
||||
address: "test@example.com",
|
||||
service: "auto",
|
||||
};
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
|
||||
expect(result).toBe("iMessage;-;test@example.com");
|
||||
});
|
||||
|
||||
it("extracts guid from various response formats", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
chatGuid: "format1-guid",
|
||||
id: 100,
|
||||
participants: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 100 };
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
|
||||
expect(result).toBe("format1-guid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessageBlueBubbles", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it("throws when text is empty", async () => {
|
||||
await expect(
|
||||
sendMessageBlueBubbles("+15551234567", "", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("requires text");
|
||||
});
|
||||
|
||||
it("throws when text is whitespace only", async () => {
|
||||
await expect(
|
||||
sendMessageBlueBubbles("+15551234567", " ", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("requires text");
|
||||
});
|
||||
|
||||
it("throws when serverUrl is missing", async () => {
|
||||
await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow(
|
||||
"serverUrl is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when password is missing", async () => {
|
||||
await expect(
|
||||
sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
}),
|
||||
).rejects.toThrow("password is required");
|
||||
});
|
||||
|
||||
it("throws when chatGuid cannot be resolved", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageBlueBubbles("+15559999999", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("chatGuid not found");
|
||||
});
|
||||
|
||||
it("sends message successfully", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "msg-uuid-123" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("msg-uuid-123");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
const sendCall = mockFetch.mock.calls[1];
|
||||
expect(sendCall[0]).toContain("/api/v1/message/text");
|
||||
const body = JSON.parse(sendCall[1].body);
|
||||
expect(body.chatGuid).toBe("iMessage;-;+15551234567");
|
||||
expect(body.message).toBe("Hello world!");
|
||||
expect(body.method).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses private-api when reply metadata is present", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "msg-uuid-124" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
replyToMessageGuid: "reply-guid-123",
|
||||
replyToPartIndex: 1,
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("msg-uuid-124");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
const sendCall = mockFetch.mock.calls[1];
|
||||
const body = JSON.parse(sendCall[1].body);
|
||||
expect(body.method).toBe("private-api");
|
||||
expect(body.selectedMessageGuid).toBe("reply-guid-123");
|
||||
expect(body.partIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("normalizes effect names and uses private-api for effects", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "msg-uuid-125" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
effectId: "invisible ink",
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("msg-uuid-125");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
const sendCall = mockFetch.mock.calls[1];
|
||||
const body = JSON.parse(sendCall[1].body);
|
||||
expect(body.method).toBe("private-api");
|
||||
expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink");
|
||||
});
|
||||
|
||||
it("sends message with chat_guid target directly", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { messageId: "direct-msg-123" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles(
|
||||
"chat_guid:iMessage;-;direct-chat",
|
||||
"Direct message",
|
||||
{
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.messageId).toBe("direct-msg-123");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles send failure", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Internal server error"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("send failed (500)");
|
||||
});
|
||||
|
||||
it("handles empty response body", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("ok");
|
||||
});
|
||||
|
||||
it("handles invalid JSON response body", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve("not valid json"),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("ok");
|
||||
});
|
||||
|
||||
it("extracts messageId from various response formats", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
id: "numeric-id-456",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("numeric-id-456");
|
||||
});
|
||||
|
||||
it("extracts messageGuid from response payload", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { messageGuid: "msg-guid-789" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("msg-guid-789");
|
||||
});
|
||||
|
||||
it("resolves credentials from config", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://config-server:5678",
|
||||
password: "config-pass",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("msg-123");
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("config-server:5678");
|
||||
});
|
||||
|
||||
it("includes tempGuid in request payload", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
|
||||
});
|
||||
|
||||
await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
const sendCall = mockFetch.mock.calls[1];
|
||||
const body = JSON.parse(sendCall[1].body);
|
||||
expect(body.tempGuid).toBeDefined();
|
||||
expect(typeof body.tempGuid).toBe("string");
|
||||
expect(body.tempGuid.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import {
|
||||
extractHandleFromChatGuid,
|
||||
normalizeBlueBubblesHandle,
|
||||
parseBlueBubblesTarget,
|
||||
} from "./targets.js";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
blueBubblesFetchWithTimeout,
|
||||
@@ -15,12 +19,52 @@ export type BlueBubblesSendOpts = {
|
||||
accountId?: string;
|
||||
timeoutMs?: number;
|
||||
cfg?: ClawdbotConfig;
|
||||
/** Message GUID to reply to (reply threading) */
|
||||
replyToMessageGuid?: string;
|
||||
/** Part index for reply (default: 0) */
|
||||
replyToPartIndex?: number;
|
||||
/** Effect ID or short name for message effects (e.g., "slam", "balloons") */
|
||||
effectId?: string;
|
||||
};
|
||||
|
||||
export type BlueBubblesSendResult = {
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
/** Maps short effect names to full Apple effect IDs */
|
||||
const EFFECT_MAP: Record<string, string> = {
|
||||
// Bubble effects
|
||||
slam: "com.apple.MobileSMS.expressivesend.impact",
|
||||
loud: "com.apple.MobileSMS.expressivesend.loud",
|
||||
gentle: "com.apple.MobileSMS.expressivesend.gentle",
|
||||
invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
|
||||
"invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink",
|
||||
"invisible ink": "com.apple.MobileSMS.expressivesend.invisibleink",
|
||||
invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink",
|
||||
// Screen effects
|
||||
echo: "com.apple.messages.effect.CKEchoEffect",
|
||||
spotlight: "com.apple.messages.effect.CKSpotlightEffect",
|
||||
balloons: "com.apple.messages.effect.CKHappyBirthdayEffect",
|
||||
confetti: "com.apple.messages.effect.CKConfettiEffect",
|
||||
love: "com.apple.messages.effect.CKHeartEffect",
|
||||
heart: "com.apple.messages.effect.CKHeartEffect",
|
||||
hearts: "com.apple.messages.effect.CKHeartEffect",
|
||||
lasers: "com.apple.messages.effect.CKLasersEffect",
|
||||
fireworks: "com.apple.messages.effect.CKFireworksEffect",
|
||||
celebration: "com.apple.messages.effect.CKSparklesEffect",
|
||||
};
|
||||
|
||||
function resolveEffectId(raw?: string): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (EFFECT_MAP[trimmed]) return EFFECT_MAP[trimmed];
|
||||
const normalized = trimmed.replace(/[\s_]+/g, "-");
|
||||
if (EFFECT_MAP[normalized]) return EFFECT_MAP[normalized];
|
||||
const compact = trimmed.replace(/[\s_-]+/g, "");
|
||||
if (EFFECT_MAP[compact]) return EFFECT_MAP[compact];
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
||||
const parsed = parseBlueBubblesTarget(raw);
|
||||
if (parsed.kind === "handle") {
|
||||
@@ -42,12 +86,18 @@ function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
||||
function extractMessageId(payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") return "unknown";
|
||||
const record = payload as Record<string, unknown>;
|
||||
const data = record.data && typeof record.data === "object" ? (record.data as Record<string, unknown>) : null;
|
||||
const data =
|
||||
record.data && typeof record.data === "object" ? (record.data as Record<string, unknown>) : null;
|
||||
const candidates = [
|
||||
record.messageId,
|
||||
record.messageGuid,
|
||||
record.message_guid,
|
||||
record.guid,
|
||||
record.id,
|
||||
data?.messageId,
|
||||
data?.messageGuid,
|
||||
data?.message_guid,
|
||||
data?.message_id,
|
||||
data?.guid,
|
||||
data?.id,
|
||||
];
|
||||
@@ -154,6 +204,7 @@ export async function resolveChatGuidForTarget(params: {
|
||||
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
|
||||
|
||||
const limit = 500;
|
||||
let participantMatch: string | null = null;
|
||||
for (let offset = 0; offset < 5000; offset += limit) {
|
||||
const chats = await queryChats({
|
||||
baseUrl: params.baseUrl,
|
||||
@@ -184,16 +235,23 @@ export async function resolveChatGuidForTarget(params: {
|
||||
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
|
||||
}
|
||||
if (normalizedHandle) {
|
||||
const participants = extractParticipantAddresses(chat).map((entry) =>
|
||||
normalizeBlueBubblesHandle(entry),
|
||||
);
|
||||
if (participants.includes(normalizedHandle)) {
|
||||
return extractChatGuid(chat);
|
||||
const guid = extractChatGuid(chat);
|
||||
const directHandle = guid ? extractHandleFromChatGuid(guid) : null;
|
||||
if (directHandle && directHandle === normalizedHandle) {
|
||||
return guid;
|
||||
}
|
||||
if (!participantMatch && guid) {
|
||||
const participants = extractParticipantAddresses(chat).map((entry) =>
|
||||
normalizeBlueBubblesHandle(entry),
|
||||
);
|
||||
if (participants.includes(normalizedHandle)) {
|
||||
participantMatch = guid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return participantMatch;
|
||||
}
|
||||
|
||||
export async function sendMessageBlueBubbles(
|
||||
@@ -227,12 +285,27 @@ export async function sendMessageBlueBubbles(
|
||||
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
||||
);
|
||||
}
|
||||
const effectId = resolveEffectId(opts.effectId);
|
||||
const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId);
|
||||
const payload: Record<string, unknown> = {
|
||||
chatGuid,
|
||||
tempGuid: crypto.randomUUID(),
|
||||
message: trimmedText,
|
||||
method: "apple-script",
|
||||
};
|
||||
if (needsPrivateApi) {
|
||||
payload.method = "private-api";
|
||||
}
|
||||
|
||||
// Add reply threading support
|
||||
if (opts.replyToMessageGuid) {
|
||||
payload.selectedMessageGuid = opts.replyToMessageGuid;
|
||||
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
|
||||
}
|
||||
|
||||
// Add message effects support
|
||||
if (effectId) {
|
||||
payload.effectId = effectId;
|
||||
}
|
||||
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
|
||||
184
extensions/bluebubbles/src/targets.test.ts
Normal file
184
extensions/bluebubbles/src/targets.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
looksLikeBlueBubblesTargetId,
|
||||
normalizeBlueBubblesMessagingTarget,
|
||||
parseBlueBubblesTarget,
|
||||
parseBlueBubblesAllowTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
describe("normalizeBlueBubblesMessagingTarget", () => {
|
||||
it("normalizes chat_guid targets", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
|
||||
});
|
||||
|
||||
it("normalizes group numeric targets to chat_id", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
|
||||
});
|
||||
|
||||
it("strips provider prefix and normalizes handles", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
|
||||
"imessage:user@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts handle from DM chat_guid for cross-context matching", () => {
|
||||
// DM format: service;-;handle
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
|
||||
"+19257864429",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
|
||||
"+15551234567",
|
||||
);
|
||||
// Email handles
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
|
||||
"user@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves group chat_guid format", () => {
|
||||
// Group format: service;+;groupId
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
|
||||
"chat_guid:iMessage;+;chat123456789",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes raw chat_guid values", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
|
||||
"chat_guid:iMessage;+;chat660250192681427962",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
|
||||
});
|
||||
|
||||
it("normalizes chat<digits> pattern to chat_identifier format", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
|
||||
"chat_identifier:chat660250192681427962",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
|
||||
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
|
||||
});
|
||||
|
||||
it("normalizes UUID/hex chat identifiers", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
|
||||
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
|
||||
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeBlueBubblesTargetId", () => {
|
||||
it("accepts chat targets", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts email handles", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts phone numbers with punctuation", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts raw chat_guid values", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts chat<digits> pattern as chat_id", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts UUID/hex chat identifiers", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects display names", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlueBubblesTarget", () => {
|
||||
it("parses chat<digits> pattern as chat_identifier", () => {
|
||||
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat660250192681427962",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("chat123")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat123",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "Chat456789",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses UUID/hex chat identifiers as chat_identifier", () => {
|
||||
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit chat_id: prefix", () => {
|
||||
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
|
||||
});
|
||||
|
||||
it("parses phone numbers as handles", () => {
|
||||
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
|
||||
kind: "handle",
|
||||
to: "+19257864429",
|
||||
service: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses raw chat_guid format", () => {
|
||||
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
|
||||
kind: "chat_guid",
|
||||
chatGuid: "iMessage;+;chat660250192681427962",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlueBubblesAllowTarget", () => {
|
||||
it("parses chat<digits> pattern as chat_identifier", () => {
|
||||
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat660250192681427962",
|
||||
});
|
||||
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat123",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses UUID/hex chat identifiers as chat_identifier", () => {
|
||||
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
});
|
||||
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit chat_id: prefix", () => {
|
||||
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
|
||||
});
|
||||
|
||||
it("parses phone numbers as handles", () => {
|
||||
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
|
||||
kind: "handle",
|
||||
handle: "+19257864429",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,11 +20,41 @@ const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> =
|
||||
{ prefix: "sms:", service: "sms" },
|
||||
{ prefix: "auto:", service: "auto" },
|
||||
];
|
||||
const CHAT_IDENTIFIER_UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i;
|
||||
|
||||
function parseRawChatGuid(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const parts = trimmed.split(";");
|
||||
if (parts.length !== 3) return null;
|
||||
const service = parts[0]?.trim();
|
||||
const separator = parts[1]?.trim();
|
||||
const identifier = parts[2]?.trim();
|
||||
if (!service || !identifier) return null;
|
||||
if (separator !== "+" && separator !== "-") return null;
|
||||
return `${service};${separator};${identifier}`;
|
||||
}
|
||||
|
||||
function stripPrefix(value: string, prefix: string): string {
|
||||
return value.slice(prefix.length).trim();
|
||||
}
|
||||
|
||||
function stripBlueBubblesPrefix(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
if (!trimmed.toLowerCase().startsWith("bluebubbles:")) return trimmed;
|
||||
return trimmed.slice("bluebubbles:".length).trim();
|
||||
}
|
||||
|
||||
function looksLikeRawChatIdentifier(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^chat\d+$/i.test(trimmed)) return true;
|
||||
return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
|
||||
}
|
||||
|
||||
export function normalizeBlueBubblesHandle(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "";
|
||||
@@ -36,8 +66,83 @@ export function normalizeBlueBubblesHandle(raw: string): string {
|
||||
return trimmed.replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
||||
/**
|
||||
* Extracts the handle from a chat_guid if it's a DM (1:1 chat).
|
||||
* BlueBubbles chat_guid format for DM: "service;-;handle" (e.g., "iMessage;-;+19257864429")
|
||||
* Group chat format: "service;+;groupId" (has "+" instead of "-")
|
||||
*/
|
||||
export function extractHandleFromChatGuid(chatGuid: string): string | null {
|
||||
const parts = chatGuid.split(";");
|
||||
// DM format: service;-;handle (3 parts, middle is "-")
|
||||
if (parts.length === 3 && parts[1] === "-") {
|
||||
const handle = parts[2]?.trim();
|
||||
if (handle) return normalizeBlueBubblesHandle(handle);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined {
|
||||
let trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
trimmed = stripBlueBubblesPrefix(trimmed);
|
||||
if (!trimmed) return undefined;
|
||||
try {
|
||||
const parsed = parseBlueBubblesTarget(trimmed);
|
||||
if (parsed.kind === "chat_id") return `chat_id:${parsed.chatId}`;
|
||||
if (parsed.kind === "chat_guid") {
|
||||
// For DM chat_guids, normalize to just the handle for easier comparison.
|
||||
// This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890".
|
||||
const handle = extractHandleFromChatGuid(parsed.chatGuid);
|
||||
if (handle) return handle;
|
||||
// For group chats or unrecognized formats, keep the full chat_guid
|
||||
return `chat_guid:${parsed.chatGuid}`;
|
||||
}
|
||||
if (parsed.kind === "chat_identifier") return `chat_identifier:${parsed.chatIdentifier}`;
|
||||
const handle = normalizeBlueBubblesHandle(parsed.to);
|
||||
if (!handle) return undefined;
|
||||
return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
const candidate = stripBlueBubblesPrefix(trimmed);
|
||||
if (!candidate) return false;
|
||||
if (parseRawChatGuid(candidate)) return true;
|
||||
const lowered = candidate.toLowerCase();
|
||||
if (/^(imessage|sms|auto):/.test(lowered)) return true;
|
||||
if (
|
||||
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
|
||||
lowered,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs
|
||||
if (/^chat\d+$/i.test(candidate)) return true;
|
||||
if (looksLikeRawChatIdentifier(candidate)) return true;
|
||||
if (candidate.includes("@")) return true;
|
||||
const digitsOnly = candidate.replace(/[\s().-]/g, "");
|
||||
if (/^\+?\d{3,}$/.test(digitsOnly)) return true;
|
||||
if (normalized) {
|
||||
const normalizedTrimmed = normalized.trim();
|
||||
if (!normalizedTrimmed) return false;
|
||||
const normalizedLower = normalizedTrimmed.toLowerCase();
|
||||
if (
|
||||
/^(imessage|sms|auto):/.test(normalizedLower) ||
|
||||
/^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
||||
const trimmed = stripBlueBubblesPrefix(raw);
|
||||
if (!trimmed) throw new Error("BlueBubbles target is required");
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
@@ -95,6 +200,22 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
||||
return { kind: "chat_guid", chatGuid: value };
|
||||
}
|
||||
|
||||
const rawChatGuid = parseRawChatGuid(trimmed);
|
||||
if (rawChatGuid) {
|
||||
return { kind: "chat_guid", chatGuid: rawChatGuid };
|
||||
}
|
||||
|
||||
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
|
||||
// These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs
|
||||
if (/^chat\d+$/i.test(trimmed)) {
|
||||
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
||||
}
|
||||
|
||||
// Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc")
|
||||
if (looksLikeRawChatIdentifier(trimmed)) {
|
||||
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
||||
}
|
||||
|
||||
return { kind: "handle", to: trimmed, service: "auto" };
|
||||
}
|
||||
|
||||
@@ -140,6 +261,17 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
|
||||
if (value) return { kind: "chat_guid", chatGuid: value };
|
||||
}
|
||||
|
||||
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
|
||||
// These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs
|
||||
if (/^chat\d+$/i.test(trimmed)) {
|
||||
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
||||
}
|
||||
|
||||
// Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc")
|
||||
if (looksLikeRawChatIdentifier(trimmed)) {
|
||||
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
||||
}
|
||||
|
||||
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
||||
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
||||
|
||||
export type BlueBubblesGroupConfig = {
|
||||
/** If true, only respond in this group when mentioned. */
|
||||
requireMention?: boolean;
|
||||
};
|
||||
|
||||
export type BlueBubblesAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
@@ -36,10 +41,23 @@ export type BlueBubblesAccountConfig = {
|
||||
blockStreamingCoalesce?: Record<string, unknown>;
|
||||
/** Max outbound media size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Send read receipts for incoming messages (default: true). */
|
||||
sendReadReceipts?: boolean;
|
||||
/** Per-group configuration keyed by chat GUID or identifier. */
|
||||
groups?: Record<string, BlueBubblesGroupConfig>;
|
||||
};
|
||||
|
||||
export type BlueBubblesActionConfig = {
|
||||
reactions?: boolean;
|
||||
edit?: boolean;
|
||||
unsend?: boolean;
|
||||
reply?: boolean;
|
||||
sendWithEffect?: boolean;
|
||||
renameGroup?: boolean;
|
||||
addParticipant?: boolean;
|
||||
removeParticipant?: boolean;
|
||||
leaveGroup?: boolean;
|
||||
sendAttachment?: boolean;
|
||||
};
|
||||
|
||||
export type BlueBubblesConfig = {
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
|
||||
"clawdbot": "workspace:*",
|
||||
"markdown-it": "14.1.0",
|
||||
"matrix-js-sdk": "40.0.0"
|
||||
"matrix-bot-sdk": "0.8.0",
|
||||
"music-metadata": "^11.10.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import os from "node:os";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
@@ -11,7 +10,7 @@ describe("matrix directory", () => {
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: () => os.tmpdir(),
|
||||
resolveStateDir: (_env, homeDir) => homeDir(),
|
||||
},
|
||||
} as PluginRuntime);
|
||||
});
|
||||
@@ -21,7 +20,8 @@ describe("matrix directory", () => {
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
|
||||
rooms: {
|
||||
groupAllowFrom: ["@dana:example.org"],
|
||||
groups: {
|
||||
"!room1:example.org": { users: ["@carol:example.org"] },
|
||||
"#alias:example.org": { users: [] },
|
||||
},
|
||||
@@ -40,6 +40,7 @@ describe("matrix directory", () => {
|
||||
{ kind: "user", id: "user:@alice:example.org" },
|
||||
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
|
||||
{ kind: "user", id: "user:@carol:example.org" },
|
||||
{ kind: "user", id: "user:@dana:example.org" },
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -46,10 +46,12 @@ const meta = {
|
||||
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
||||
let normalized = raw.trim();
|
||||
if (!normalized) return undefined;
|
||||
if (normalized.toLowerCase().startsWith("matrix:")) {
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered.startsWith("matrix:")) {
|
||||
normalized = normalized.slice("matrix:".length).trim();
|
||||
}
|
||||
return normalized ? normalized.toLowerCase() : undefined;
|
||||
const stripped = normalized.replace(/^(room|channel|user):/i, "").trim();
|
||||
return stripped || undefined;
|
||||
}
|
||||
|
||||
function buildMatrixConfigUpdate(
|
||||
@@ -155,10 +157,11 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
}),
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
const groupPolicy =
|
||||
account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy !== "open") return [];
|
||||
return [
|
||||
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.rooms to restrict rooms.",
|
||||
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.",
|
||||
];
|
||||
},
|
||||
},
|
||||
@@ -168,6 +171,17 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) =>
|
||||
(cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off",
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
const currentTarget = context.To;
|
||||
return {
|
||||
currentChannelId: currentTarget?.trim() || undefined,
|
||||
currentThreadTs:
|
||||
context.MessageThreadId != null
|
||||
? String(context.MessageThreadId)
|
||||
: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMatrixMessagingTarget,
|
||||
@@ -194,7 +208,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
ids.add(raw.replace(/^matrix:/i, ""));
|
||||
}
|
||||
|
||||
for (const room of Object.values(account.config.rooms ?? {})) {
|
||||
for (const entry of account.config.groupAllowFrom ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") continue;
|
||||
ids.add(raw.replace(/^matrix:/i, ""));
|
||||
}
|
||||
|
||||
const groups = account.config.groups ?? account.config.rooms ?? {};
|
||||
for (const room of Object.values(groups)) {
|
||||
for (const entry of room.users ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") continue;
|
||||
@@ -226,7 +247,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = Object.keys(account.config.rooms ?? {})
|
||||
const groups = account.config.groups ?? account.config.rooms ?? {};
|
||||
const ids = Object.keys(groups)
|
||||
.map((raw) => raw.trim())
|
||||
.filter((raw) => Boolean(raw) && raw !== "*")
|
||||
.map((raw) => raw.replace(/^matrix:/i, ""))
|
||||
@@ -263,10 +285,16 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
validateInput: ({ input }) => {
|
||||
if (input.useEnv) return null;
|
||||
if (!input.homeserver?.trim()) return "Matrix requires --homeserver";
|
||||
if (!input.userId?.trim()) return "Matrix requires --user-id";
|
||||
if (!input.accessToken?.trim() && !input.password?.trim()) {
|
||||
const accessToken = input.accessToken?.trim();
|
||||
const password = input.password?.trim();
|
||||
const userId = input.userId?.trim();
|
||||
if (!accessToken && !password) {
|
||||
return "Matrix requires --access-token or --password";
|
||||
}
|
||||
if (!accessToken) {
|
||||
if (!userId) return "Matrix requires --user-id when using --password";
|
||||
if (!password) return "Matrix requires --password when using --user-id";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, input }) => {
|
||||
@@ -381,6 +409,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
mediaMaxMb: account.config.mediaMaxMb,
|
||||
initialSyncLimit: account.config.initialSyncLimit,
|
||||
replyToMode: account.config.replyToMode,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ export const MatrixConfigSchema = z.object({
|
||||
password: z.string().optional(),
|
||||
deviceName: z.string().optional(),
|
||||
initialSyncLimit: z.number().optional(),
|
||||
encryption: z.boolean().optional(),
|
||||
allowlistOnly: z.boolean().optional(),
|
||||
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
||||
replyToMode: z.enum(["off", "first", "all"]).optional(),
|
||||
@@ -49,7 +50,9 @@ export const MatrixConfigSchema = z.object({
|
||||
mediaMaxMb: z.number().optional(),
|
||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
||||
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||
dm: matrixDmSchema,
|
||||
groups: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
actions: matrixActionSchema,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: cfg.channels?.matrix?.rooms,
|
||||
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupChannel || undefined,
|
||||
|
||||
83
extensions/matrix/src/matrix/accounts.test.ts
Normal file
83
extensions/matrix/src/matrix/accounts.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { resolveMatrixAccount } from "./accounts.js";
|
||||
|
||||
vi.mock("./credentials.js", () => ({
|
||||
loadMatrixCredentials: () => null,
|
||||
credentialsMatchConfig: () => false,
|
||||
}));
|
||||
|
||||
const envKeys = [
|
||||
"MATRIX_HOMESERVER",
|
||||
"MATRIX_USER_ID",
|
||||
"MATRIX_ACCESS_TOKEN",
|
||||
"MATRIX_PASSWORD",
|
||||
"MATRIX_DEVICE_NAME",
|
||||
];
|
||||
|
||||
describe("resolveMatrixAccount", () => {
|
||||
let prevEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
prevEnv = {};
|
||||
for (const key of envKeys) {
|
||||
prevEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
const value = prevEnv[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("treats access-token-only config as configured", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-access",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("requires userId + password when no access token is set", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("marks password auth as configured when userId is present", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -31,18 +31,20 @@ export function resolveMatrixAccount(params: {
|
||||
const base = (params.cfg.channels?.matrix ?? {}) as MatrixConfig;
|
||||
const enabled = base.enabled !== false;
|
||||
const resolved = resolveMatrixConfig(params.cfg, process.env);
|
||||
const hasCore = Boolean(resolved.homeserver && resolved.userId);
|
||||
const hasToken = Boolean(resolved.accessToken || resolved.password);
|
||||
const hasHomeserver = Boolean(resolved.homeserver);
|
||||
const hasUserId = Boolean(resolved.userId);
|
||||
const hasAccessToken = Boolean(resolved.accessToken);
|
||||
const hasPassword = Boolean(resolved.password);
|
||||
const hasPasswordAuth = hasUserId && hasPassword;
|
||||
const stored = loadMatrixCredentials(process.env);
|
||||
const hasStored =
|
||||
stored &&
|
||||
resolved.homeserver &&
|
||||
resolved.userId &&
|
||||
credentialsMatchConfig(stored, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
});
|
||||
const configured = hasCore && (hasToken || Boolean(hasStored));
|
||||
stored && resolved.homeserver
|
||||
? credentialsMatchConfig(stored, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId || "",
|
||||
})
|
||||
: false;
|
||||
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
|
||||
@@ -1,447 +1,15 @@
|
||||
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
||||
import {
|
||||
Direction,
|
||||
EventType,
|
||||
MatrixError,
|
||||
MsgType,
|
||||
RelationType,
|
||||
} from "matrix-js-sdk";
|
||||
import type {
|
||||
ReactionEventContent,
|
||||
RoomMessageEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/events.js";
|
||||
import type {
|
||||
RoomPinnedEventsEventContent,
|
||||
RoomTopicEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/state_events.js";
|
||||
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import {
|
||||
createMatrixClient,
|
||||
isBunRuntime,
|
||||
resolveMatrixAuth,
|
||||
resolveSharedMatrixClient,
|
||||
waitForMatrixSync,
|
||||
} from "./client.js";
|
||||
import {
|
||||
reactMatrixMessage,
|
||||
resolveMatrixRoomId,
|
||||
sendMessageMatrix,
|
||||
} from "./send.js";
|
||||
|
||||
export type MatrixActionClientOpts = {
|
||||
client?: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type MatrixMessageSummary = {
|
||||
eventId?: string;
|
||||
sender?: string;
|
||||
body?: string;
|
||||
msgtype?: string;
|
||||
timestamp?: number;
|
||||
relatesTo?: {
|
||||
relType?: string;
|
||||
eventId?: string;
|
||||
key?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixReactionSummary = {
|
||||
key: string;
|
||||
count: number;
|
||||
users: string[];
|
||||
};
|
||||
|
||||
type MatrixActionClient = {
|
||||
client: MatrixClient;
|
||||
stopOnDone: boolean;
|
||||
};
|
||||
|
||||
function ensureNodeRuntime() {
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix support requires Node (bun runtime not supported)");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<MatrixActionClient> {
|
||||
ensureNodeRuntime();
|
||||
if (opts.client) return { client: opts.client, stopOnDone: false };
|
||||
const active = getActiveMatrixClient();
|
||||
if (active) return { client: active, stopOnDone: false };
|
||||
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
|
||||
if (shouldShareClient) {
|
||||
const client = await resolveSharedMatrixClient({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return { client, stopOnDone: false };
|
||||
}
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
});
|
||||
const client = await createMatrixClient({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
localTimeoutMs: opts.timeoutMs,
|
||||
});
|
||||
await client.startClient({
|
||||
initialSyncLimit: 0,
|
||||
lazyLoadMembers: true,
|
||||
threadSupport: true,
|
||||
});
|
||||
await waitForMatrixSync({ client, timeoutMs: opts.timeoutMs });
|
||||
return { client, stopOnDone: true };
|
||||
}
|
||||
|
||||
function summarizeMatrixEvent(event: MatrixEvent): MatrixMessageSummary {
|
||||
const content = event.getContent<RoomMessageEventContent>();
|
||||
const relates = content["m.relates_to"];
|
||||
let relType: string | undefined;
|
||||
let eventId: string | undefined;
|
||||
if (relates) {
|
||||
if ("rel_type" in relates) {
|
||||
relType = relates.rel_type;
|
||||
eventId = relates.event_id;
|
||||
} else if ("m.in_reply_to" in relates) {
|
||||
eventId = relates["m.in_reply_to"]?.event_id;
|
||||
}
|
||||
}
|
||||
const relatesTo =
|
||||
relType || eventId
|
||||
? {
|
||||
relType,
|
||||
eventId,
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
eventId: event.getId() ?? undefined,
|
||||
sender: event.getSender() ?? undefined,
|
||||
body: content.body,
|
||||
msgtype: content.msgtype,
|
||||
timestamp: event.getTs() ?? undefined,
|
||||
relatesTo,
|
||||
};
|
||||
}
|
||||
|
||||
async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
|
||||
try {
|
||||
const content = (await client.getStateEvent(
|
||||
roomId,
|
||||
EventType.RoomPinnedEvents,
|
||||
"",
|
||||
)) as RoomPinnedEventsEventContent;
|
||||
const pinned = content.pinned;
|
||||
return pinned.filter((id) => id.trim().length > 0);
|
||||
} catch (err) {
|
||||
const httpStatus = err instanceof MatrixError ? err.httpStatus : undefined;
|
||||
const errcode = err instanceof MatrixError ? err.errcode : undefined;
|
||||
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEventSummary(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
eventId: string,
|
||||
): Promise<MatrixMessageSummary | null> {
|
||||
const raw = await client.fetchRoomEvent(roomId, eventId);
|
||||
const mapper = client.getEventMapper();
|
||||
const event = mapper(raw);
|
||||
if (event.isRedacted()) return null;
|
||||
return summarizeMatrixEvent(event);
|
||||
}
|
||||
|
||||
export async function sendMatrixMessage(
|
||||
to: string,
|
||||
content: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
threadId?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await sendMessageMatrix(to, content, {
|
||||
mediaUrl: opts.mediaUrl,
|
||||
replyToId: opts.replyToId,
|
||||
threadId: opts.threadId,
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
content: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) throw new Error("Matrix edit requires content");
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const newContent = {
|
||||
msgtype: MsgType.Text,
|
||||
body: trimmed,
|
||||
} satisfies RoomMessageEventContent;
|
||||
const payload: RoomMessageEventContent = {
|
||||
msgtype: MsgType.Text,
|
||||
body: `* ${trimmed}`,
|
||||
"m.new_content": newContent,
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: messageId,
|
||||
},
|
||||
};
|
||||
const response = await client.sendMessage(resolvedRoom, payload);
|
||||
return { eventId: response.event_id ?? null };
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { reason?: string } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
await client.redactEvent(resolvedRoom, messageId, undefined, {
|
||||
reason: opts.reason,
|
||||
});
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readMatrixMessages(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
limit?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
} = {},
|
||||
): Promise<{
|
||||
messages: MatrixMessageSummary[];
|
||||
nextBatch?: string | null;
|
||||
prevBatch?: string | null;
|
||||
}> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const limit =
|
||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||
? Math.max(1, Math.floor(opts.limit))
|
||||
: 20;
|
||||
const token = opts.before?.trim() || opts.after?.trim() || null;
|
||||
const dir = opts.after ? Direction.Forward : Direction.Backward;
|
||||
const res = await client.createMessagesRequest(resolvedRoom, token, limit, dir);
|
||||
const mapper = client.getEventMapper();
|
||||
const events = res.chunk.map(mapper);
|
||||
const messages = events
|
||||
.filter((event) => event.getType() === EventType.RoomMessage)
|
||||
.filter((event) => !event.isRedacted())
|
||||
.map(summarizeMatrixEvent);
|
||||
return {
|
||||
messages,
|
||||
nextBatch: res.end ?? null,
|
||||
prevBatch: res.start ?? null,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMatrixReactions(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { limit?: number } = {},
|
||||
): Promise<MatrixReactionSummary[]> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const limit =
|
||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||
? Math.max(1, Math.floor(opts.limit))
|
||||
: 100;
|
||||
const res = await client.relations(
|
||||
resolvedRoom,
|
||||
messageId,
|
||||
RelationType.Annotation,
|
||||
EventType.Reaction,
|
||||
{ dir: Direction.Backward, limit },
|
||||
);
|
||||
const summaries = new Map<string, MatrixReactionSummary>();
|
||||
for (const event of res.events) {
|
||||
const content = event.getContent<ReactionEventContent>();
|
||||
const key = content["m.relates_to"].key;
|
||||
if (!key) continue;
|
||||
const sender = event.getSender() ?? "";
|
||||
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
||||
key,
|
||||
count: 0,
|
||||
users: [],
|
||||
};
|
||||
entry.count += 1;
|
||||
if (sender && !entry.users.includes(sender)) {
|
||||
entry.users.push(sender);
|
||||
}
|
||||
summaries.set(key, entry);
|
||||
}
|
||||
return Array.from(summaries.values());
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeMatrixReactions(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { emoji?: string } = {},
|
||||
): Promise<{ removed: number }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const res = await client.relations(
|
||||
resolvedRoom,
|
||||
messageId,
|
||||
RelationType.Annotation,
|
||||
EventType.Reaction,
|
||||
{ dir: Direction.Backward, limit: 200 },
|
||||
);
|
||||
const userId = client.getUserId();
|
||||
if (!userId) return { removed: 0 };
|
||||
const targetEmoji = opts.emoji?.trim();
|
||||
const toRemove = res.events
|
||||
.filter((event) => event.getSender() === userId)
|
||||
.filter((event) => {
|
||||
if (!targetEmoji) return true;
|
||||
const content = event.getContent<ReactionEventContent>();
|
||||
return content["m.relates_to"].key === targetEmoji;
|
||||
})
|
||||
.map((event) => event.getId())
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (toRemove.length === 0) return { removed: 0 };
|
||||
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
||||
return { removed: toRemove.length };
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function pinMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[] }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const current = await readPinnedEvents(client, resolvedRoom);
|
||||
const next = current.includes(messageId) ? current : [...current, messageId];
|
||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload);
|
||||
return { pinned: next };
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function unpinMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[] }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const current = await readPinnedEvents(client, resolvedRoom);
|
||||
const next = current.filter((id) => id !== messageId);
|
||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload);
|
||||
return { pinned: next };
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMatrixPins(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const pinned = await readPinnedEvents(client, resolvedRoom);
|
||||
const events = (
|
||||
await Promise.all(
|
||||
pinned.map(async (eventId) => {
|
||||
try {
|
||||
return await fetchEventSummary(client, resolvedRoom, eventId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
||||
return { pinned, events };
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMatrixMemberInfo(
|
||||
userId: string,
|
||||
opts: MatrixActionClientOpts & { roomId?: string } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
||||
const profile = await client.getProfileInfo(userId);
|
||||
const member = roomId ? client.getRoom(roomId)?.getMember(userId) : undefined;
|
||||
return {
|
||||
userId,
|
||||
profile: {
|
||||
displayName: profile?.displayname ?? null,
|
||||
avatarUrl: profile?.avatar_url ?? null,
|
||||
},
|
||||
membership: member?.membership ?? null,
|
||||
powerLevel: member?.powerLevel ?? null,
|
||||
displayName: member?.name ?? null,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const room = client.getRoom(resolvedRoom);
|
||||
const topicEvent = room?.currentState.getStateEvents(EventType.RoomTopic, "");
|
||||
const topicContent = topicEvent?.getContent<RoomTopicEventContent>();
|
||||
const topic = typeof topicContent?.topic === "string" ? topicContent.topic : undefined;
|
||||
return {
|
||||
roomId: resolvedRoom,
|
||||
name: room?.name ?? null,
|
||||
topic: topic ?? null,
|
||||
canonicalAlias: room?.getCanonicalAlias?.() ?? null,
|
||||
altAliases: room?.getAltAliases?.() ?? [],
|
||||
memberCount: room?.getJoinedMemberCount?.() ?? null,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export { reactMatrixMessage };
|
||||
export type {
|
||||
MatrixActionClientOpts,
|
||||
MatrixMessageSummary,
|
||||
MatrixReactionSummary,
|
||||
} from "./actions/types.js";
|
||||
export {
|
||||
sendMatrixMessage,
|
||||
editMatrixMessage,
|
||||
deleteMatrixMessage,
|
||||
readMatrixMessages,
|
||||
} from "./actions/messages.js";
|
||||
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js";
|
||||
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
|
||||
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
|
||||
export { reactMatrixMessage } from "./send.js";
|
||||
|
||||
53
extensions/matrix/src/matrix/actions/client.ts
Normal file
53
extensions/matrix/src/matrix/actions/client.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getActiveMatrixClient } from "../active-client.js";
|
||||
import {
|
||||
createMatrixClient,
|
||||
isBunRuntime,
|
||||
resolveMatrixAuth,
|
||||
resolveSharedMatrixClient,
|
||||
} from "../client.js";
|
||||
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export function ensureNodeRuntime() {
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix support requires Node (bun runtime not supported)");
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveActionClient(
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<MatrixActionClient> {
|
||||
ensureNodeRuntime();
|
||||
if (opts.client) return { client: opts.client, stopOnDone: false };
|
||||
const active = getActiveMatrixClient();
|
||||
if (active) return { client: active, stopOnDone: false };
|
||||
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
|
||||
if (shouldShareClient) {
|
||||
const client = await resolveSharedMatrixClient({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return { client, stopOnDone: false };
|
||||
}
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
});
|
||||
const client = await createMatrixClient({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
encryption: auth.encryption,
|
||||
localTimeoutMs: opts.timeoutMs,
|
||||
});
|
||||
if (auth.encryption && client.crypto) {
|
||||
try {
|
||||
const joinedRooms = await client.getJoinedRooms();
|
||||
await client.crypto.prepare(joinedRooms);
|
||||
} catch {
|
||||
// Ignore crypto prep failures for one-off actions.
|
||||
}
|
||||
}
|
||||
await client.start();
|
||||
return { client, stopOnDone: true };
|
||||
}
|
||||
120
extensions/matrix/src/matrix/actions/messages.ts
Normal file
120
extensions/matrix/src/matrix/actions/messages.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
EventType,
|
||||
MsgType,
|
||||
RelationType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixMessageSummary,
|
||||
type MatrixRawEvent,
|
||||
type RoomMessageEventContent,
|
||||
} from "./types.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import { summarizeMatrixRawEvent } from "./summary.js";
|
||||
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
|
||||
|
||||
export async function sendMatrixMessage(
|
||||
to: string,
|
||||
content: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
threadId?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await sendMessageMatrix(to, content, {
|
||||
mediaUrl: opts.mediaUrl,
|
||||
replyToId: opts.replyToId,
|
||||
threadId: opts.threadId,
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
content: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) throw new Error("Matrix edit requires content");
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const newContent = {
|
||||
msgtype: MsgType.Text,
|
||||
body: trimmed,
|
||||
} satisfies RoomMessageEventContent;
|
||||
const payload: RoomMessageEventContent = {
|
||||
msgtype: MsgType.Text,
|
||||
body: `* ${trimmed}`,
|
||||
"m.new_content": newContent,
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: messageId,
|
||||
},
|
||||
};
|
||||
const eventId = await client.sendMessage(resolvedRoom, payload);
|
||||
return { eventId: eventId ?? null };
|
||||
} finally {
|
||||
if (stopOnDone) client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { reason?: string } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
await client.redactEvent(resolvedRoom, messageId, opts.reason);
|
||||
} finally {
|
||||
if (stopOnDone) client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readMatrixMessages(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
limit?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
} = {},
|
||||
): Promise<{
|
||||
messages: MatrixMessageSummary[];
|
||||
nextBatch?: string | null;
|
||||
prevBatch?: string | null;
|
||||
}> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const limit =
|
||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||
? Math.max(1, Math.floor(opts.limit))
|
||||
: 20;
|
||||
const token = opts.before?.trim() || opts.after?.trim() || undefined;
|
||||
const dir = opts.after ? "f" : "b";
|
||||
// matrix-bot-sdk uses doRequest for room messages
|
||||
const res = await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
|
||||
{
|
||||
dir,
|
||||
limit,
|
||||
from: token,
|
||||
},
|
||||
) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
|
||||
const messages = res.chunk
|
||||
.filter((event) => event.type === EventType.RoomMessage)
|
||||
.filter((event) => !event.unsigned?.redacted_because)
|
||||
.map(summarizeMatrixRawEvent);
|
||||
return {
|
||||
messages,
|
||||
nextBatch: res.end ?? null,
|
||||
prevBatch: res.start ?? null,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) client.stop();
|
||||
}
|
||||
}
|
||||
70
extensions/matrix/src/matrix/actions/pins.ts
Normal file
70
extensions/matrix/src/matrix/actions/pins.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
EventType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixMessageSummary,
|
||||
type RoomPinnedEventsEventContent,
|
||||
} from "./types.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
|
||||
export async function pinMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[] }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const current = await readPinnedEvents(client, resolvedRoom);
|
||||
const next = current.includes(messageId) ? current : [...current, messageId];
|
||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
||||
return { pinned: next };
|
||||
} finally {
|
||||
if (stopOnDone) client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export async function unpinMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[] }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const current = await readPinnedEvents(client, resolvedRoom);
|
||||
const next = current.filter((id) => id !== messageId);
|
||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
||||
return { pinned: next };
|
||||
} finally {
|
||||
if (stopOnDone) client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMatrixPins(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const pinned = await readPinnedEvents(client, resolvedRoom);
|
||||
const events = (
|
||||
await Promise.all(
|
||||
pinned.map(async (eventId) => {
|
||||
try {
|
||||
return await fetchEventSummary(client, resolvedRoom, eventId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
||||
return { pinned, events };
|
||||
} finally {
|
||||
if (stopOnDone) client.stop();
|
||||
}
|
||||
}
|
||||
84
extensions/matrix/src/matrix/actions/reactions.ts
Normal file
84
extensions/matrix/src/matrix/actions/reactions.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
EventType,
|
||||
RelationType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixRawEvent,
|
||||
type MatrixReactionSummary,
|
||||
type ReactionEventContent,
|
||||
} from "./types.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
|
||||
export async function listMatrixReactions(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { limit?: number } = {},
|
||||
): Promise<MatrixReactionSummary[]> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const limit =
|
||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||
? Math.max(1, Math.floor(opts.limit))
|
||||
: 100;
|
||||
// matrix-bot-sdk uses doRequest for relations
|
||||
const res = await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||
{ dir: "b", limit },
|
||||
) as { chunk: MatrixRawEvent[] };
|
||||
const summaries = new Map<string, MatrixReactionSummary>();
|
||||
for (const event of res.chunk) {
|
||||
const content = event.content as ReactionEventContent;
|
||||
const key = content["m.relates_to"]?.key;
|
||||
if (!key) continue;
|
||||
const sender = event.sender ?? "";
|
||||
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
||||
key,
|
||||
count: 0,
|
||||
users: [],
|
||||
};
|
||||
entry.count += 1;
|
||||
if (sender && !entry.users.includes(sender)) {
|
||||
entry.users.push(sender);
|
||||
}
|
||||
summaries.set(key, entry);
|
||||
}
|
||||
return Array.from(summaries.values());
|
||||
} finally {
|
||||
if (stopOnDone) client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeMatrixReactions(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { emoji?: string } = {},
|
||||
): Promise<{ removed: number }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const res = await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||
{ dir: "b", limit: 200 },
|
||||
) as { chunk: MatrixRawEvent[] };
|
||||
const userId = await client.getUserId();
|
||||
if (!userId) return { removed: 0 };
|
||||
const targetEmoji = opts.emoji?.trim();
|
||||
const toRemove = res.chunk
|
||||
.filter((event) => event.sender === userId)
|
||||
.filter((event) => {
|
||||
if (!targetEmoji) return true;
|
||||
const content = event.content as ReactionEventContent;
|
||||
return content["m.relates_to"]?.key === targetEmoji;
|
||||
})
|
||||
.map((event) => event.event_id)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (toRemove.length === 0) return { removed: 0 };
|
||||
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
||||
return { removed: toRemove.length };
|
||||
} finally {
|
||||
if (stopOnDone) client.stop();
|
||||
}
|
||||
}
|
||||
88
extensions/matrix/src/matrix/actions/room.ts
Normal file
88
extensions/matrix/src/matrix/actions/room.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { EventType, type MatrixActionClientOpts } from "./types.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
|
||||
export async function getMatrixMemberInfo(
|
||||
userId: string,
|
||||
opts: MatrixActionClientOpts & { roomId?: string } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
||||
// matrix-bot-sdk uses getUserProfile
|
||||
const profile = await client.getUserProfile(userId);
|
||||
// Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
|
||||
// We'd need to fetch room state separately if needed
|
||||
return {
|
||||
userId,
|
||||
profile: {
|
||||
displayName: profile?.displayname ?? null,
|
||||
avatarUrl: profile?.avatar_url ?? null,
|
||||
},
|
||||
membership: null, // Would need separate room state query
|
||||
powerLevel: null, // Would need separate power levels state query
|
||||
displayName: profile?.displayname ?? null,
|
||||
roomId: roomId ?? null,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMatrixRoomInfo(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
// matrix-bot-sdk uses getRoomState for state events
|
||||
let name: string | null = null;
|
||||
let topic: string | null = null;
|
||||
let canonicalAlias: string | null = null;
|
||||
let memberCount: number | null = null;
|
||||
|
||||
try {
|
||||
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
|
||||
name = nameState?.name ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
|
||||
topic = topicState?.topic ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const aliasState = await client.getRoomStateEvent(
|
||||
resolvedRoom,
|
||||
"m.room.canonical_alias",
|
||||
"",
|
||||
);
|
||||
canonicalAlias = aliasState?.alias ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const members = await client.getJoinedRoomMembers(resolvedRoom);
|
||||
memberCount = members.length;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
roomId: resolvedRoom,
|
||||
name,
|
||||
topic,
|
||||
canonicalAlias,
|
||||
altAliases: [], // Would need separate query
|
||||
memberCount,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) client.stop();
|
||||
}
|
||||
}
|
||||
77
extensions/matrix/src/matrix/actions/summary.ts
Normal file
77
extensions/matrix/src/matrix/actions/summary.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { MatrixClient } from "matrix-bot-sdk";
|
||||
|
||||
import {
|
||||
EventType,
|
||||
type MatrixMessageSummary,
|
||||
type MatrixRawEvent,
|
||||
type RoomMessageEventContent,
|
||||
type RoomPinnedEventsEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
|
||||
const content = event.content as RoomMessageEventContent;
|
||||
const relates = content["m.relates_to"];
|
||||
let relType: string | undefined;
|
||||
let eventId: string | undefined;
|
||||
if (relates) {
|
||||
if ("rel_type" in relates) {
|
||||
relType = relates.rel_type;
|
||||
eventId = relates.event_id;
|
||||
} else if ("m.in_reply_to" in relates) {
|
||||
eventId = relates["m.in_reply_to"]?.event_id;
|
||||
}
|
||||
}
|
||||
const relatesTo =
|
||||
relType || eventId
|
||||
? {
|
||||
relType,
|
||||
eventId,
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
eventId: event.event_id,
|
||||
sender: event.sender,
|
||||
body: content.body,
|
||||
msgtype: content.msgtype,
|
||||
timestamp: event.origin_server_ts,
|
||||
relatesTo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readPinnedEvents(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const content = (await client.getRoomStateEvent(
|
||||
roomId,
|
||||
EventType.RoomPinnedEvents,
|
||||
"",
|
||||
)) as RoomPinnedEventsEventContent;
|
||||
const pinned = content.pinned;
|
||||
return pinned.filter((id) => id.trim().length > 0);
|
||||
} catch (err: unknown) {
|
||||
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
|
||||
const httpStatus = errObj.statusCode;
|
||||
const errcode = errObj.body?.errcode;
|
||||
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEventSummary(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
eventId: string,
|
||||
): Promise<MatrixMessageSummary | null> {
|
||||
try {
|
||||
const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent;
|
||||
if (raw.unsigned?.redacted_because) return null;
|
||||
return summarizeMatrixRawEvent(raw);
|
||||
} catch {
|
||||
// Event not found, redacted, or inaccessible - return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
84
extensions/matrix/src/matrix/actions/types.ts
Normal file
84
extensions/matrix/src/matrix/actions/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { MatrixClient } from "matrix-bot-sdk";
|
||||
|
||||
export const MsgType = {
|
||||
Text: "m.text",
|
||||
} as const;
|
||||
|
||||
export const RelationType = {
|
||||
Replace: "m.replace",
|
||||
Annotation: "m.annotation",
|
||||
} as const;
|
||||
|
||||
export const EventType = {
|
||||
RoomMessage: "m.room.message",
|
||||
RoomPinnedEvents: "m.room.pinned_events",
|
||||
RoomTopic: "m.room.topic",
|
||||
Reaction: "m.reaction",
|
||||
} as const;
|
||||
|
||||
export type RoomMessageEventContent = {
|
||||
msgtype: string;
|
||||
body: string;
|
||||
"m.new_content"?: RoomMessageEventContent;
|
||||
"m.relates_to"?: {
|
||||
rel_type?: string;
|
||||
event_id?: string;
|
||||
"m.in_reply_to"?: { event_id?: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type ReactionEventContent = {
|
||||
"m.relates_to": {
|
||||
rel_type: string;
|
||||
event_id: string;
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type RoomPinnedEventsEventContent = {
|
||||
pinned: string[];
|
||||
};
|
||||
|
||||
export type RoomTopicEventContent = {
|
||||
topic?: string;
|
||||
};
|
||||
|
||||
export type MatrixRawEvent = {
|
||||
event_id: string;
|
||||
sender: string;
|
||||
type: string;
|
||||
origin_server_ts: number;
|
||||
content: Record<string, unknown>;
|
||||
unsigned?: {
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixActionClientOpts = {
|
||||
client?: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type MatrixMessageSummary = {
|
||||
eventId?: string;
|
||||
sender?: string;
|
||||
body?: string;
|
||||
msgtype?: string;
|
||||
timestamp?: number;
|
||||
relatesTo?: {
|
||||
relType?: string;
|
||||
eventId?: string;
|
||||
key?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixReactionSummary = {
|
||||
key: string;
|
||||
count: number;
|
||||
users: string[];
|
||||
};
|
||||
|
||||
export type MatrixActionClient = {
|
||||
client: MatrixClient;
|
||||
stopOnDone: boolean;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
import type { MatrixClient } from "matrix-bot-sdk";
|
||||
|
||||
let activeClient: MatrixClient | null = null;
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("resolveMatrixConfig", () => {
|
||||
password: "cfg-pass",
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
encryption: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,5 +52,6 @@ describe("resolveMatrixConfig", () => {
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
expect(resolved.deviceName).toBe("EnvDevice");
|
||||
expect(resolved.initialSyncLimit).toBeUndefined();
|
||||
expect(resolved.encryption).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,338 +1,9 @@
|
||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
|
||||
export type MatrixResolvedConfig = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
};
|
||||
|
||||
export type MatrixAuth = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
};
|
||||
|
||||
type MatrixSdk = typeof import("matrix-js-sdk");
|
||||
|
||||
type SharedMatrixClientState = {
|
||||
client: MatrixClient;
|
||||
key: string;
|
||||
started: boolean;
|
||||
};
|
||||
|
||||
let sharedClientState: SharedMatrixClientState | null = null;
|
||||
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
|
||||
let sharedClientStartPromise: Promise<void> | null = null;
|
||||
|
||||
export function isBunRuntime(): boolean {
|
||||
const versions = process.versions as { bun?: string };
|
||||
return typeof versions.bun === "string";
|
||||
}
|
||||
|
||||
async function loadMatrixSdk(): Promise<MatrixSdk> {
|
||||
return (await import("matrix-js-sdk")) as MatrixSdk;
|
||||
}
|
||||
|
||||
function clean(value?: string): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function resolveMatrixConfig(
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const matrix = cfg.channels?.matrix ?? {};
|
||||
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
|
||||
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
|
||||
const accessToken =
|
||||
clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
|
||||
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
|
||||
const deviceName =
|
||||
clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
|
||||
const initialSyncLimit =
|
||||
typeof matrix.initialSyncLimit === "number"
|
||||
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
||||
: undefined;
|
||||
return {
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
password,
|
||||
deviceName,
|
||||
initialSyncLimit,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMatrixAuth(params?: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<MatrixAuth> {
|
||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const env = params?.env ?? process.env;
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
if (!resolved.homeserver) {
|
||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
||||
}
|
||||
if (!resolved.userId) {
|
||||
throw new Error("Matrix userId is required (matrix.userId)");
|
||||
}
|
||||
|
||||
const {
|
||||
loadMatrixCredentials,
|
||||
saveMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
touchMatrixCredentials,
|
||||
} = await import("./credentials.js");
|
||||
|
||||
const cached = loadMatrixCredentials(env);
|
||||
const cachedCredentials =
|
||||
cached &&
|
||||
credentialsMatchConfig(cached, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
})
|
||||
? cached
|
||||
: null;
|
||||
|
||||
if (resolved.accessToken) {
|
||||
if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
||||
touchMatrixCredentials(env);
|
||||
}
|
||||
return {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
accessToken: resolved.accessToken,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
};
|
||||
}
|
||||
|
||||
if (cachedCredentials) {
|
||||
touchMatrixCredentials(env);
|
||||
return {
|
||||
homeserver: cachedCredentials.homeserver,
|
||||
userId: cachedCredentials.userId,
|
||||
accessToken: cachedCredentials.accessToken,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
};
|
||||
}
|
||||
|
||||
if (!resolved.password) {
|
||||
throw new Error(
|
||||
"Matrix access token or password is required (matrix.accessToken or matrix.password)",
|
||||
);
|
||||
}
|
||||
|
||||
const sdk = await loadMatrixSdk();
|
||||
const loginClient = sdk.createClient({
|
||||
baseUrl: resolved.homeserver,
|
||||
});
|
||||
const login = await loginClient.loginRequest({
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: resolved.userId },
|
||||
password: resolved.password,
|
||||
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
|
||||
});
|
||||
const accessToken = login.access_token?.trim();
|
||||
if (!accessToken) {
|
||||
throw new Error("Matrix login did not return an access token");
|
||||
}
|
||||
|
||||
const auth: MatrixAuth = {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: login.user_id ?? resolved.userId,
|
||||
accessToken,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
};
|
||||
|
||||
saveMatrixCredentials({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
});
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
export async function createMatrixClient(params: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
localTimeoutMs?: number;
|
||||
}): Promise<MatrixClient> {
|
||||
const sdk = await loadMatrixSdk();
|
||||
const store = new sdk.MemoryStore();
|
||||
return sdk.createClient({
|
||||
baseUrl: params.homeserver,
|
||||
userId: params.userId,
|
||||
accessToken: params.accessToken,
|
||||
localTimeoutMs: params.localTimeoutMs,
|
||||
store,
|
||||
});
|
||||
}
|
||||
|
||||
function buildSharedClientKey(auth: MatrixAuth): string {
|
||||
return [auth.homeserver, auth.userId, auth.accessToken].join("|");
|
||||
}
|
||||
|
||||
async function createSharedMatrixClient(params: {
|
||||
auth: MatrixAuth;
|
||||
timeoutMs?: number;
|
||||
}): Promise<SharedMatrixClientState> {
|
||||
const client = await createMatrixClient({
|
||||
homeserver: params.auth.homeserver,
|
||||
userId: params.auth.userId,
|
||||
accessToken: params.auth.accessToken,
|
||||
localTimeoutMs: params.timeoutMs,
|
||||
});
|
||||
return { client, key: buildSharedClientKey(params.auth), started: false };
|
||||
}
|
||||
|
||||
async function ensureSharedClientStarted(params: {
|
||||
state: SharedMatrixClientState;
|
||||
timeoutMs?: number;
|
||||
initialSyncLimit?: number;
|
||||
}): Promise<void> {
|
||||
if (params.state.started) return;
|
||||
if (sharedClientStartPromise) {
|
||||
await sharedClientStartPromise;
|
||||
return;
|
||||
}
|
||||
sharedClientStartPromise = (async () => {
|
||||
const startOpts: Parameters<MatrixClient["startClient"]>[0] = {
|
||||
lazyLoadMembers: true,
|
||||
threadSupport: true,
|
||||
};
|
||||
if (typeof params.initialSyncLimit === "number") {
|
||||
startOpts.initialSyncLimit = params.initialSyncLimit;
|
||||
}
|
||||
await params.state.client.startClient(startOpts);
|
||||
await waitForMatrixSync({
|
||||
client: params.state.client,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
params.state.started = true;
|
||||
})();
|
||||
try {
|
||||
await sharedClientStartPromise;
|
||||
} finally {
|
||||
sharedClientStartPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSharedMatrixClient(
|
||||
params: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
auth?: MatrixAuth;
|
||||
startClient?: boolean;
|
||||
} = {},
|
||||
): Promise<MatrixClient> {
|
||||
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
|
||||
const key = buildSharedClientKey(auth);
|
||||
const shouldStart = params.startClient !== false;
|
||||
|
||||
if (sharedClientState?.key === key) {
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: sharedClientState,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
});
|
||||
}
|
||||
return sharedClientState.client;
|
||||
}
|
||||
|
||||
if (sharedClientPromise) {
|
||||
const pending = await sharedClientPromise;
|
||||
if (pending.key === key) {
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: pending,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
});
|
||||
}
|
||||
return pending.client;
|
||||
}
|
||||
pending.client.stopClient();
|
||||
sharedClientState = null;
|
||||
sharedClientPromise = null;
|
||||
}
|
||||
|
||||
sharedClientPromise = createSharedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
try {
|
||||
const created = await sharedClientPromise;
|
||||
sharedClientState = created;
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: created,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
});
|
||||
}
|
||||
return created.client;
|
||||
} finally {
|
||||
sharedClientPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForMatrixSync(params: {
|
||||
client: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const timeoutMs = Math.max(1000, params.timeoutMs ?? 15_000);
|
||||
if (params.client.getSyncState() === SyncState.Syncing) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let done = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const cleanup = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
params.client.removeListener(ClientEvent.Sync, onSync);
|
||||
if (params.abortSignal) {
|
||||
params.abortSignal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
const onSync = (state: SyncState) => {
|
||||
if (done) return;
|
||||
if (state === SyncState.Prepared || state === SyncState.Syncing) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
if (state === SyncState.Error) {
|
||||
cleanup();
|
||||
reject(new Error("Matrix sync failed"));
|
||||
}
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(new Error("Matrix sync aborted"));
|
||||
};
|
||||
params.client.on(ClientEvent.Sync, onSync);
|
||||
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Matrix sync timed out"));
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
|
||||
export { isBunRuntime } from "./client/runtime.js";
|
||||
export { resolveMatrixConfig, resolveMatrixAuth } from "./client/config.js";
|
||||
export { createMatrixClient } from "./client/create-client.js";
|
||||
export {
|
||||
resolveSharedMatrixClient,
|
||||
waitForMatrixSync,
|
||||
stopSharedClient,
|
||||
} from "./client/shared.js";
|
||||
|
||||
165
extensions/matrix/src/matrix/client/config.ts
Normal file
165
extensions/matrix/src/matrix/client/config.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { MatrixClient } from "matrix-bot-sdk";
|
||||
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
|
||||
function clean(value?: string): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function resolveMatrixConfig(
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const matrix = cfg.channels?.matrix ?? {};
|
||||
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
|
||||
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
|
||||
const accessToken =
|
||||
clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
|
||||
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
|
||||
const deviceName =
|
||||
clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
|
||||
const initialSyncLimit =
|
||||
typeof matrix.initialSyncLimit === "number"
|
||||
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
||||
: undefined;
|
||||
const encryption = matrix.encryption ?? false;
|
||||
return {
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
password,
|
||||
deviceName,
|
||||
initialSyncLimit,
|
||||
encryption,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMatrixAuth(params?: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<MatrixAuth> {
|
||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const env = params?.env ?? process.env;
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
if (!resolved.homeserver) {
|
||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
||||
}
|
||||
|
||||
const {
|
||||
loadMatrixCredentials,
|
||||
saveMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
touchMatrixCredentials,
|
||||
} = await import("./credentials.js");
|
||||
|
||||
const cached = loadMatrixCredentials(env);
|
||||
const cachedCredentials =
|
||||
cached &&
|
||||
credentialsMatchConfig(cached, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId || "",
|
||||
})
|
||||
? cached
|
||||
: null;
|
||||
|
||||
// If we have an access token, we can fetch userId via whoami if not provided
|
||||
if (resolved.accessToken) {
|
||||
let userId = resolved.userId;
|
||||
if (!userId) {
|
||||
// Fetch userId from access token via whoami
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
|
||||
const whoami = await tempClient.getUserId();
|
||||
userId = whoami;
|
||||
// Save the credentials with the fetched userId
|
||||
saveMatrixCredentials({
|
||||
homeserver: resolved.homeserver,
|
||||
userId,
|
||||
accessToken: resolved.accessToken,
|
||||
});
|
||||
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
||||
touchMatrixCredentials(env);
|
||||
}
|
||||
return {
|
||||
homeserver: resolved.homeserver,
|
||||
userId,
|
||||
accessToken: resolved.accessToken,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
};
|
||||
}
|
||||
|
||||
if (cachedCredentials) {
|
||||
touchMatrixCredentials(env);
|
||||
return {
|
||||
homeserver: cachedCredentials.homeserver,
|
||||
userId: cachedCredentials.userId,
|
||||
accessToken: cachedCredentials.accessToken,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
};
|
||||
}
|
||||
|
||||
if (!resolved.userId) {
|
||||
throw new Error(
|
||||
"Matrix userId is required when no access token is configured (matrix.userId)",
|
||||
);
|
||||
}
|
||||
|
||||
if (!resolved.password) {
|
||||
throw new Error(
|
||||
"Matrix password is required when no access token is configured (matrix.password)",
|
||||
);
|
||||
}
|
||||
|
||||
// Login with password using HTTP API
|
||||
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: resolved.userId },
|
||||
password: resolved.password,
|
||||
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
const errorText = await loginResponse.text();
|
||||
throw new Error(`Matrix login failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const login = (await loginResponse.json()) as {
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
|
||||
const accessToken = login.access_token?.trim();
|
||||
if (!accessToken) {
|
||||
throw new Error("Matrix login did not return an access token");
|
||||
}
|
||||
|
||||
const auth: MatrixAuth = {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: login.user_id ?? resolved.userId,
|
||||
accessToken,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
};
|
||||
|
||||
saveMatrixCredentials({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
deviceId: login.device_id,
|
||||
});
|
||||
|
||||
return auth;
|
||||
}
|
||||
127
extensions/matrix/src/matrix/client/create-client.ts
Normal file
127
extensions/matrix/src/matrix/client/create-client.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
import {
|
||||
LogService,
|
||||
MatrixClient,
|
||||
SimpleFsStorageProvider,
|
||||
RustSdkCryptoStorageProvider,
|
||||
} from "matrix-bot-sdk";
|
||||
import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk";
|
||||
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import {
|
||||
maybeMigrateLegacyStorage,
|
||||
resolveMatrixStoragePaths,
|
||||
writeStorageMeta,
|
||||
} from "./storage.js";
|
||||
|
||||
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
||||
if (input == null) return [];
|
||||
if (!Array.isArray(input)) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Expected ${label} list to be an array, got ${typeof input}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const filtered = input.filter(
|
||||
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
||||
);
|
||||
if (filtered.length !== input.length) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export async function createMatrixClient(params: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
encryption?: boolean;
|
||||
localTimeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
}): Promise<MatrixClient> {
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const env = process.env;
|
||||
|
||||
// Create storage provider
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
accessToken: params.accessToken,
|
||||
accountId: params.accountId,
|
||||
env,
|
||||
});
|
||||
maybeMigrateLegacyStorage({ storagePaths, env });
|
||||
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
|
||||
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
|
||||
|
||||
// Create crypto storage if encryption is enabled
|
||||
let cryptoStorage: ICryptoStorageProvider | undefined;
|
||||
if (params.encryption) {
|
||||
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
|
||||
|
||||
try {
|
||||
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
cryptoStorage = new RustSdkCryptoStorageProvider(
|
||||
storagePaths.cryptoPath,
|
||||
StoreType.Sqlite,
|
||||
);
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
|
||||
}
|
||||
}
|
||||
|
||||
writeStorageMeta({
|
||||
storagePaths,
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
const client = new MatrixClient(
|
||||
params.homeserver,
|
||||
params.accessToken,
|
||||
storage,
|
||||
cryptoStorage,
|
||||
);
|
||||
|
||||
if (client.crypto) {
|
||||
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
|
||||
client.crypto.updateSyncData = async (
|
||||
toDeviceMessages,
|
||||
otkCounts,
|
||||
unusedFallbackKeyAlgs,
|
||||
changedDeviceLists,
|
||||
leftDeviceLists,
|
||||
) => {
|
||||
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
|
||||
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
|
||||
try {
|
||||
return await originalUpdateSyncData(
|
||||
toDeviceMessages,
|
||||
otkCounts,
|
||||
unusedFallbackKeyAlgs,
|
||||
safeChanged,
|
||||
safeLeft,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
|
||||
if (message.includes("Expect value to be String")) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
"Ignoring malformed device list entries during crypto sync",
|
||||
message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
35
extensions/matrix/src/matrix/client/logging.ts
Normal file
35
extensions/matrix/src/matrix/client/logging.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ConsoleLogger, LogService } from "matrix-bot-sdk";
|
||||
|
||||
let matrixSdkLoggingConfigured = false;
|
||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||
|
||||
function shouldSuppressMatrixHttpNotFound(
|
||||
module: string,
|
||||
messageOrObject: unknown[],
|
||||
): boolean {
|
||||
if (module !== "MatrixHttpClient") return false;
|
||||
return messageOrObject.some((entry) => {
|
||||
if (!entry || typeof entry !== "object") return false;
|
||||
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureMatrixSdkLoggingConfigured(): void {
|
||||
if (matrixSdkLoggingConfigured) return;
|
||||
matrixSdkLoggingConfigured = true;
|
||||
|
||||
LogService.setLogger({
|
||||
trace: (module, ...messageOrObject) =>
|
||||
matrixSdkBaseLogger.trace(module, ...messageOrObject),
|
||||
debug: (module, ...messageOrObject) =>
|
||||
matrixSdkBaseLogger.debug(module, ...messageOrObject),
|
||||
info: (module, ...messageOrObject) =>
|
||||
matrixSdkBaseLogger.info(module, ...messageOrObject),
|
||||
warn: (module, ...messageOrObject) =>
|
||||
matrixSdkBaseLogger.warn(module, ...messageOrObject),
|
||||
error: (module, ...messageOrObject) => {
|
||||
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return;
|
||||
matrixSdkBaseLogger.error(module, ...messageOrObject);
|
||||
},
|
||||
});
|
||||
}
|
||||
4
extensions/matrix/src/matrix/client/runtime.ts
Normal file
4
extensions/matrix/src/matrix/client/runtime.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function isBunRuntime(): boolean {
|
||||
const versions = process.versions as { bun?: string };
|
||||
return typeof versions.bun === "string";
|
||||
}
|
||||
169
extensions/matrix/src/matrix/client/shared.ts
Normal file
169
extensions/matrix/src/matrix/client/shared.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { LogService } from "matrix-bot-sdk";
|
||||
import type { MatrixClient } from "matrix-bot-sdk";
|
||||
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { createMatrixClient } from "./create-client.js";
|
||||
import { resolveMatrixAuth } from "./config.js";
|
||||
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
|
||||
import type { MatrixAuth } from "./types.js";
|
||||
|
||||
type SharedMatrixClientState = {
|
||||
client: MatrixClient;
|
||||
key: string;
|
||||
started: boolean;
|
||||
cryptoReady: boolean;
|
||||
};
|
||||
|
||||
let sharedClientState: SharedMatrixClientState | null = null;
|
||||
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
|
||||
let sharedClientStartPromise: Promise<void> | null = null;
|
||||
|
||||
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
|
||||
return [
|
||||
auth.homeserver,
|
||||
auth.userId,
|
||||
auth.accessToken,
|
||||
auth.encryption ? "e2ee" : "plain",
|
||||
accountId ?? DEFAULT_ACCOUNT_KEY,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
async function createSharedMatrixClient(params: {
|
||||
auth: MatrixAuth;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
}): Promise<SharedMatrixClientState> {
|
||||
const client = await createMatrixClient({
|
||||
homeserver: params.auth.homeserver,
|
||||
userId: params.auth.userId,
|
||||
accessToken: params.auth.accessToken,
|
||||
encryption: params.auth.encryption,
|
||||
localTimeoutMs: params.timeoutMs,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return {
|
||||
client,
|
||||
key: buildSharedClientKey(params.auth, params.accountId),
|
||||
started: false,
|
||||
cryptoReady: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSharedClientStarted(params: {
|
||||
state: SharedMatrixClientState;
|
||||
timeoutMs?: number;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
}): Promise<void> {
|
||||
if (params.state.started) return;
|
||||
if (sharedClientStartPromise) {
|
||||
await sharedClientStartPromise;
|
||||
return;
|
||||
}
|
||||
sharedClientStartPromise = (async () => {
|
||||
const client = params.state.client;
|
||||
|
||||
// Initialize crypto if enabled
|
||||
if (params.encryption && !params.state.cryptoReady) {
|
||||
try {
|
||||
const joinedRooms = await client.getJoinedRooms();
|
||||
if (client.crypto) {
|
||||
await client.crypto.prepare(joinedRooms);
|
||||
params.state.cryptoReady = true;
|
||||
}
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
|
||||
}
|
||||
}
|
||||
|
||||
await client.start();
|
||||
params.state.started = true;
|
||||
})();
|
||||
try {
|
||||
await sharedClientStartPromise;
|
||||
} finally {
|
||||
sharedClientStartPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSharedMatrixClient(
|
||||
params: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
auth?: MatrixAuth;
|
||||
startClient?: boolean;
|
||||
accountId?: string | null;
|
||||
} = {},
|
||||
): Promise<MatrixClient> {
|
||||
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
|
||||
const key = buildSharedClientKey(auth, params.accountId);
|
||||
const shouldStart = params.startClient !== false;
|
||||
|
||||
if (sharedClientState?.key === key) {
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: sharedClientState,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
encryption: auth.encryption,
|
||||
});
|
||||
}
|
||||
return sharedClientState.client;
|
||||
}
|
||||
|
||||
if (sharedClientPromise) {
|
||||
const pending = await sharedClientPromise;
|
||||
if (pending.key === key) {
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: pending,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
encryption: auth.encryption,
|
||||
});
|
||||
}
|
||||
return pending.client;
|
||||
}
|
||||
pending.client.stop();
|
||||
sharedClientState = null;
|
||||
sharedClientPromise = null;
|
||||
}
|
||||
|
||||
sharedClientPromise = createSharedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: params.timeoutMs,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
try {
|
||||
const created = await sharedClientPromise;
|
||||
sharedClientState = created;
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: created,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
encryption: auth.encryption,
|
||||
});
|
||||
}
|
||||
return created.client;
|
||||
} finally {
|
||||
sharedClientPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForMatrixSync(_params: {
|
||||
client: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// matrix-bot-sdk handles sync internally in start()
|
||||
// This is kept for API compatibility but is essentially a no-op now
|
||||
}
|
||||
|
||||
export function stopSharedClient(): void {
|
||||
if (sharedClientState) {
|
||||
sharedClientState.client.stop();
|
||||
sharedClientState = null;
|
||||
}
|
||||
}
|
||||
131
extensions/matrix/src/matrix/client/storage.ts
Normal file
131
extensions/matrix/src/matrix/client/storage.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { MatrixStoragePaths } from "./types.js";
|
||||
|
||||
export const DEFAULT_ACCOUNT_KEY = "default";
|
||||
const STORAGE_META_FILENAME = "storage-meta.json";
|
||||
|
||||
function sanitizePathSegment(value: string): string {
|
||||
const cleaned = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
return cleaned || "unknown";
|
||||
}
|
||||
|
||||
function resolveHomeserverKey(homeserver: string): string {
|
||||
try {
|
||||
const url = new URL(homeserver);
|
||||
if (url.host) return sanitizePathSegment(url.host);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return sanitizePathSegment(homeserver);
|
||||
}
|
||||
|
||||
function hashAccessToken(accessToken: string): string {
|
||||
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
} {
|
||||
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
return {
|
||||
storagePath: path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
cryptoPath: path.join(stateDir, "matrix", "crypto"),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixStoragePaths(params: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
accountId?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): MatrixStoragePaths {
|
||||
const env = params.env ?? process.env;
|
||||
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY);
|
||||
const userKey = sanitizePathSegment(params.userId);
|
||||
const serverKey = resolveHomeserverKey(params.homeserver);
|
||||
const tokenHash = hashAccessToken(params.accessToken);
|
||||
const rootDir = path.join(
|
||||
stateDir,
|
||||
"matrix",
|
||||
"accounts",
|
||||
accountKey,
|
||||
`${serverKey}__${userKey}`,
|
||||
tokenHash,
|
||||
);
|
||||
return {
|
||||
rootDir,
|
||||
storagePath: path.join(rootDir, "bot-storage.json"),
|
||||
cryptoPath: path.join(rootDir, "crypto"),
|
||||
metaPath: path.join(rootDir, STORAGE_META_FILENAME),
|
||||
accountKey,
|
||||
tokenHash,
|
||||
};
|
||||
}
|
||||
|
||||
export function maybeMigrateLegacyStorage(params: {
|
||||
storagePaths: MatrixStoragePaths;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): void {
|
||||
const legacy = resolveLegacyStoragePaths(params.env);
|
||||
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
|
||||
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
|
||||
const hasNewStorage =
|
||||
fs.existsSync(params.storagePaths.storagePath) ||
|
||||
fs.existsSync(params.storagePaths.cryptoPath);
|
||||
|
||||
if (!hasLegacyStorage && !hasLegacyCrypto) return;
|
||||
if (hasNewStorage) return;
|
||||
|
||||
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||
if (hasLegacyStorage) {
|
||||
try {
|
||||
fs.renameSync(legacy.storagePath, params.storagePaths.storagePath);
|
||||
} catch {
|
||||
// Ignore migration failures; new store will be created.
|
||||
}
|
||||
}
|
||||
if (hasLegacyCrypto) {
|
||||
try {
|
||||
fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath);
|
||||
} catch {
|
||||
// Ignore migration failures; new store will be created.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStorageMeta(params: {
|
||||
storagePaths: MatrixStoragePaths;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accountId?: string | null;
|
||||
}): void {
|
||||
try {
|
||||
const payload = {
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
|
||||
accessTokenHash: params.storagePaths.tokenHash,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
params.storagePaths.metaPath,
|
||||
JSON.stringify(payload, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
} catch {
|
||||
// ignore meta write failures
|
||||
}
|
||||
}
|
||||
34
extensions/matrix/src/matrix/client/types.ts
Normal file
34
extensions/matrix/src/matrix/client/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type MatrixResolvedConfig = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticated Matrix configuration.
|
||||
* Note: deviceId is NOT included here because it's implicit in the accessToken.
|
||||
* The crypto storage assumes the device ID (and thus access token) does not change
|
||||
* between restarts. If the access token becomes invalid or crypto storage is lost,
|
||||
* both will need to be recreated together.
|
||||
*/
|
||||
export type MatrixAuth = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
};
|
||||
|
||||
export type MatrixStoragePaths = {
|
||||
rootDir: string;
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
metaPath: string;
|
||||
accountKey: string;
|
||||
tokenHash: string;
|
||||
};
|
||||
@@ -8,6 +8,7 @@ export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId?: string;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
};
|
||||
@@ -94,5 +95,9 @@ export function credentialsMatchConfig(
|
||||
stored: MatrixStoredCredentials,
|
||||
config: { homeserver: string; userId: string },
|
||||
): boolean {
|
||||
// If userId is empty (token-based auth), only match homeserver
|
||||
if (!config.userId) {
|
||||
return stored.homeserver === config.homeserver;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.userId === config.userId;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user