Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af8ab49f13 | ||
|
|
dcafc78024 | ||
|
|
a1ebc99c7b | ||
|
|
ddc976004a |
No files matched your search
@@ -1 +1 @@
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext
|
||||
@@ -7,7 +7,6 @@
|
||||
- Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).
|
||||
- Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).
|
||||
- Tests: cover read tool image metadata + text output.
|
||||
- Tests: add queue mode coverage (collect/followup + directive parsing).
|
||||
|
||||
### Breaking
|
||||
- Skills config schema moved under `skills.*`:
|
||||
@@ -22,13 +21,10 @@
|
||||
- Config: remove `routing.groupChat.requireMention` + `telegram.requireMention`; use `whatsapp.groups`, `imessage.groups`, and `telegram.groups` defaults instead (run `clawdis doctor` to migrate).
|
||||
|
||||
### Features
|
||||
- Discord: expand `discord` tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.
|
||||
- Discord/Telegram: add reply tags (`[[reply_to_current]]`, `[[reply_to:<id>]]`) with per-provider `replyToMode` (off|first|all) for native threaded replies.
|
||||
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
||||
- Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.
|
||||
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
||||
- Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).
|
||||
- CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.
|
||||
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
|
||||
- Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via `*.groups` with `"*"` defaults; Discord now supports `discord.guilds."*"` as a default.
|
||||
- Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.
|
||||
@@ -49,7 +45,6 @@
|
||||
- CLI: add `configure`, `doctor`, and `update` wizards for ongoing setup, health checks, and modernization.
|
||||
- CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.
|
||||
- CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.
|
||||
- CLI: add `clawdis tui` gateway-connected terminal UI (local or remote).
|
||||
- Skills: allow `bun` as a node manager for skill installs.
|
||||
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme.
|
||||
- Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).
|
||||
@@ -100,7 +95,6 @@
|
||||
- macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.
|
||||
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
|
||||
- macOS codesign: include camera entitlement so permission prompts work in the menu bar app.
|
||||
- Agent tools: bash tool supports real TTY via `stdinMode: "pty"` with node-pty, warning + fallback on load/start failure.
|
||||
- Agent tools: map `camera.snap` JPEG payloads to `image/jpeg` to avoid MIME mismatch errors.
|
||||
- Tests: cover `camera.snap` MIME mapping to prevent image/png vs image/jpeg mismatches.
|
||||
- macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.
|
||||
|
||||
@@ -310,6 +310,12 @@ struct ConnectionsSettings: View {
|
||||
TextField("20", text: self.$store.discordHistoryLimit)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Reactions")
|
||||
Toggle("", isOn: self.$store.discordEnableReactions)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Slash command")
|
||||
Toggle("", isOn: self.$store.discordSlashEnabled)
|
||||
@@ -334,105 +340,6 @@ struct ConnectionsSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
Text("Tool actions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Reactions")
|
||||
Toggle("", isOn: self.$store.discordActionReactions)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Stickers")
|
||||
Toggle("", isOn: self.$store.discordActionStickers)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Polls")
|
||||
Toggle("", isOn: self.$store.discordActionPolls)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Permissions")
|
||||
Toggle("", isOn: self.$store.discordActionPermissions)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Messages")
|
||||
Toggle("", isOn: self.$store.discordActionMessages)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Threads")
|
||||
Toggle("", isOn: self.$store.discordActionThreads)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Pins")
|
||||
Toggle("", isOn: self.$store.discordActionPins)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Search")
|
||||
Toggle("", isOn: self.$store.discordActionSearch)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Member info")
|
||||
Toggle("", isOn: self.$store.discordActionMemberInfo)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Role info")
|
||||
Toggle("", isOn: self.$store.discordActionRoleInfo)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Channel info")
|
||||
Toggle("", isOn: self.$store.discordActionChannelInfo)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Voice status")
|
||||
Toggle("", isOn: self.$store.discordActionVoiceStatus)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Events")
|
||||
Toggle("", isOn: self.$store.discordActionEvents)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Role changes")
|
||||
Toggle("", isOn: self.$store.discordActionRoles)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Moderation")
|
||||
Toggle("", isOn: self.$store.discordActionModeration)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
|
||||
if self.isDiscordTokenLocked {
|
||||
Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.")
|
||||
.font(.caption)
|
||||
|
||||
@@ -174,21 +174,7 @@ final class ConnectionsStore {
|
||||
var discordGroupChannels: String = ""
|
||||
var discordMediaMaxMb: String = ""
|
||||
var discordHistoryLimit: String = ""
|
||||
var discordActionReactions = true
|
||||
var discordActionStickers = true
|
||||
var discordActionPolls = true
|
||||
var discordActionPermissions = true
|
||||
var discordActionMessages = true
|
||||
var discordActionThreads = true
|
||||
var discordActionPins = true
|
||||
var discordActionSearch = true
|
||||
var discordActionMemberInfo = true
|
||||
var discordActionRoleInfo = true
|
||||
var discordActionChannelInfo = true
|
||||
var discordActionVoiceStatus = true
|
||||
var discordActionEvents = true
|
||||
var discordActionRoles = false
|
||||
var discordActionModeration = false
|
||||
var discordEnableReactions = true
|
||||
var discordSlashEnabled = false
|
||||
var discordSlashName: String = ""
|
||||
var discordSlashSessionPrefix: String = ""
|
||||
@@ -434,22 +420,7 @@ final class ConnectionsStore {
|
||||
} else {
|
||||
self.discordHistoryLimit = ""
|
||||
}
|
||||
let discordActions = discord?["actions"]?.dictionaryValue
|
||||
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
|
||||
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
|
||||
self.discordActionPolls = discordActions?["polls"]?.boolValue ?? true
|
||||
self.discordActionPermissions = discordActions?["permissions"]?.boolValue ?? true
|
||||
self.discordActionMessages = discordActions?["messages"]?.boolValue ?? true
|
||||
self.discordActionThreads = discordActions?["threads"]?.boolValue ?? true
|
||||
self.discordActionPins = discordActions?["pins"]?.boolValue ?? true
|
||||
self.discordActionSearch = discordActions?["search"]?.boolValue ?? true
|
||||
self.discordActionMemberInfo = discordActions?["memberInfo"]?.boolValue ?? true
|
||||
self.discordActionRoleInfo = discordActions?["roleInfo"]?.boolValue ?? true
|
||||
self.discordActionChannelInfo = discordActions?["channelInfo"]?.boolValue ?? true
|
||||
self.discordActionVoiceStatus = discordActions?["voiceStatus"]?.boolValue ?? true
|
||||
self.discordActionEvents = discordActions?["events"]?.boolValue ?? true
|
||||
self.discordActionRoles = discordActions?["roles"]?.boolValue ?? false
|
||||
self.discordActionModeration = discordActions?["moderation"]?.boolValue ?? false
|
||||
self.discordEnableReactions = discord?["enableReactions"]?.boolValue ?? true
|
||||
let slash = discord?["slashCommand"]?.dictionaryValue
|
||||
self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false
|
||||
self.discordSlashName = slash?["name"]?.stringValue ?? ""
|
||||
@@ -673,33 +644,10 @@ final class ConnectionsStore {
|
||||
discord.removeValue(forKey: "historyLimit")
|
||||
}
|
||||
|
||||
var actions: [String: Any] = (discord["actions"] as? [String: Any]) ?? [:]
|
||||
func setAction(_ key: String, value: Bool, defaultValue: Bool) {
|
||||
if value == defaultValue {
|
||||
actions.removeValue(forKey: key)
|
||||
} else {
|
||||
actions[key] = value
|
||||
}
|
||||
}
|
||||
setAction("reactions", value: self.discordActionReactions, defaultValue: true)
|
||||
setAction("stickers", value: self.discordActionStickers, defaultValue: true)
|
||||
setAction("polls", value: self.discordActionPolls, defaultValue: true)
|
||||
setAction("permissions", value: self.discordActionPermissions, defaultValue: true)
|
||||
setAction("messages", value: self.discordActionMessages, defaultValue: true)
|
||||
setAction("threads", value: self.discordActionThreads, defaultValue: true)
|
||||
setAction("pins", value: self.discordActionPins, defaultValue: true)
|
||||
setAction("search", value: self.discordActionSearch, defaultValue: true)
|
||||
setAction("memberInfo", value: self.discordActionMemberInfo, defaultValue: true)
|
||||
setAction("roleInfo", value: self.discordActionRoleInfo, defaultValue: true)
|
||||
setAction("channelInfo", value: self.discordActionChannelInfo, defaultValue: true)
|
||||
setAction("voiceStatus", value: self.discordActionVoiceStatus, defaultValue: true)
|
||||
setAction("events", value: self.discordActionEvents, defaultValue: true)
|
||||
setAction("roles", value: self.discordActionRoles, defaultValue: false)
|
||||
setAction("moderation", value: self.discordActionModeration, defaultValue: false)
|
||||
if actions.isEmpty {
|
||||
discord.removeValue(forKey: "actions")
|
||||
if self.discordEnableReactions {
|
||||
discord.removeValue(forKey: "enableReactions")
|
||||
} else {
|
||||
discord["actions"] = actions
|
||||
discord["enableReactions"] = false
|
||||
}
|
||||
|
||||
var slash: [String: Any] = (discord["slashCommand"] as? [String: Any]) ?? [:]
|
||||
|
||||
@@ -94,7 +94,6 @@ git commit -m "Add Clawd workspace"
|
||||
- **eightctl** — Control your sleep, from the terminal.
|
||||
- **imsg** — Send, read, stream iMessage & SMS.
|
||||
- **wacli** — WhatsApp CLI: sync, search, send.
|
||||
- **discord** — Discord actions: react, stickers, polls.
|
||||
- **gog** — Google Suite CLI: Gmail, Calendar, Drive, Contacts.
|
||||
- **spotify-player** — Terminal Spotify client to search/queue/control playback.
|
||||
- **sag** — ElevenLabs speech with mac-style say UX; streams to speakers by default.
|
||||
|
||||
+1
-10
@@ -70,16 +70,7 @@ Legacy Pi/Tau session folders are **not** read.
|
||||
|
||||
## Steering while streaming
|
||||
|
||||
When queue mode is `steer`, inbound messages are injected into the current run.
|
||||
The queue is checked **after each tool call**; if a queued message is present,
|
||||
remaining tool calls from the current assistant message are skipped (error tool
|
||||
results with "Skipped due to queued user message."), then the queued user
|
||||
message is injected before the next assistant response.
|
||||
|
||||
When queue mode is `followup` or `collect`, inbound messages are held until the
|
||||
current turn ends, then a new agent turn starts with the queued payloads. See
|
||||
`docs/queue.md` for mode + debounce/cap behavior.
|
||||
|
||||
Incoming user messages are queued while the agent is streaming. The queue is checked **after each tool call**. If a queued message is present, remaining tool calls from the current assistant message are skipped (error tool results with "Skipped due to queued user message."), then the queued user message is injected before the next assistant response.
|
||||
Block streaming sends completed assistant blocks as soon as they finish; disable
|
||||
via `agent.blockStreamingDefault: "off"` if you only want the final response.
|
||||
Tune the boundary via `agent.blockStreamingBreak` (`text_end` vs `message_end`).
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
summary: "Bash tool usage, stdin modes, and TTY support"
|
||||
read_when:
|
||||
- Using or modifying the bash tool
|
||||
- Debugging stdin or TTY behavior
|
||||
---
|
||||
|
||||
# Bash tool
|
||||
|
||||
Run shell commands in the workspace. Supports foreground + background execution via `process`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `command` (required)
|
||||
- `yieldMs` (default 20000): auto-background after delay
|
||||
- `background` (bool): background immediately
|
||||
- `timeout` (seconds, default 1800): kill on expiry
|
||||
- `stdinMode` (`pipe` | `pty`):
|
||||
- `pipe` (default): classic stdin/stdout/stderr pipes
|
||||
- `pty`: real TTY via node-pty (merged stdout/stderr)
|
||||
|
||||
## TTY mode (`stdinMode: "pty"`)
|
||||
|
||||
- Uses node-pty if available. If node-pty fails to load/start, the tool warns and falls back to `pipe`.
|
||||
- Output streams are merged (no separate stderr).
|
||||
- `process write` sends raw input; `eof: true` sends Ctrl-D (`\x04`).
|
||||
|
||||
## Examples
|
||||
|
||||
Foreground:
|
||||
```json
|
||||
{"tool":"bash","command":"ls -la"}
|
||||
```
|
||||
|
||||
Background + poll:
|
||||
```json
|
||||
{"tool":"bash","command":"npm run build","yieldMs":1000}
|
||||
{"tool":"process","action":"poll","sessionId":"<id>"}
|
||||
```
|
||||
|
||||
TTY command:
|
||||
```json
|
||||
{"tool":"bash","command":"htop","stdinMode":"pty","background":true}
|
||||
```
|
||||
+7
-32
@@ -156,16 +156,13 @@ Controls how inbound messages behave when an agent run is already active.
|
||||
{
|
||||
routing: {
|
||||
queue: {
|
||||
mode: "collect", // steer | followup | collect | steer-backlog (steer+backlog ok) | interrupt (queue=steer legacy)
|
||||
debounceMs: 1000,
|
||||
cap: 20,
|
||||
drop: "summarize", // old | new | summarize
|
||||
mode: "interrupt", // global default: queue | interrupt
|
||||
bySurface: {
|
||||
whatsapp: "collect",
|
||||
telegram: "collect",
|
||||
discord: "steer-backlog",
|
||||
imessage: "collect",
|
||||
webchat: "collect"
|
||||
whatsapp: "interrupt",
|
||||
telegram: "interrupt",
|
||||
discord: "queue",
|
||||
imessage: "interrupt",
|
||||
webchat: "queue"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,24 +221,7 @@ Configure the Discord bot by setting the bot token and optional gating:
|
||||
enabled: true,
|
||||
token: "your-bot-token",
|
||||
mediaMaxMb: 8, // clamp inbound media size
|
||||
actions: { // tool action gates (false disables)
|
||||
reactions: true,
|
||||
stickers: true,
|
||||
polls: true,
|
||||
permissions: true,
|
||||
messages: true,
|
||||
threads: true,
|
||||
pins: true,
|
||||
search: true,
|
||||
memberInfo: true,
|
||||
roleInfo: true,
|
||||
roles: false,
|
||||
channelInfo: true,
|
||||
voiceStatus: true,
|
||||
events: true,
|
||||
moderation: false
|
||||
},
|
||||
replyToMode: "off", // off | first | all
|
||||
enableReactions: true, // allow agent-triggered reactions
|
||||
slashCommand: { // user-installed app slash commands
|
||||
enabled: true,
|
||||
name: "clawd",
|
||||
@@ -730,11 +710,6 @@ Gmail helper config (used by `clawdis hooks gmail setup` / `run`):
|
||||
}
|
||||
```
|
||||
|
||||
Gateway auto-start:
|
||||
- If `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts
|
||||
`gog gmail watch serve` on boot and auto-renews the watch.
|
||||
- Set `CLAWDIS_SKIP_GMAIL_WATCHER=1` to disable the auto-start (for manual runs).
|
||||
|
||||
Note: when `tailscale.mode` is on, Clawdis defaults `serve.path` to `/` so
|
||||
Tailscale can proxy `/gmail-pubsub` correctly (it strips the set-path prefix).
|
||||
|
||||
|
||||
+7
-54
@@ -27,7 +27,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
||||
8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
|
||||
9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists.
|
||||
10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
|
||||
11. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`).
|
||||
11. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool.
|
||||
12. Slash commands use isolated session keys (`${sessionPrefix}:${userId}`) rather than the shared `main` session.
|
||||
|
||||
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
||||
@@ -50,23 +50,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
|
||||
enabled: true,
|
||||
token: "abc.123",
|
||||
mediaMaxMb: 8,
|
||||
actions: {
|
||||
reactions: true,
|
||||
stickers: true,
|
||||
polls: true,
|
||||
permissions: true,
|
||||
messages: true,
|
||||
threads: true,
|
||||
pins: true,
|
||||
search: true,
|
||||
memberInfo: true,
|
||||
roleInfo: true,
|
||||
roles: false,
|
||||
channelInfo: true,
|
||||
voiceStatus: true,
|
||||
events: true,
|
||||
moderation: false
|
||||
},
|
||||
enableReactions: true,
|
||||
replyToMode: "off",
|
||||
slashCommand: {
|
||||
enabled: true,
|
||||
@@ -109,32 +93,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
|
||||
- `slashCommand`: optional config for user-installed slash commands (ephemeral responses).
|
||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
||||
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
||||
- `reactions` (covers react + read reactions)
|
||||
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
||||
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
||||
- `roles` (role add/remove, default `false`)
|
||||
- `moderation` (timeout/kick/ban, default `false`)
|
||||
|
||||
### Tool action defaults
|
||||
|
||||
| Action group | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| reactions | enabled | React + list reactions + emojiList |
|
||||
| stickers | enabled | Send stickers |
|
||||
| polls | enabled | Create polls |
|
||||
| permissions | enabled | Channel permission snapshot |
|
||||
| messages | enabled | Read/send/edit/delete |
|
||||
| threads | enabled | Create/list/reply |
|
||||
| pins | enabled | Pin/unpin/list |
|
||||
| search | enabled | Message search (preview spec) |
|
||||
| memberInfo | enabled | Member info |
|
||||
| roleInfo | enabled | Role list |
|
||||
| channelInfo | enabled | Channel info + list |
|
||||
| voiceStatus | enabled | Voice state lookup |
|
||||
| events | enabled | List/create scheduled events |
|
||||
| roles | disabled | Role add/remove |
|
||||
| moderation | disabled | Timeout/kick/ban |
|
||||
- `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`).
|
||||
- `replyToMode`: `off` (default), `first`, or `all`. Applies only when the model includes a reply tag.
|
||||
|
||||
## Reply tags
|
||||
@@ -160,16 +119,10 @@ Slash command notes:
|
||||
- Slash commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules).
|
||||
- Clawdis will auto-register `/clawd` (or the configured name) if it doesn't already exist.
|
||||
|
||||
## Tool actions
|
||||
The agent can call `discord` with actions like:
|
||||
- `react` / `reactions` (add or list reactions)
|
||||
- `sticker`, `poll`, `permissions`
|
||||
- `readMessages`, `sendMessage`, `editMessage`, `deleteMessage`
|
||||
- `threadCreate`, `threadList`, `threadReply`
|
||||
- `pinMessage`, `unpinMessage`, `listPins`
|
||||
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
|
||||
- `channelInfo`, `channelList`, `voiceStatus`, `eventList`, `eventCreate`
|
||||
- `timeout`, `kick`, `ban`
|
||||
## Reactions
|
||||
When `discord.enableReactions = true`, the agent can call `clawdis_discord` with:
|
||||
- `action: "react"`
|
||||
- `channelId`, `messageId`, `emoji`
|
||||
|
||||
Discord message ids are surfaced in the injected context (`[discord message id: …]` and history lines) so the agent can target them.
|
||||
Emoji can be unicode (e.g., `✅`) or custom emoji syntax like `<:party_blob:1234567890>`.
|
||||
|
||||
@@ -56,12 +56,7 @@ Want a custom endpoint? Use `--push-endpoint <url>` or `--tailscale off`.
|
||||
Platform note: on macOS the wizard installs `gcloud`, `gogcli`, and `tailscale`
|
||||
via Homebrew; on Linux install them manually first.
|
||||
|
||||
Gateway auto-start (recommended):
|
||||
- When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts
|
||||
`gog gmail watch serve` on boot and auto-renews the watch.
|
||||
- Set `CLAWDIS_SKIP_GMAIL_WATCHER=1` to opt out (useful if you run the daemon yourself).
|
||||
|
||||
Manual daemon (starts `gog gmail watch serve` + auto-renew):
|
||||
Run the daemon (starts `gog gmail watch serve` + auto-renew):
|
||||
|
||||
```bash
|
||||
clawdis hooks gmail run
|
||||
|
||||
+8
-24
@@ -3,7 +3,7 @@ summary: "Command queue design that serializes auto-reply command execution"
|
||||
read_when:
|
||||
- Changing auto-reply execution or concurrency
|
||||
---
|
||||
# Command Queue (2026-01-03)
|
||||
# Command Queue (2025-11-25)
|
||||
|
||||
We now serialize command-based auto-replies (WhatsApp Web listener) through a tiny in-process queue to prevent multiple commands from running at once, while allowing safe parallelism across sessions.
|
||||
|
||||
@@ -19,16 +19,13 @@ We now serialize command-based auto-replies (WhatsApp Web listener) through a ti
|
||||
- Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn.
|
||||
|
||||
## Queue modes (per surface)
|
||||
Inbound messages can steer the current run, wait for a followup turn, or do both:
|
||||
- `steer`: inject immediately into the current run (cancels pending tool calls after the next tool boundary). If not streaming, falls back to followup.
|
||||
- `followup`: enqueue for the next agent turn after the current run ends.
|
||||
- `collect`: coalesce all queued messages into a **single** followup turn (default).
|
||||
- `steer-backlog` (aka `steer+backlog`): steer now **and** preserve the message for a followup turn.
|
||||
- `interrupt` (legacy): abort the active run for that session, then run the newest message.
|
||||
- `queue` (legacy alias): same as `steer`.
|
||||
Inbound messages can either queue or interrupt when a run is already active:
|
||||
- `queue`: serialize per session; if the agent is streaming, the new message is appended to the current run.
|
||||
- `interrupt`: abort the active run for that session, then run the newest message.
|
||||
|
||||
Defaults (when unset in config):
|
||||
- All surfaces → `collect`
|
||||
- WhatsApp + Telegram → `interrupt`
|
||||
- Discord + WebChat → `queue`
|
||||
|
||||
Configure globally or per surface via `routing.queue`:
|
||||
|
||||
@@ -36,29 +33,16 @@ Configure globally or per surface via `routing.queue`:
|
||||
{
|
||||
routing: {
|
||||
queue: {
|
||||
mode: "collect",
|
||||
debounceMs: 1000,
|
||||
cap: 20,
|
||||
drop: "summarize",
|
||||
bySurface: { discord: "steer-backlog" }
|
||||
mode: "interrupt",
|
||||
bySurface: { discord: "queue", telegram: "interrupt" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Queue options
|
||||
Options apply to `followup`, `collect`, and `steer-backlog` (and to `steer` when it falls back to followup):
|
||||
- `debounceMs`: wait for quiet before starting a followup turn (prevents “continue, continue”).
|
||||
- `cap`: max queued messages per session.
|
||||
- `drop`: overflow policy (`old`, `new`, `summarize`).
|
||||
|
||||
Summarize keeps a short bullet list of dropped messages and injects it as a synthetic followup prompt.
|
||||
Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`.
|
||||
|
||||
## Per-session overrides
|
||||
- `/queue <mode>` as a standalone command stores the mode for the current session.
|
||||
- `/queue <mode>` embedded in a message applies **once** (no persistence).
|
||||
- Options can be combined: `/queue collect debounce:2s cap:25 drop:summarize`
|
||||
- `/queue default` or `/queue reset` clears the session override.
|
||||
|
||||
## Scope and guarantees
|
||||
|
||||
@@ -105,45 +105,6 @@ Core actions:
|
||||
Notes:
|
||||
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
|
||||
|
||||
### `discord`
|
||||
Send Discord reactions, stickers, or polls.
|
||||
|
||||
Core actions:
|
||||
- `react` (`channelId`, `messageId`, `emoji`)
|
||||
- `reactions` (`channelId`, `messageId`, optional `limit`)
|
||||
- `sticker` (`to`, `stickerIds`, optional `content`)
|
||||
- `poll` (`to`, `question`, `answers`, optional `allowMultiselect`, `durationHours`, `content`)
|
||||
- `permissions` (`channelId`)
|
||||
- `readMessages` (`channelId`, optional `limit`/`before`/`after`/`around`)
|
||||
- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyTo`)
|
||||
- `editMessage` (`channelId`, `messageId`, `content`)
|
||||
- `deleteMessage` (`channelId`, `messageId`)
|
||||
- `threadCreate` (`channelId`, `name`, optional `messageId`, `autoArchiveMinutes`)
|
||||
- `threadList` (`guildId`, optional `channelId`, `includeArchived`, `before`, `limit`)
|
||||
- `threadReply` (`channelId`, `content`, optional `mediaUrl`, `replyTo`)
|
||||
- `pinMessage`/`unpinMessage` (`channelId`, `messageId`)
|
||||
- `listPins` (`channelId`)
|
||||
- `searchMessages` (`guildId`, `content`, optional `channelId`/`channelIds`, `authorId`/`authorIds`, `limit`)
|
||||
- `memberInfo` (`guildId`, `userId`)
|
||||
- `roleInfo` (`guildId`)
|
||||
- `emojiList` (`guildId`)
|
||||
- `roleAdd`/`roleRemove` (`guildId`, `userId`, `roleId`)
|
||||
- `channelInfo` (`channelId`)
|
||||
- `channelList` (`guildId`)
|
||||
- `voiceStatus` (`guildId`, `userId`)
|
||||
- `eventList` (`guildId`)
|
||||
- `eventCreate` (`guildId`, `name`, `startTime`, optional `endTime`, `description`, `channelId`, `entityType`, `location`)
|
||||
- `timeout` (`guildId`, `userId`, optional `durationMinutes`, `until`, `reason`)
|
||||
- `kick` (`guildId`, `userId`, optional `reason`)
|
||||
- `ban` (`guildId`, `userId`, optional `reason`, `deleteMessageDays`)
|
||||
|
||||
Notes:
|
||||
- `to` accepts `channel:<id>` or `user:<id>`.
|
||||
- Polls require 2–10 answers and default to 24 hours.
|
||||
- `reactions` returns per-emoji user lists (limited to 100 per reaction).
|
||||
- `discord.actions.*` gates Discord tool actions; `roles` + `moderation` default to `false`.
|
||||
- `searchMessages` follows the Discord preview spec (limit max 25, channel/author filters accept arrays).
|
||||
|
||||
## Parameters (common)
|
||||
|
||||
Gateway-backed tools (`clawdis_canvas`, `clawdis_nodes`, `clawdis_cron`):
|
||||
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
---
|
||||
summary: "Terminal UI (TUI) for Clawdis via the Gateway"
|
||||
read_when:
|
||||
- You want a terminal UI that connects to the Gateway from any machine
|
||||
- You are debugging the TUI client or Gateway chat stream
|
||||
---
|
||||
# TUI (Gateway chat client)
|
||||
|
||||
Updated: 2026-01-03
|
||||
|
||||
## What it is
|
||||
- A terminal UI that connects to the Gateway WebSocket and speaks the same chat APIs as WebChat.
|
||||
- Works locally (loopback) or remotely (Tailscale/SSH tunnel) without running a separate agent process.
|
||||
|
||||
## Run
|
||||
```bash
|
||||
clawdis tui
|
||||
```
|
||||
|
||||
### Remote
|
||||
```bash
|
||||
clawdis tui --url ws://127.0.0.1:18789 --token <gateway-token>
|
||||
```
|
||||
Use SSH tunneling or Tailscale to reach the Gateway WS.
|
||||
|
||||
## Options
|
||||
- `--url <url>`: Gateway WebSocket URL (defaults to config `gateway.remote.url` or `ws://127.0.0.1:18789`).
|
||||
- `--token <token>`: Gateway token (if required).
|
||||
- `--password <password>`: Gateway password (if required).
|
||||
- `--session <key>`: Session key (default: `session.mainKey` or `main`).
|
||||
- `--deliver`: Deliver assistant replies to the provider.
|
||||
- `--thinking <level>`: Override thinking level for sends.
|
||||
- `--timeout-ms <ms>`: Agent timeout in ms (default 30000).
|
||||
- `--history-limit <n>`: History entries to load (default 200).
|
||||
|
||||
## Controls
|
||||
- Enter: send message
|
||||
- Esc: abort active run
|
||||
- Ctrl+C: exit
|
||||
|
||||
## Slash commands
|
||||
- `/help`
|
||||
- `/session <key>`
|
||||
- `/abort`
|
||||
- `/exit`
|
||||
|
||||
## Notes
|
||||
- The TUI shows Gateway chat deltas (`event: chat`) and final responses.
|
||||
- It registers as a Gateway client with `mode: "tui"` for presence and debugging.
|
||||
|
||||
## Files
|
||||
- CLI: `src/cli/tui-cli.ts`
|
||||
- Runner: `src/tui/tui.ts`
|
||||
- Gateway client: `src/tui/gateway-chat.ts`
|
||||
@@ -75,7 +75,6 @@
|
||||
"@mariozechner/pi-agent-core": "^0.31.1",
|
||||
"@mariozechner/pi-ai": "^0.31.1",
|
||||
"@mariozechner/pi-coding-agent": "^0.31.1",
|
||||
"@mariozechner/pi-tui": "^0.31.1",
|
||||
"@sinclair/typebox": "0.34.46",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"ajv": "^8.17.1",
|
||||
|
||||
@@ -1,91 +1,5 @@
|
||||
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
|
||||
index ff9cbcfebfac6b4370d85dc838f5cacf2a60ed64..42096c82aec925b412258348a36ba4a7025b283b 100644
|
||||
--- a/dist/providers/google-shared.js
|
||||
+++ b/dist/providers/google-shared.js
|
||||
@@ -140,6 +140,71 @@ export function convertMessages(model, context) {
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
+/**
|
||||
+ * Sanitize JSON Schema for Google Cloud Code Assist API.
|
||||
+ * Removes unsupported keywords like patternProperties, const, anyOf, etc.
|
||||
+ * and converts to a format compatible with Google's function declarations.
|
||||
+ */
|
||||
+function sanitizeSchemaForGoogle(schema) {
|
||||
+ if (!schema || typeof schema !== 'object') {
|
||||
+ return schema;
|
||||
+ }
|
||||
+ // If it's an array, sanitize each element
|
||||
+ if (Array.isArray(schema)) {
|
||||
+ return schema.map(item => sanitizeSchemaForGoogle(item));
|
||||
+ }
|
||||
+ const sanitized = {};
|
||||
+ // List of unsupported JSON Schema keywords that Google's API doesn't understand
|
||||
+ const unsupportedKeywords = [
|
||||
+ 'patternProperties',
|
||||
+ 'const',
|
||||
+ 'anyOf',
|
||||
+ 'oneOf',
|
||||
+ 'allOf',
|
||||
+ 'not',
|
||||
+ '$schema',
|
||||
+ '$id',
|
||||
+ '$ref',
|
||||
+ '$defs',
|
||||
+ 'definitions',
|
||||
+ 'if',
|
||||
+ 'then',
|
||||
+ 'else',
|
||||
+ 'dependentSchemas',
|
||||
+ 'dependentRequired',
|
||||
+ 'unevaluatedProperties',
|
||||
+ 'unevaluatedItems',
|
||||
+ 'contentEncoding',
|
||||
+ 'contentMediaType',
|
||||
+ 'contentSchema',
|
||||
+ 'deprecated',
|
||||
+ 'readOnly',
|
||||
+ 'writeOnly',
|
||||
+ 'examples',
|
||||
+ '$comment',
|
||||
+ 'additionalProperties',
|
||||
+ ];
|
||||
+ // TODO(steipete): lossy schema scrub; revisit when Google supports these keywords.
|
||||
+ for (const [key, value] of Object.entries(schema)) {
|
||||
+ // Skip unsupported keywords
|
||||
+ if (unsupportedKeywords.includes(key)) {
|
||||
+ continue;
|
||||
+ }
|
||||
+ // Recursively sanitize nested objects
|
||||
+ if (key === 'properties' && typeof value === 'object' && value !== null) {
|
||||
+ sanitized[key] = {};
|
||||
+ for (const [propKey, propValue] of Object.entries(value)) {
|
||||
+ sanitized[key][propKey] = sanitizeSchemaForGoogle(propValue);
|
||||
+ }
|
||||
+ } else if (key === 'items' && typeof value === 'object') {
|
||||
+ sanitized[key] = sanitizeSchemaForGoogle(value);
|
||||
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
+ sanitized[key] = sanitizeSchemaForGoogle(value);
|
||||
+ } else {
|
||||
+ sanitized[key] = value;
|
||||
+ }
|
||||
+ }
|
||||
+ return sanitized;
|
||||
+}
|
||||
/**
|
||||
* Convert tools to Gemini function declarations format.
|
||||
*/
|
||||
@@ -151,7 +216,7 @@ export function convertTools(tools) {
|
||||
functionDeclarations: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
- parameters: tool.parameters,
|
||||
+ parameters: sanitizeSchemaForGoogle(tool.parameters),
|
||||
})),
|
||||
},
|
||||
];
|
||||
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
|
||||
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..31bae0aface1319487ce62d35f1f3b6ed334863e 100644
|
||||
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..c2bc63f483f3285b00755901ba97db810221cea6 100644
|
||||
--- a/dist/providers/openai-responses.js
|
||||
+++ b/dist/providers/openai-responses.js
|
||||
@@ -486,7 +486,6 @@ function convertTools(tools) {
|
||||
|
||||
Generated
+5
-8
@@ -9,7 +9,7 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
'@mariozechner/pi-ai':
|
||||
hash: 717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4
|
||||
hash: bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832
|
||||
path: patches/@mariozechner__pi-ai.patch
|
||||
'@mariozechner/pi-coding-agent@0.31.1':
|
||||
hash: d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745
|
||||
@@ -33,13 +33,10 @@ importers:
|
||||
version: 0.31.1(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.31.1
|
||||
version: 0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)
|
||||
version: 0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.31.1
|
||||
version: 0.31.1(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-tui':
|
||||
specifier: ^0.31.1
|
||||
version: 0.31.1
|
||||
'@sinclair/typebox':
|
||||
specifier: 0.34.46
|
||||
version: 0.34.46
|
||||
@@ -3350,7 +3347,7 @@ snapshots:
|
||||
|
||||
'@mariozechner/pi-agent-core@0.31.1(ws@8.18.3)(zod@4.3.4)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai': 0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-tui': 0.31.1
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
@@ -3360,7 +3357,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)':
|
||||
'@mariozechner/pi-ai@0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.4)
|
||||
'@google/genai': 1.34.0
|
||||
@@ -3383,7 +3380,7 @@ snapshots:
|
||||
'@mariozechner/pi-coding-agent@0.31.1(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-agent-core': 0.31.1(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai': 0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai': 0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-tui': 0.31.1
|
||||
chalk: 5.6.2
|
||||
cli-highlight: 2.1.11
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
---
|
||||
name: discord
|
||||
description: Use when you need to control Discord from Clawdis via the discord tool: send messages, react, post stickers, run polls, manage threads/pins/search, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
|
||||
---
|
||||
|
||||
# Discord Actions
|
||||
|
||||
## Overview
|
||||
|
||||
Use `discord` to manage messages, reactions, threads, polls, and moderation. You can disable groups via `discord.actions.*` (defaults to enabled, except roles/moderation). The tool uses the bot token configured for Clawdis.
|
||||
|
||||
## Inputs to collect
|
||||
|
||||
- For reactions: `channelId`, `messageId`, and an `emoji`.
|
||||
- For stickers/polls: a `to` target (`channel:<id>` or `user:<id>`). Optional `content` text.
|
||||
- Polls also need a `question` plus 2–10 `answers`.
|
||||
|
||||
Message context lines include `discord message id` and `channel` fields you can reuse directly.
|
||||
|
||||
## Actions
|
||||
|
||||
### React to a message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "react",
|
||||
"channelId": "123",
|
||||
"messageId": "456",
|
||||
"emoji": "✅"
|
||||
}
|
||||
```
|
||||
|
||||
### List reactions + users
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "reactions",
|
||||
"channelId": "123",
|
||||
"messageId": "456",
|
||||
"limit": 100
|
||||
}
|
||||
```
|
||||
|
||||
### Send a sticker
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "sticker",
|
||||
"to": "channel:123",
|
||||
"stickerIds": ["9876543210"],
|
||||
"content": "Nice work!"
|
||||
}
|
||||
```
|
||||
|
||||
- Up to 3 sticker IDs per message.
|
||||
- `to` can be `user:<id>` for DMs.
|
||||
|
||||
### Create a poll
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "poll",
|
||||
"to": "channel:123",
|
||||
"question": "Lunch?",
|
||||
"answers": ["Pizza", "Sushi", "Salad"],
|
||||
"allowMultiselect": false,
|
||||
"durationHours": 24,
|
||||
"content": "Vote now"
|
||||
}
|
||||
```
|
||||
|
||||
- `durationHours` defaults to 24; max 32 days (768 hours).
|
||||
|
||||
### Check bot permissions for a channel
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "permissions",
|
||||
"channelId": "123"
|
||||
}
|
||||
```
|
||||
|
||||
## Ideas to try
|
||||
|
||||
- React with ✅/⚠️ to mark status updates.
|
||||
- Post a quick poll for release decisions or meeting times.
|
||||
- Send celebratory stickers after successful deploys.
|
||||
- Run weekly “priority check” polls in team channels.
|
||||
- DM stickers as acknowledgements when a user’s request is completed.
|
||||
|
||||
## Action gating
|
||||
|
||||
Use `discord.actions.*` to disable action groups:
|
||||
- `reactions` (react + reactions list + emojiList)
|
||||
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
||||
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
||||
- `roles` (role add/remove, default `false`)
|
||||
- `moderation` (timeout/kick/ban, default `false`)
|
||||
### Read recent messages
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "readMessages",
|
||||
"channelId": "123",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
### Send/edit/delete a message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "sendMessage",
|
||||
"to": "channel:123",
|
||||
"content": "Hello from Clawdis"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "editMessage",
|
||||
"channelId": "123",
|
||||
"messageId": "456",
|
||||
"content": "Fixed typo"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "deleteMessage",
|
||||
"channelId": "123",
|
||||
"messageId": "456"
|
||||
}
|
||||
```
|
||||
|
||||
### Threads
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "threadCreate",
|
||||
"channelId": "123",
|
||||
"name": "Bug triage",
|
||||
"messageId": "456"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "threadList",
|
||||
"guildId": "999"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "threadReply",
|
||||
"channelId": "777",
|
||||
"content": "Replying in thread"
|
||||
}
|
||||
```
|
||||
|
||||
### Pins
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "pinMessage",
|
||||
"channelId": "123",
|
||||
"messageId": "456"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "listPins",
|
||||
"channelId": "123"
|
||||
}
|
||||
```
|
||||
|
||||
### Search messages
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "searchMessages",
|
||||
"guildId": "999",
|
||||
"content": "release notes",
|
||||
"channelIds": ["123", "456"],
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
### Member + role info
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "memberInfo",
|
||||
"guildId": "999",
|
||||
"userId": "111"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "roleInfo",
|
||||
"guildId": "999"
|
||||
}
|
||||
```
|
||||
|
||||
### List available custom emojis
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "emojiList",
|
||||
"guildId": "999"
|
||||
}
|
||||
```
|
||||
|
||||
### Role changes (disabled by default)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "roleAdd",
|
||||
"guildId": "999",
|
||||
"userId": "111",
|
||||
"roleId": "222"
|
||||
}
|
||||
```
|
||||
|
||||
### Channel info
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "channelInfo",
|
||||
"channelId": "123"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "channelList",
|
||||
"guildId": "999"
|
||||
}
|
||||
```
|
||||
|
||||
### Voice status
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "voiceStatus",
|
||||
"guildId": "999",
|
||||
"userId": "111"
|
||||
}
|
||||
```
|
||||
|
||||
### Scheduled events
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "eventList",
|
||||
"guildId": "999"
|
||||
}
|
||||
```
|
||||
|
||||
### Moderation (disabled by default)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "timeout",
|
||||
"guildId": "999",
|
||||
"userId": "111",
|
||||
"durationMinutes": 10
|
||||
}
|
||||
```
|
||||
@@ -41,22 +41,3 @@ Voice defaults
|
||||
- `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`
|
||||
|
||||
Confirm voice + speaker before long output.
|
||||
|
||||
## Chat voice responses
|
||||
|
||||
When Peter asks for a "voice" reply (e.g., "crazy scientist voice", "explain in voice"), generate audio and send it:
|
||||
|
||||
```bash
|
||||
# Generate audio file
|
||||
sag -v Clawd -o /tmp/voice-reply.mp3 "Your message here"
|
||||
|
||||
# Then include in reply:
|
||||
# MEDIA:/tmp/voice-reply.mp3
|
||||
```
|
||||
|
||||
Voice character tips:
|
||||
- Crazy scientist: Use `[excited]` tags, dramatic pauses `[short pause]`, vary intensity
|
||||
- Calm: Use `[whispers]` or slower pacing
|
||||
- Dramatic: Use `[sings]` or `[shouts]` sparingly
|
||||
|
||||
Default voice for Clawd: `lj2rcrvANS3gaWWnczSX` (or just `-v Clawd`)
|
||||
@@ -1,28 +1,19 @@
|
||||
---
|
||||
name: spotify-player
|
||||
description: Terminal Spotify playback/search via spogo (preferred) or spotify_player.
|
||||
description: Terminal Spotify client (TUI + CLI commands) for playback and search.
|
||||
homepage: https://www.spotify.com
|
||||
metadata: {"clawdis":{"emoji":"🎵","requires":{"anyBins":["spogo","spotify_player"]},"install":[{"id":"brew","kind":"brew","formula":"spogo","tap":"steipete/tap","bins":["spogo"],"label":"Install spogo (brew)"},{"id":"brew","kind":"brew","formula":"spotify_player","bins":["spotify_player"],"label":"Install spotify_player (brew)"}]}}
|
||||
metadata: {"clawdis":{"emoji":"🎵","requires":{"bins":["spotify_player"]},"install":[{"id":"brew","kind":"brew","formula":"spotify_player","bins":["spotify_player"],"label":"Install spotify-player (brew)"}]}}
|
||||
---
|
||||
|
||||
# spogo / spotify_player
|
||||
# spotify_player
|
||||
|
||||
Use `spogo` **(preferred)** for Spotify playback/search. Fall back to `spotify_player` if needed.
|
||||
Use `spotify_player` for Spotify playback/search in the terminal.
|
||||
|
||||
Requirements
|
||||
- Spotify Premium account.
|
||||
- Either `spogo` or `spotify_player` installed.
|
||||
|
||||
spogo setup
|
||||
- Import cookies: `spogo auth import --browser chrome`
|
||||
- First-time auth: `spotify_player authenticate`.
|
||||
|
||||
Common CLI commands
|
||||
- Search: `spogo search track "query"`
|
||||
- Playback: `spogo play|pause|next|prev`
|
||||
- Devices: `spogo device list`, `spogo device set "<name|id>"`
|
||||
- Status: `spogo status`
|
||||
|
||||
spotify_player commands (fallback)
|
||||
- Search: `spotify_player search "query"`
|
||||
- Playback: `spotify_player playback play|pause|next|previous`
|
||||
- Connect device: `spotify_player connect`
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("bash tool pty mode", () => {
|
||||
it("falls back to pipe with warning when node-pty fails to load", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("node-pty", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const { createBashTool } = await import("./bash-tools.js");
|
||||
const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 });
|
||||
const result = await tool.execute("call", {
|
||||
command: "echo test",
|
||||
stdinMode: "pty",
|
||||
});
|
||||
|
||||
const text = result.content.find((c) => c.type === "text")?.text ?? "";
|
||||
expect(text).toContain("Warning: node-pty failed to load");
|
||||
expect(text).toContain("falling back to pipe mode.");
|
||||
|
||||
vi.doUnmock("node-pty");
|
||||
});
|
||||
|
||||
it("uses node-pty when available", async () => {
|
||||
vi.resetModules();
|
||||
const spawn = vi.fn(() => {
|
||||
let onData: ((data: string) => void) | undefined;
|
||||
let onExit:
|
||||
| ((event: { exitCode: number | null; signal?: number | null }) => void)
|
||||
| undefined;
|
||||
const pty = {
|
||||
pid: 4321,
|
||||
onData: (cb: (data: string) => void) => {
|
||||
onData = cb;
|
||||
},
|
||||
onExit: (
|
||||
cb: (event: {
|
||||
exitCode: number | null;
|
||||
signal?: number | null;
|
||||
}) => void,
|
||||
) => {
|
||||
onExit = cb;
|
||||
},
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
setTimeout(() => {
|
||||
onData?.("hello\n");
|
||||
onExit?.({ exitCode: 0, signal: null });
|
||||
}, 10);
|
||||
return pty;
|
||||
});
|
||||
vi.doMock("node-pty", () => ({ spawn }));
|
||||
|
||||
const { createBashTool } = await import("./bash-tools.js");
|
||||
const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 });
|
||||
const result = await tool.execute("call", {
|
||||
command: "ignored",
|
||||
stdinMode: "pty",
|
||||
});
|
||||
|
||||
const text = result.content.find((c) => c.type === "text")?.text ?? "";
|
||||
expect(text).toContain("hello");
|
||||
expect(text).not.toContain("Warning:");
|
||||
|
||||
vi.doUnmock("node-pty");
|
||||
});
|
||||
});
|
||||
+19
-117
@@ -1,8 +1,5 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { IPty } from "node-pty";
|
||||
@@ -34,20 +31,16 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||
1_000,
|
||||
150_000,
|
||||
);
|
||||
const DEFAULT_SHELL_PATH = "/bin/sh";
|
||||
const DEFAULT_PATH =
|
||||
"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
const DEFAULT_PTY_NAME = "xterm-256color";
|
||||
|
||||
type PtyModule = typeof import("node-pty");
|
||||
type PtyLoadResult = { module: PtyModule | null; error?: unknown };
|
||||
let ptyModulePromise: Promise<PtyLoadResult> | null = null;
|
||||
let ptyModulePromise: Promise<PtyModule | null> | null = null;
|
||||
|
||||
async function loadPtyModule(): Promise<PtyLoadResult> {
|
||||
async function loadPtyModule(): Promise<PtyModule | null> {
|
||||
if (!ptyModulePromise) {
|
||||
ptyModulePromise = import("node-pty")
|
||||
.then((mod) => ({ module: mod }))
|
||||
.catch((error) => ({ module: null, error }));
|
||||
.then((mod) => mod)
|
||||
.catch(() => null);
|
||||
}
|
||||
return ptyModulePromise;
|
||||
}
|
||||
@@ -161,67 +154,40 @@ export function createBashTool(
|
||||
const maxOutput = DEFAULT_MAX_OUTPUT;
|
||||
const startedAt = Date.now();
|
||||
const sessionId = randomUUID();
|
||||
const warnings: string[] = [];
|
||||
const workdir = resolveWorkdir(
|
||||
params.workdir?.trim() || process.cwd(),
|
||||
warnings,
|
||||
);
|
||||
const workdir = params.workdir?.trim() || process.cwd();
|
||||
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
const env = params.env ? { ...process.env, ...params.env } : process.env;
|
||||
const requestedStdinMode = params.stdinMode === "pty" ? "pty" : "pipe";
|
||||
const requestedStdinMode =
|
||||
params.stdinMode === "pty" ? "pty" : "pipe";
|
||||
let stdinMode: ProcessStdinMode = requestedStdinMode;
|
||||
let warning: string | null = null;
|
||||
let child: ChildProcessWithoutNullStreams | undefined;
|
||||
let pty: IPty | undefined;
|
||||
|
||||
if (stdinMode === "pty") {
|
||||
const { module: ptyModule, error: ptyError } = await loadPtyModule();
|
||||
const ptyModule = await loadPtyModule();
|
||||
if (!ptyModule) {
|
||||
warning =
|
||||
`Warning: node-pty failed to load${formatPtyError(ptyError)}; ` +
|
||||
"falling back to pipe mode.";
|
||||
"Warning: node-pty failed to load; falling back to pipe mode.";
|
||||
stdinMode = "pipe";
|
||||
} else {
|
||||
const ptyEnv = ensurePath({
|
||||
const ptyEnv = {
|
||||
...env,
|
||||
TERM: env.TERM ?? DEFAULT_PTY_NAME,
|
||||
} as Record<string, string>);
|
||||
const ptyShell = resolveShellPath(shell, ptyEnv);
|
||||
} as Record<string, string>;
|
||||
try {
|
||||
pty = ptyModule.spawn(ptyShell, [...shellArgs, params.command], {
|
||||
pty = ptyModule.spawn(shell, [...shellArgs, params.command], {
|
||||
cwd: workdir,
|
||||
env: ptyEnv,
|
||||
name: ptyEnv.TERM || DEFAULT_PTY_NAME,
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
});
|
||||
} catch (error) {
|
||||
if (ptyShell !== DEFAULT_SHELL_PATH && existsSync(DEFAULT_SHELL_PATH)) {
|
||||
try {
|
||||
pty = ptyModule.spawn(
|
||||
DEFAULT_SHELL_PATH,
|
||||
[...shellArgs, params.command],
|
||||
{
|
||||
cwd: workdir,
|
||||
env: ptyEnv,
|
||||
name: ptyEnv.TERM || DEFAULT_PTY_NAME,
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
},
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
warning =
|
||||
`Warning: node-pty failed to start${formatPtyError(fallbackError)}; ` +
|
||||
"falling back to pipe mode.";
|
||||
stdinMode = "pipe";
|
||||
}
|
||||
} else {
|
||||
warning =
|
||||
`Warning: node-pty failed to start${formatPtyError(error)}; ` +
|
||||
"falling back to pipe mode.";
|
||||
stdinMode = "pipe";
|
||||
}
|
||||
} catch {
|
||||
warning =
|
||||
"Warning: node-pty failed to start; falling back to pipe mode.";
|
||||
stdinMode = "pipe";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,8 +201,6 @@ export function createBashTool(
|
||||
});
|
||||
}
|
||||
|
||||
if (warning) warnings.push(warning);
|
||||
|
||||
const session = {
|
||||
id: sessionId,
|
||||
command: params.command,
|
||||
@@ -293,7 +257,7 @@ export function createBashTool(
|
||||
const emitUpdate = () => {
|
||||
if (!onUpdate) return;
|
||||
const tailText = session.tail || session.aggregated;
|
||||
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
|
||||
const warningText = warning ? `${warning}\n\n` : "";
|
||||
onUpdate({
|
||||
content: [{ type: "text", text: warningText + (tailText || "") }],
|
||||
details: {
|
||||
@@ -343,7 +307,7 @@ export function createBashTool(
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
|
||||
`${warning ? `${warning}\n\n` : ""}` +
|
||||
`Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` +
|
||||
"Use process (list/poll/log/write/kill/clear/remove) for follow-up.",
|
||||
},
|
||||
@@ -417,7 +381,7 @@ export function createBashTool(
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
|
||||
`${warning ? `${warning}\n\n` : ""}` +
|
||||
(aggregated || "(no output)"),
|
||||
},
|
||||
],
|
||||
@@ -922,68 +886,6 @@ function killSession(session: {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkdir(workdir: string, warnings: string[]) {
|
||||
const current = safeCwd();
|
||||
const fallback = current ?? homedir();
|
||||
try {
|
||||
const stats = statSync(workdir);
|
||||
if (stats.isDirectory()) return workdir;
|
||||
} catch {
|
||||
// ignore, fallback below
|
||||
}
|
||||
warnings.push(
|
||||
`Warning: workdir "${workdir}" is unavailable; using "${fallback}".`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function safeCwd() {
|
||||
try {
|
||||
const cwd = process.cwd();
|
||||
return existsSync(cwd) ? cwd : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePath(env: Record<string, string>) {
|
||||
if (!env.PATH?.trim()) {
|
||||
env.PATH = DEFAULT_PATH;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function resolveShellPath(shell: string, env: Record<string, string>) {
|
||||
if (process.platform === "win32") return shell;
|
||||
if (shell.includes("/") && existsSync(shell)) {
|
||||
return shell;
|
||||
}
|
||||
const searchPath = env.PATH ?? "";
|
||||
for (const segment of searchPath.split(path.delimiter)) {
|
||||
if (!segment) continue;
|
||||
const candidate = path.join(segment, shell);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
if (existsSync(DEFAULT_SHELL_PATH)) {
|
||||
return DEFAULT_SHELL_PATH;
|
||||
}
|
||||
return shell;
|
||||
}
|
||||
|
||||
function formatPtyError(error: unknown) {
|
||||
if (!error) return "";
|
||||
if (typeof error === "string") return ` (${error})`;
|
||||
if (error instanceof Error) {
|
||||
const firstLine = error.message.split(/\r?\n/)[0]?.trim();
|
||||
return firstLine ? ` (${firstLine})` : "";
|
||||
}
|
||||
try {
|
||||
return ` (${JSON.stringify(error)})`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function clampNumber(
|
||||
value: number | undefined,
|
||||
defaultValue: number,
|
||||
|
||||
+21
-762
@@ -41,36 +41,7 @@ import {
|
||||
} from "../cli/nodes-screen.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
addRoleDiscord,
|
||||
banMemberDiscord,
|
||||
createScheduledEventDiscord,
|
||||
createThreadDiscord,
|
||||
deleteMessageDiscord,
|
||||
editMessageDiscord,
|
||||
fetchChannelInfoDiscord,
|
||||
fetchChannelPermissionsDiscord,
|
||||
fetchMemberInfoDiscord,
|
||||
fetchReactionsDiscord,
|
||||
fetchRoleInfoDiscord,
|
||||
fetchVoiceStatusDiscord,
|
||||
kickMemberDiscord,
|
||||
listGuildChannelsDiscord,
|
||||
listGuildEmojisDiscord,
|
||||
listPinsDiscord,
|
||||
listScheduledEventsDiscord,
|
||||
listThreadsDiscord,
|
||||
pinMessageDiscord,
|
||||
reactMessageDiscord,
|
||||
readMessagesDiscord,
|
||||
removeRoleDiscord,
|
||||
searchMessagesDiscord,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
timeoutMemberDiscord,
|
||||
unpinMessageDiscord,
|
||||
} from "../discord/send.js";
|
||||
import { reactMessageDiscord } from "../discord/send.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { detectMime, imageMimeFromFormat } from "../media/mime.js";
|
||||
import { sanitizeToolResultImages } from "./tool-images.js";
|
||||
@@ -137,46 +108,6 @@ function readStringParam(
|
||||
return value;
|
||||
}
|
||||
|
||||
function readStringArrayParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
options: StringParamOptions & { required: true },
|
||||
): string[];
|
||||
function readStringArrayParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
options?: StringParamOptions,
|
||||
): string[] | undefined;
|
||||
function readStringArrayParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
options: StringParamOptions = {},
|
||||
) {
|
||||
const { required = false, label = key } = options;
|
||||
const raw = params[key];
|
||||
if (Array.isArray(raw)) {
|
||||
const values = raw
|
||||
.filter((entry) => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
if (values.length === 0) {
|
||||
if (required) throw new Error(`${label} required`);
|
||||
return undefined;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
const value = raw.trim();
|
||||
if (!value) {
|
||||
if (required) throw new Error(`${label} required`);
|
||||
return undefined;
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
if (required) throw new Error(`${label} required`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function callGatewayTool<T = unknown>(
|
||||
method: string,
|
||||
opts: GatewayCallOptions,
|
||||
@@ -1555,709 +1486,37 @@ const DiscordToolSchema = Type.Union([
|
||||
messageId: Type.String(),
|
||||
emoji: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("reactions"),
|
||||
channelId: Type.String(),
|
||||
messageId: Type.String(),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("sticker"),
|
||||
to: Type.String(),
|
||||
stickerIds: Type.Array(Type.String()),
|
||||
content: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("poll"),
|
||||
to: Type.String(),
|
||||
question: Type.String(),
|
||||
answers: Type.Array(Type.String()),
|
||||
allowMultiselect: Type.Optional(Type.Boolean()),
|
||||
durationHours: Type.Optional(Type.Number()),
|
||||
content: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("permissions"),
|
||||
channelId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("readMessages"),
|
||||
channelId: Type.String(),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
before: Type.Optional(Type.String()),
|
||||
after: Type.Optional(Type.String()),
|
||||
around: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("sendMessage"),
|
||||
to: Type.String(),
|
||||
content: Type.String(),
|
||||
mediaUrl: Type.Optional(Type.String()),
|
||||
replyTo: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("editMessage"),
|
||||
channelId: Type.String(),
|
||||
messageId: Type.String(),
|
||||
content: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("deleteMessage"),
|
||||
channelId: Type.String(),
|
||||
messageId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("threadCreate"),
|
||||
channelId: Type.String(),
|
||||
name: Type.String(),
|
||||
messageId: Type.Optional(Type.String()),
|
||||
autoArchiveMinutes: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("threadList"),
|
||||
guildId: Type.String(),
|
||||
channelId: Type.Optional(Type.String()),
|
||||
includeArchived: Type.Optional(Type.Boolean()),
|
||||
before: Type.Optional(Type.String()),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("threadReply"),
|
||||
channelId: Type.String(),
|
||||
content: Type.String(),
|
||||
mediaUrl: Type.Optional(Type.String()),
|
||||
replyTo: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("pinMessage"),
|
||||
channelId: Type.String(),
|
||||
messageId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("unpinMessage"),
|
||||
channelId: Type.String(),
|
||||
messageId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("listPins"),
|
||||
channelId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("searchMessages"),
|
||||
guildId: Type.String(),
|
||||
content: Type.String(),
|
||||
channelId: Type.Optional(Type.String()),
|
||||
channelIds: Type.Optional(Type.Array(Type.String())),
|
||||
authorId: Type.Optional(Type.String()),
|
||||
authorIds: Type.Optional(Type.Array(Type.String())),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("memberInfo"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("roleInfo"),
|
||||
guildId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("emojiList"),
|
||||
guildId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("roleAdd"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
roleId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("roleRemove"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
roleId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("channelInfo"),
|
||||
channelId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("channelList"),
|
||||
guildId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("voiceStatus"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("eventList"),
|
||||
guildId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("eventCreate"),
|
||||
guildId: Type.String(),
|
||||
name: Type.String(),
|
||||
startTime: Type.String(),
|
||||
endTime: Type.Optional(Type.String()),
|
||||
description: Type.Optional(Type.String()),
|
||||
channelId: Type.Optional(Type.String()),
|
||||
entityType: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("voice"),
|
||||
Type.Literal("stage"),
|
||||
Type.Literal("external"),
|
||||
]),
|
||||
),
|
||||
location: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("timeout"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
durationMinutes: Type.Optional(Type.Number()),
|
||||
until: Type.Optional(Type.String()),
|
||||
reason: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("kick"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
reason: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("ban"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
reason: Type.Optional(Type.String()),
|
||||
deleteMessageDays: Type.Optional(Type.Number()),
|
||||
}),
|
||||
]);
|
||||
|
||||
function createDiscordTool(): AnyAgentTool {
|
||||
return {
|
||||
label: "Clawdis Discord",
|
||||
name: "discord",
|
||||
description: "Manage Discord messages, reactions, and moderation.",
|
||||
name: "clawdis_discord",
|
||||
description:
|
||||
"React to Discord messages. Controlled by discord.enableReactions (default: true).",
|
||||
parameters: DiscordToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const cfg = loadConfig();
|
||||
const isActionEnabled = (
|
||||
key: keyof NonNullable<typeof cfg.discord>["actions"],
|
||||
defaultValue = true,
|
||||
) => {
|
||||
const value = cfg.discord?.actions?.[key];
|
||||
if (value === undefined) return defaultValue;
|
||||
return value !== false;
|
||||
};
|
||||
if (action !== "react") throw new Error(`Unknown action: ${action}`);
|
||||
|
||||
switch (action) {
|
||||
case "react": {
|
||||
if (!isActionEnabled("reactions")) {
|
||||
throw new Error("Discord reactions are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const emoji = readStringParam(params, "emoji", { required: true });
|
||||
await reactMessageDiscord(channelId, messageId, emoji);
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "reactions": {
|
||||
if (!isActionEnabled("reactions")) {
|
||||
throw new Error("Discord reactions are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const limitRaw = params.limit;
|
||||
const limit =
|
||||
typeof limitRaw === "number" && Number.isFinite(limitRaw)
|
||||
? limitRaw
|
||||
: undefined;
|
||||
const reactions = await fetchReactionsDiscord(channelId, messageId, {
|
||||
limit,
|
||||
});
|
||||
return jsonResult({ ok: true, reactions });
|
||||
}
|
||||
case "sticker": {
|
||||
if (!isActionEnabled("stickers")) {
|
||||
throw new Error("Discord stickers are disabled.");
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "content");
|
||||
const stickerIds = readStringArrayParam(params, "stickerIds", {
|
||||
required: true,
|
||||
label: "stickerIds",
|
||||
});
|
||||
await sendStickerDiscord(to, stickerIds, { content });
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "poll": {
|
||||
if (!isActionEnabled("polls")) {
|
||||
throw new Error("Discord polls are disabled.");
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "content");
|
||||
const question = readStringParam(params, "question", {
|
||||
required: true,
|
||||
});
|
||||
const answers = readStringArrayParam(params, "answers", {
|
||||
required: true,
|
||||
label: "answers",
|
||||
});
|
||||
const allowMultiselectRaw = params.allowMultiselect;
|
||||
const allowMultiselect =
|
||||
typeof allowMultiselectRaw === "boolean"
|
||||
? allowMultiselectRaw
|
||||
: undefined;
|
||||
const durationRaw = params.durationHours;
|
||||
const durationHours =
|
||||
typeof durationRaw === "number" && Number.isFinite(durationRaw)
|
||||
? durationRaw
|
||||
: undefined;
|
||||
await sendPollDiscord(
|
||||
to,
|
||||
{ question, answers, allowMultiselect, durationHours },
|
||||
{ content },
|
||||
);
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "permissions": {
|
||||
if (!isActionEnabled("permissions")) {
|
||||
throw new Error("Discord permissions are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const permissions = await fetchChannelPermissionsDiscord(channelId);
|
||||
return jsonResult({ ok: true, permissions });
|
||||
}
|
||||
case "readMessages": {
|
||||
if (!isActionEnabled("messages")) {
|
||||
throw new Error("Discord message reads are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messages = await readMessagesDiscord(channelId, {
|
||||
limit:
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: undefined,
|
||||
before: readStringParam(params, "before"),
|
||||
after: readStringParam(params, "after"),
|
||||
around: readStringParam(params, "around"),
|
||||
});
|
||||
return jsonResult({ ok: true, messages });
|
||||
}
|
||||
case "sendMessage": {
|
||||
if (!isActionEnabled("messages")) {
|
||||
throw new Error("Discord message sends are disabled.");
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "content", {
|
||||
required: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const result = await sendMessageDiscord(to, content, {
|
||||
mediaUrl,
|
||||
replyTo,
|
||||
});
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
case "editMessage": {
|
||||
if (!isActionEnabled("messages")) {
|
||||
throw new Error("Discord message edits are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const content = readStringParam(params, "content", {
|
||||
required: true,
|
||||
});
|
||||
const message = await editMessageDiscord(channelId, messageId, {
|
||||
content,
|
||||
});
|
||||
return jsonResult({ ok: true, message });
|
||||
}
|
||||
case "deleteMessage": {
|
||||
if (!isActionEnabled("messages")) {
|
||||
throw new Error("Discord message deletes are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
await deleteMessageDiscord(channelId, messageId);
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "threadCreate": {
|
||||
if (!isActionEnabled("threads")) {
|
||||
throw new Error("Discord threads are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const messageId = readStringParam(params, "messageId");
|
||||
const autoArchiveMinutesRaw = params.autoArchiveMinutes;
|
||||
const autoArchiveMinutes =
|
||||
typeof autoArchiveMinutesRaw === "number" &&
|
||||
Number.isFinite(autoArchiveMinutesRaw)
|
||||
? autoArchiveMinutesRaw
|
||||
: undefined;
|
||||
const thread = await createThreadDiscord(channelId, {
|
||||
name,
|
||||
messageId,
|
||||
autoArchiveMinutes,
|
||||
});
|
||||
return jsonResult({ ok: true, thread });
|
||||
}
|
||||
case "threadList": {
|
||||
if (!isActionEnabled("threads")) {
|
||||
throw new Error("Discord threads are disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
const includeArchived =
|
||||
typeof params.includeArchived === "boolean"
|
||||
? params.includeArchived
|
||||
: undefined;
|
||||
const before = readStringParam(params, "before");
|
||||
const limit =
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: undefined;
|
||||
const threads = await listThreadsDiscord({
|
||||
guildId,
|
||||
channelId,
|
||||
includeArchived,
|
||||
before,
|
||||
limit,
|
||||
});
|
||||
return jsonResult({ ok: true, threads });
|
||||
}
|
||||
case "threadReply": {
|
||||
if (!isActionEnabled("threads")) {
|
||||
throw new Error("Discord threads are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const content = readStringParam(params, "content", {
|
||||
required: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const result = await sendMessageDiscord(
|
||||
`channel:${channelId}`,
|
||||
content,
|
||||
{
|
||||
mediaUrl,
|
||||
replyTo,
|
||||
},
|
||||
);
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
case "pinMessage": {
|
||||
if (!isActionEnabled("pins")) {
|
||||
throw new Error("Discord pins are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
await pinMessageDiscord(channelId, messageId);
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "unpinMessage": {
|
||||
if (!isActionEnabled("pins")) {
|
||||
throw new Error("Discord pins are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
await unpinMessageDiscord(channelId, messageId);
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "listPins": {
|
||||
if (!isActionEnabled("pins")) {
|
||||
throw new Error("Discord pins are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const pins = await listPinsDiscord(channelId);
|
||||
return jsonResult({ ok: true, pins });
|
||||
}
|
||||
case "searchMessages": {
|
||||
if (!isActionEnabled("search")) {
|
||||
throw new Error("Discord search is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const content = readStringParam(params, "content", {
|
||||
required: true,
|
||||
});
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
const channelIds = readStringArrayParam(params, "channelIds");
|
||||
const authorId = readStringParam(params, "authorId");
|
||||
const authorIds = readStringArrayParam(params, "authorIds");
|
||||
const limit =
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: undefined;
|
||||
const channelIdList = [
|
||||
...(channelIds ?? []),
|
||||
...(channelId ? [channelId] : []),
|
||||
];
|
||||
const authorIdList = [
|
||||
...(authorIds ?? []),
|
||||
...(authorId ? [authorId] : []),
|
||||
];
|
||||
const results = await searchMessagesDiscord({
|
||||
guildId,
|
||||
content,
|
||||
channelIds: channelIdList.length ? channelIdList : undefined,
|
||||
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||
limit,
|
||||
});
|
||||
return jsonResult({ ok: true, results });
|
||||
}
|
||||
case "memberInfo": {
|
||||
if (!isActionEnabled("memberInfo")) {
|
||||
throw new Error("Discord member info is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const member = await fetchMemberInfoDiscord(guildId, userId);
|
||||
return jsonResult({ ok: true, member });
|
||||
}
|
||||
case "roleInfo": {
|
||||
if (!isActionEnabled("roleInfo")) {
|
||||
throw new Error("Discord role info is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const roles = await fetchRoleInfoDiscord(guildId);
|
||||
return jsonResult({ ok: true, roles });
|
||||
}
|
||||
case "emojiList": {
|
||||
if (!isActionEnabled("reactions")) {
|
||||
throw new Error("Discord reactions are disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const emojis = await listGuildEmojisDiscord(guildId);
|
||||
return jsonResult({ ok: true, emojis });
|
||||
}
|
||||
case "roleAdd": {
|
||||
if (!isActionEnabled("roles", false)) {
|
||||
throw new Error("Discord role changes are disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const roleId = readStringParam(params, "roleId", {
|
||||
required: true,
|
||||
});
|
||||
await addRoleDiscord({ guildId, userId, roleId });
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "roleRemove": {
|
||||
if (!isActionEnabled("roles", false)) {
|
||||
throw new Error("Discord role changes are disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const roleId = readStringParam(params, "roleId", {
|
||||
required: true,
|
||||
});
|
||||
await removeRoleDiscord({ guildId, userId, roleId });
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "channelInfo": {
|
||||
if (!isActionEnabled("channelInfo")) {
|
||||
throw new Error("Discord channel info is disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const channel = await fetchChannelInfoDiscord(channelId);
|
||||
return jsonResult({ ok: true, channel });
|
||||
}
|
||||
case "channelList": {
|
||||
if (!isActionEnabled("channelInfo")) {
|
||||
throw new Error("Discord channel info is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const channels = await listGuildChannelsDiscord(guildId);
|
||||
return jsonResult({ ok: true, channels });
|
||||
}
|
||||
case "voiceStatus": {
|
||||
if (!isActionEnabled("voiceStatus")) {
|
||||
throw new Error("Discord voice status is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const voice = await fetchVoiceStatusDiscord(guildId, userId);
|
||||
return jsonResult({ ok: true, voice });
|
||||
}
|
||||
case "eventList": {
|
||||
if (!isActionEnabled("events")) {
|
||||
throw new Error("Discord events are disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const events = await listScheduledEventsDiscord(guildId);
|
||||
return jsonResult({ ok: true, events });
|
||||
}
|
||||
case "eventCreate": {
|
||||
if (!isActionEnabled("events")) {
|
||||
throw new Error("Discord events are disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const startTime = readStringParam(params, "startTime", {
|
||||
required: true,
|
||||
});
|
||||
const endTime = readStringParam(params, "endTime");
|
||||
const description = readStringParam(params, "description");
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
const location = readStringParam(params, "location");
|
||||
const entityTypeRaw = readStringParam(params, "entityType");
|
||||
const entityType =
|
||||
entityTypeRaw === "stage"
|
||||
? 1
|
||||
: entityTypeRaw === "external"
|
||||
? 3
|
||||
: 2;
|
||||
const payload = {
|
||||
name,
|
||||
description,
|
||||
scheduled_start_time: startTime,
|
||||
scheduled_end_time: endTime,
|
||||
entity_type: entityType,
|
||||
channel_id: channelId,
|
||||
entity_metadata:
|
||||
entityType === 3 && location ? { location } : undefined,
|
||||
privacy_level: 2,
|
||||
};
|
||||
const event = await createScheduledEventDiscord(guildId, payload);
|
||||
return jsonResult({ ok: true, event });
|
||||
}
|
||||
case "timeout": {
|
||||
if (!isActionEnabled("moderation", false)) {
|
||||
throw new Error("Discord moderation is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const durationMinutes =
|
||||
typeof params.durationMinutes === "number" &&
|
||||
Number.isFinite(params.durationMinutes)
|
||||
? params.durationMinutes
|
||||
: undefined;
|
||||
const until = readStringParam(params, "until");
|
||||
const reason = readStringParam(params, "reason");
|
||||
const member = await timeoutMemberDiscord({
|
||||
guildId,
|
||||
userId,
|
||||
durationMinutes,
|
||||
until,
|
||||
reason,
|
||||
});
|
||||
return jsonResult({ ok: true, member });
|
||||
}
|
||||
case "kick": {
|
||||
if (!isActionEnabled("moderation", false)) {
|
||||
throw new Error("Discord moderation is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const reason = readStringParam(params, "reason");
|
||||
await kickMemberDiscord({ guildId, userId, reason });
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "ban": {
|
||||
if (!isActionEnabled("moderation", false)) {
|
||||
throw new Error("Discord moderation is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const reason = readStringParam(params, "reason");
|
||||
const deleteMessageDays =
|
||||
typeof params.deleteMessageDays === "number" &&
|
||||
Number.isFinite(params.deleteMessageDays)
|
||||
? params.deleteMessageDays
|
||||
: undefined;
|
||||
await banMemberDiscord({
|
||||
guildId,
|
||||
userId,
|
||||
reason,
|
||||
deleteMessageDays,
|
||||
});
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
const cfg = loadConfig();
|
||||
if (cfg.discord?.enableReactions === false) {
|
||||
throw new Error(
|
||||
"Discord reactions are disabled (set discord.enableReactions=true).",
|
||||
);
|
||||
}
|
||||
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const emoji = readStringParam(params, "emoji", { required: true });
|
||||
|
||||
await reactMessageDiscord(channelId, messageId, emoji);
|
||||
return jsonResult({ ok: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ vi.mock("../agents/pi-embedded.js", () => ({
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
vi.mock("../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(),
|
||||
|
||||
@@ -26,8 +26,6 @@ vi.mock("../agents/pi-embedded.js", () => ({
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
vi.mock("../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(),
|
||||
@@ -99,43 +97,6 @@ describe("directive parsing", () => {
|
||||
expect(res.cleaned).toBe("please now");
|
||||
});
|
||||
|
||||
it("parses queue options and modes", () => {
|
||||
const res = extractQueueDirective(
|
||||
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now",
|
||||
);
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.queueMode).toBe("steer-backlog");
|
||||
expect(res.debounceMs).toBe(2000);
|
||||
expect(res.cap).toBe(5);
|
||||
expect(res.dropPolicy).toBe("summarize");
|
||||
expect(res.cleaned).toBe("please now");
|
||||
});
|
||||
|
||||
it("errors on invalid queue options", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/queue collect debounce:bogus cap:zero drop:maybe", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Invalid debounce");
|
||||
expect(text).toContain("Invalid cap");
|
||||
expect(text).toContain("Invalid drop policy");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts reply_to_current tag", () => {
|
||||
const res = extractReplyToTag("ok [[reply_to_current]]", "msg-1");
|
||||
expect(res.replyToId).toBe("msg-1");
|
||||
@@ -315,43 +276,6 @@ describe("directive parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists queue options when directive is standalone", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/queue collect debounce:2s cap:5 drop:old",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toMatch(/^⚙️ Queue mode set to collect\./);
|
||||
expect(text).toMatch(/Queue debounce set to 2000ms/);
|
||||
expect(text).toMatch(/Queue cap set to 5/);
|
||||
expect(text).toMatch(/Queue drop set to old/);
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = Object.values(store)[0];
|
||||
expect(entry?.queueMode).toBe("collect");
|
||||
expect(entry?.queueDebounceMs).toBe(2000);
|
||||
expect(entry?.queueCap).toBe(5);
|
||||
expect(entry?.queueDrop).toBe("old");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("resets queue mode to default", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
@@ -388,9 +312,6 @@ describe("directive parsing", () => {
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = Object.values(store)[0];
|
||||
expect(entry?.queueMode).toBeUndefined();
|
||||
expect(entry?.queueDebounceMs).toBeUndefined();
|
||||
expect(entry?.queueCap).toBeUndefined();
|
||||
expect(entry?.queueDrop).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
isEmbeddedPiRunActive,
|
||||
isEmbeddedPiRunStreaming,
|
||||
runEmbeddedPiAgent,
|
||||
} from "../agents/pi-embedded.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
function makeResult(text: string) {
|
||||
return {
|
||||
payloads: [{ text }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-queue-"));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = base;
|
||||
try {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
return await fn(base);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
try {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes("ENOTEMPTY")) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeCfg(home: string, queue?: Record<string, unknown>) {
|
||||
return {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
routing: queue ? { queue } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("queue followups", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("collects queued messages and drains after run completes", async () => {
|
||||
vi.useFakeTimers();
|
||||
await withTempHome(async (home) => {
|
||||
const prompts: string[] = [];
|
||||
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
|
||||
prompts.push(params.prompt);
|
||||
if (params.prompt.includes("[Queued messages while agent was busy]")) {
|
||||
return makeResult("followup");
|
||||
}
|
||||
return makeResult("main");
|
||||
});
|
||||
|
||||
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(true);
|
||||
vi.mocked(isEmbeddedPiRunStreaming).mockReturnValue(true);
|
||||
|
||||
const cfg = makeCfg(home, {
|
||||
mode: "collect",
|
||||
debounceMs: 200,
|
||||
cap: 10,
|
||||
drop: "summarize",
|
||||
});
|
||||
|
||||
const first = await getReplyFromConfig(
|
||||
{ Body: "first", From: "+1001", To: "+2000" },
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
expect(first).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(false);
|
||||
vi.mocked(isEmbeddedPiRunStreaming).mockReturnValue(false);
|
||||
|
||||
const second = await getReplyFromConfig(
|
||||
{ Body: "second", From: "+1001", To: "+2000" },
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
|
||||
const secondText = Array.isArray(second)
|
||||
? second[0]?.text
|
||||
: second?.text;
|
||||
expect(secondText).toBe("main");
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
prompts.some((p) =>
|
||||
p.includes("[Queued messages while agent was busy]"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes dropped followups when cap is exceeded", async () => {
|
||||
vi.useFakeTimers();
|
||||
await withTempHome(async (home) => {
|
||||
const prompts: string[] = [];
|
||||
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
|
||||
prompts.push(params.prompt);
|
||||
return makeResult("ok");
|
||||
});
|
||||
|
||||
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(true);
|
||||
vi.mocked(isEmbeddedPiRunStreaming).mockReturnValue(false);
|
||||
|
||||
const cfg = makeCfg(home, {
|
||||
mode: "followup",
|
||||
debounceMs: 0,
|
||||
cap: 1,
|
||||
drop: "summarize",
|
||||
});
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "one", From: "+1002", To: "+2000" },
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
await getReplyFromConfig(
|
||||
{ Body: "two", From: "+1002", To: "+2000" },
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
|
||||
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(false);
|
||||
await getReplyFromConfig(
|
||||
{ Body: "three", From: "+1002", To: "+2000" },
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(prompts.some((p) => p.includes("[Queue overflow]"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,6 @@ vi.mock("../agents/pi-embedded.js", () => ({
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
|
||||
+57
-756
@@ -16,16 +16,11 @@ import {
|
||||
} from "../agents/model-selection.js";
|
||||
import {
|
||||
abortEmbeddedPiRun,
|
||||
isEmbeddedPiRunActive,
|
||||
isEmbeddedPiRunStreaming,
|
||||
queueEmbeddedPiMessage,
|
||||
resolveEmbeddedSessionLane,
|
||||
runEmbeddedPiAgent,
|
||||
} from "../agents/pi-embedded.js";
|
||||
import {
|
||||
buildWorkspaceSkillSnapshot,
|
||||
type SkillSnapshot,
|
||||
} from "../agents/skills.js";
|
||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||
import {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||
ensureAgentWorkspace,
|
||||
@@ -54,7 +49,6 @@ import {
|
||||
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
|
||||
import {
|
||||
@@ -81,68 +75,11 @@ const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]);
|
||||
const ABORT_MEMORY = new Map<string, boolean>();
|
||||
const SYSTEM_MARK = "⚙️";
|
||||
|
||||
type QueueMode =
|
||||
| "steer"
|
||||
| "followup"
|
||||
| "collect"
|
||||
| "steer-backlog"
|
||||
| "interrupt"
|
||||
| "queue";
|
||||
|
||||
type QueueDropPolicy = "old" | "new" | "summarize";
|
||||
|
||||
type QueueSettings = {
|
||||
mode: QueueMode;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
dropPolicy?: QueueDropPolicy;
|
||||
};
|
||||
|
||||
type FollowupRun = {
|
||||
prompt: string;
|
||||
summaryLine?: string;
|
||||
enqueuedAt: number;
|
||||
run: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config: ClawdisConfig;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkLevel?: ThinkLevel;
|
||||
verboseLevel?: VerboseLevel;
|
||||
timeoutMs: number;
|
||||
blockReplyBreak: "text_end" | "message_end";
|
||||
ownerNumbers?: string[];
|
||||
extraSystemPrompt?: string;
|
||||
enforceFinalTag?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type FollowupQueueState = {
|
||||
items: FollowupRun[];
|
||||
draining: boolean;
|
||||
lastEnqueuedAt: number;
|
||||
mode: QueueMode;
|
||||
debounceMs: number;
|
||||
cap: number;
|
||||
dropPolicy: QueueDropPolicy;
|
||||
droppedCount: number;
|
||||
summaryLines: string[];
|
||||
lastRun?: FollowupRun["run"];
|
||||
};
|
||||
|
||||
const DEFAULT_QUEUE_DEBOUNCE_MS = 1000;
|
||||
const DEFAULT_QUEUE_CAP = 20;
|
||||
const DEFAULT_QUEUE_DROP: QueueDropPolicy = "summarize";
|
||||
|
||||
const FOLLOWUP_QUEUES = new Map<string, FollowupQueueState>();
|
||||
|
||||
const BARE_SESSION_RESET_PROMPT =
|
||||
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning.";
|
||||
|
||||
type QueueMode = "queue" | "interrupt";
|
||||
|
||||
export function extractThinkDirective(body?: string): {
|
||||
cleaned: string;
|
||||
thinkLevel?: ThinkLevel;
|
||||
@@ -191,204 +128,39 @@ export function extractVerboseDirective(body?: string): {
|
||||
function normalizeQueueMode(raw?: string): QueueMode | undefined {
|
||||
if (!raw) return undefined;
|
||||
const cleaned = raw.trim().toLowerCase();
|
||||
if (cleaned === "queue" || cleaned === "queued") return "steer";
|
||||
if (cleaned === "interrupt" || cleaned === "interrupts" || cleaned === "abort")
|
||||
return "interrupt";
|
||||
if (cleaned === "steer" || cleaned === "steering") return "steer";
|
||||
if (cleaned === "followup" || cleaned === "follow-ups" || cleaned === "followups")
|
||||
return "followup";
|
||||
if (cleaned === "collect" || cleaned === "coalesce") return "collect";
|
||||
if (cleaned === "queue" || cleaned === "queued") return "queue";
|
||||
if (
|
||||
cleaned === "steer+backlog" ||
|
||||
cleaned === "steer-backlog" ||
|
||||
cleaned === "steer_backlog"
|
||||
cleaned === "interrupt" ||
|
||||
cleaned === "interrupts" ||
|
||||
cleaned === "abort"
|
||||
)
|
||||
return "steer-backlog";
|
||||
return "interrupt";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeQueueDropPolicy(raw?: string): QueueDropPolicy | undefined {
|
||||
if (!raw) return undefined;
|
||||
const cleaned = raw.trim().toLowerCase();
|
||||
if (cleaned === "old" || cleaned === "oldest") return "old";
|
||||
if (cleaned === "new" || cleaned === "newest") return "new";
|
||||
if (cleaned === "summarize" || cleaned === "summary") return "summarize";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseQueueDebounce(raw?: string): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const parsed = parseDurationMs(raw.trim(), { defaultUnit: "ms" });
|
||||
if (!parsed || parsed < 0) return undefined;
|
||||
return Math.round(parsed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseQueueCap(raw?: string): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num)) return undefined;
|
||||
const cap = Math.floor(num);
|
||||
if (cap < 1) return undefined;
|
||||
return cap;
|
||||
}
|
||||
|
||||
function parseQueueDirectiveArgs(raw: string): {
|
||||
consumed: number;
|
||||
queueMode?: QueueMode;
|
||||
queueReset: boolean;
|
||||
rawMode?: string;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
dropPolicy?: QueueDropPolicy;
|
||||
rawDebounce?: string;
|
||||
rawCap?: string;
|
||||
rawDrop?: string;
|
||||
hasOptions: boolean;
|
||||
} {
|
||||
let i = 0;
|
||||
const len = raw.length;
|
||||
while (i < len && /\s/.test(raw[i])) i += 1;
|
||||
if (raw[i] === ":") {
|
||||
i += 1;
|
||||
while (i < len && /\s/.test(raw[i])) i += 1;
|
||||
}
|
||||
|
||||
let consumed = i;
|
||||
let queueMode: QueueMode | undefined;
|
||||
let queueReset = false;
|
||||
let rawMode: string | undefined;
|
||||
let debounceMs: number | undefined;
|
||||
let cap: number | undefined;
|
||||
let dropPolicy: QueueDropPolicy | undefined;
|
||||
let rawDebounce: string | undefined;
|
||||
let rawCap: string | undefined;
|
||||
let rawDrop: string | undefined;
|
||||
let hasOptions = false;
|
||||
|
||||
const takeToken = (): string | null => {
|
||||
if (i >= len) return null;
|
||||
const start = i;
|
||||
while (i < len && !/\s/.test(raw[i])) i += 1;
|
||||
if (start === i) return null;
|
||||
const token = raw.slice(start, i);
|
||||
while (i < len && /\s/.test(raw[i])) i += 1;
|
||||
return token;
|
||||
};
|
||||
|
||||
while (i < len) {
|
||||
const token = takeToken();
|
||||
if (!token) break;
|
||||
const lowered = token.trim().toLowerCase();
|
||||
if (lowered === "default" || lowered === "reset" || lowered === "clear") {
|
||||
queueReset = true;
|
||||
consumed = i;
|
||||
break;
|
||||
}
|
||||
|
||||
if (lowered.startsWith("debounce:") || lowered.startsWith("debounce=")) {
|
||||
rawDebounce = token.split(/[:=]/)[1] ?? "";
|
||||
debounceMs = parseQueueDebounce(rawDebounce);
|
||||
hasOptions = true;
|
||||
consumed = i;
|
||||
continue;
|
||||
}
|
||||
if (lowered.startsWith("cap:") || lowered.startsWith("cap=")) {
|
||||
rawCap = token.split(/[:=]/)[1] ?? "";
|
||||
cap = parseQueueCap(rawCap);
|
||||
hasOptions = true;
|
||||
consumed = i;
|
||||
continue;
|
||||
}
|
||||
if (lowered.startsWith("drop:") || lowered.startsWith("drop=")) {
|
||||
rawDrop = token.split(/[:=]/)[1] ?? "";
|
||||
dropPolicy = normalizeQueueDropPolicy(rawDrop);
|
||||
hasOptions = true;
|
||||
consumed = i;
|
||||
continue;
|
||||
}
|
||||
|
||||
const mode = normalizeQueueMode(token);
|
||||
if (mode) {
|
||||
queueMode = mode;
|
||||
rawMode = token;
|
||||
consumed = i;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stop at first unrecognized token.
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
consumed,
|
||||
queueMode,
|
||||
queueReset,
|
||||
rawMode,
|
||||
debounceMs,
|
||||
cap,
|
||||
dropPolicy,
|
||||
rawDebounce,
|
||||
rawCap,
|
||||
rawDrop,
|
||||
hasOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractQueueDirective(body?: string): {
|
||||
cleaned: string;
|
||||
queueMode?: QueueMode;
|
||||
queueReset: boolean;
|
||||
rawMode?: string;
|
||||
hasDirective: boolean;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
dropPolicy?: QueueDropPolicy;
|
||||
rawDebounce?: string;
|
||||
rawCap?: string;
|
||||
rawDrop?: string;
|
||||
hasOptions: boolean;
|
||||
} {
|
||||
if (!body)
|
||||
return {
|
||||
cleaned: "",
|
||||
hasDirective: false,
|
||||
queueReset: false,
|
||||
hasOptions: false,
|
||||
};
|
||||
const re = /(?:^|\s)\/queue(?=$|\s|:)/i;
|
||||
const match = re.exec(body);
|
||||
if (!match) {
|
||||
return {
|
||||
cleaned: body.trim(),
|
||||
hasDirective: false,
|
||||
queueReset: false,
|
||||
hasOptions: false,
|
||||
};
|
||||
}
|
||||
const start = match.index + match[0].indexOf("/queue");
|
||||
const argsStart = start + "/queue".length;
|
||||
const args = body.slice(argsStart);
|
||||
const parsed = parseQueueDirectiveArgs(args);
|
||||
const cleanedRaw =
|
||||
body.slice(0, start) + body.slice(argsStart + parsed.consumed);
|
||||
const cleaned = cleanedRaw.replace(/\s+/g, " ").trim();
|
||||
if (!body) return { cleaned: "", hasDirective: false, queueReset: false };
|
||||
const match = body.match(/(?:^|\s)\/queue(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i);
|
||||
const rawMode = match?.[1];
|
||||
const lowered = rawMode?.trim().toLowerCase();
|
||||
const queueReset =
|
||||
lowered === "default" || lowered === "reset" || lowered === "clear";
|
||||
const queueMode = queueReset ? undefined : normalizeQueueMode(rawMode);
|
||||
const cleaned = match
|
||||
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
||||
: body.trim();
|
||||
return {
|
||||
cleaned,
|
||||
queueMode: parsed.queueMode,
|
||||
queueReset: parsed.queueReset,
|
||||
rawMode: parsed.rawMode,
|
||||
debounceMs: parsed.debounceMs,
|
||||
cap: parsed.cap,
|
||||
dropPolicy: parsed.dropPolicy,
|
||||
rawDebounce: parsed.rawDebounce,
|
||||
rawCap: parsed.rawCap,
|
||||
rawDrop: parsed.rawDrop,
|
||||
hasDirective: true,
|
||||
hasOptions: parsed.hasOptions,
|
||||
queueMode,
|
||||
queueReset,
|
||||
rawMode,
|
||||
hasDirective: !!match,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -428,184 +200,6 @@ export function extractReplyToTag(
|
||||
return { cleaned, replyToId, hasTag };
|
||||
}
|
||||
|
||||
function elideText(text: string, limit = 140): string {
|
||||
if (text.length <= limit) return text;
|
||||
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function buildQueueSummaryLine(run: FollowupRun): string {
|
||||
const base = run.summaryLine?.trim() || run.prompt.trim();
|
||||
const cleaned = base.replace(/\s+/g, " ").trim();
|
||||
return elideText(cleaned, 160);
|
||||
}
|
||||
|
||||
function getFollowupQueue(
|
||||
key: string,
|
||||
settings: QueueSettings,
|
||||
): FollowupQueueState {
|
||||
const existing = FOLLOWUP_QUEUES.get(key);
|
||||
if (existing) {
|
||||
existing.mode = settings.mode;
|
||||
existing.debounceMs =
|
||||
typeof settings.debounceMs === "number"
|
||||
? Math.max(0, settings.debounceMs)
|
||||
: existing.debounceMs;
|
||||
existing.cap =
|
||||
typeof settings.cap === "number" && settings.cap > 0
|
||||
? Math.floor(settings.cap)
|
||||
: existing.cap;
|
||||
existing.dropPolicy = settings.dropPolicy ?? existing.dropPolicy;
|
||||
return existing;
|
||||
}
|
||||
const created: FollowupQueueState = {
|
||||
items: [],
|
||||
draining: false,
|
||||
lastEnqueuedAt: 0,
|
||||
mode: settings.mode,
|
||||
debounceMs:
|
||||
typeof settings.debounceMs === "number"
|
||||
? Math.max(0, settings.debounceMs)
|
||||
: DEFAULT_QUEUE_DEBOUNCE_MS,
|
||||
cap:
|
||||
typeof settings.cap === "number" && settings.cap > 0
|
||||
? Math.floor(settings.cap)
|
||||
: DEFAULT_QUEUE_CAP,
|
||||
dropPolicy: settings.dropPolicy ?? DEFAULT_QUEUE_DROP,
|
||||
droppedCount: 0,
|
||||
summaryLines: [],
|
||||
};
|
||||
FOLLOWUP_QUEUES.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function enqueueFollowupRun(
|
||||
key: string,
|
||||
run: FollowupRun,
|
||||
settings: QueueSettings,
|
||||
): boolean {
|
||||
const queue = getFollowupQueue(key, settings);
|
||||
queue.lastEnqueuedAt = Date.now();
|
||||
queue.lastRun = run.run;
|
||||
|
||||
const cap = queue.cap;
|
||||
if (cap > 0 && queue.items.length >= cap) {
|
||||
if (queue.dropPolicy === "new") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dropCount = queue.items.length - cap + 1;
|
||||
const dropped = queue.items.splice(0, dropCount);
|
||||
if (queue.dropPolicy === "summarize") {
|
||||
for (const item of dropped) {
|
||||
queue.droppedCount += 1;
|
||||
queue.summaryLines.push(buildQueueSummaryLine(item));
|
||||
}
|
||||
while (queue.summaryLines.length > cap) queue.summaryLines.shift();
|
||||
}
|
||||
}
|
||||
|
||||
queue.items.push(run);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function waitForQueueDebounce(queue: FollowupQueueState): Promise<void> {
|
||||
const debounceMs = Math.max(0, queue.debounceMs);
|
||||
if (debounceMs <= 0) return;
|
||||
while (true) {
|
||||
const since = Date.now() - queue.lastEnqueuedAt;
|
||||
if (since >= debounceMs) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, debounceMs - since));
|
||||
}
|
||||
}
|
||||
|
||||
function buildSummaryPrompt(queue: FollowupQueueState): string | undefined {
|
||||
if (queue.dropPolicy !== "summarize" || queue.droppedCount <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const lines = [
|
||||
`[Queue overflow] Dropped ${queue.droppedCount} message${queue.droppedCount === 1 ? "" : "s"} due to cap.`,
|
||||
];
|
||||
if (queue.summaryLines.length > 0) {
|
||||
lines.push("Summary:");
|
||||
for (const line of queue.summaryLines) {
|
||||
lines.push(`- ${line}`);
|
||||
}
|
||||
}
|
||||
queue.droppedCount = 0;
|
||||
queue.summaryLines = [];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildCollectPrompt(
|
||||
items: FollowupRun[],
|
||||
summary?: string,
|
||||
): string {
|
||||
const blocks: string[] = ["[Queued messages while agent was busy]"];
|
||||
if (summary) {
|
||||
blocks.push(summary);
|
||||
}
|
||||
items.forEach((item, idx) => {
|
||||
blocks.push(`---\nQueued #${idx + 1}\n${item.prompt}`.trim());
|
||||
});
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
|
||||
function scheduleFollowupDrain(
|
||||
key: string,
|
||||
runFollowup: (run: FollowupRun) => Promise<void>,
|
||||
): void {
|
||||
const queue = FOLLOWUP_QUEUES.get(key);
|
||||
if (!queue || queue.draining) return;
|
||||
queue.draining = true;
|
||||
void (async () => {
|
||||
try {
|
||||
while (queue.items.length > 0 || queue.droppedCount > 0) {
|
||||
await waitForQueueDebounce(queue);
|
||||
if (queue.mode === "collect") {
|
||||
const items = queue.items.splice(0, queue.items.length);
|
||||
const summary = buildSummaryPrompt(queue);
|
||||
const run = items.at(-1)?.run ?? queue.lastRun;
|
||||
if (!run) break;
|
||||
const prompt = buildCollectPrompt(items, summary);
|
||||
await runFollowup({
|
||||
prompt,
|
||||
run,
|
||||
enqueuedAt: Date.now(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const summaryPrompt = buildSummaryPrompt(queue);
|
||||
if (summaryPrompt) {
|
||||
const run = queue.lastRun;
|
||||
if (!run) break;
|
||||
await runFollowup({
|
||||
prompt: summaryPrompt,
|
||||
run,
|
||||
enqueuedAt: Date.now(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
await runFollowup(next);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error?.(
|
||||
`followup queue drain failed for ${key}: ${String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
queue.draining = false;
|
||||
if (queue.items.length === 0 && queue.droppedCount === 0) {
|
||||
FOLLOWUP_QUEUES.delete(key);
|
||||
} else {
|
||||
scheduleFollowupDrain(key, runFollowup);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function isAbortTrigger(text?: string): boolean {
|
||||
if (!text) return false;
|
||||
const normalized = text.trim().toLowerCase();
|
||||
@@ -657,58 +251,32 @@ function stripMentions(
|
||||
|
||||
function defaultQueueModeForSurface(surface?: string): QueueMode {
|
||||
const normalized = surface?.trim().toLowerCase();
|
||||
if (normalized === "discord") return "collect";
|
||||
if (normalized === "webchat") return "collect";
|
||||
if (normalized === "whatsapp") return "collect";
|
||||
if (normalized === "telegram") return "collect";
|
||||
if (normalized === "imessage") return "collect";
|
||||
if (normalized === "signal") return "collect";
|
||||
return "collect";
|
||||
if (normalized === "discord") return "queue";
|
||||
if (normalized === "webchat") return "queue";
|
||||
return "interrupt";
|
||||
}
|
||||
|
||||
function resolveQueueSettings(params: {
|
||||
function resolveQueueMode(params: {
|
||||
cfg: ClawdisConfig;
|
||||
surface?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
inlineMode?: QueueMode;
|
||||
inlineOptions?: Partial<QueueSettings>;
|
||||
}): QueueSettings {
|
||||
}): QueueMode {
|
||||
const surfaceKey = params.surface?.trim().toLowerCase();
|
||||
const queueCfg = params.cfg.routing?.queue;
|
||||
const surfaceModeRaw =
|
||||
const surfaceMode =
|
||||
surfaceKey && queueCfg?.bySurface
|
||||
? (queueCfg.bySurface as Record<string, string | undefined>)[surfaceKey]
|
||||
? (queueCfg.bySurface as Record<string, QueueMode | undefined>)[
|
||||
surfaceKey
|
||||
]
|
||||
: undefined;
|
||||
const resolvedMode =
|
||||
return (
|
||||
params.inlineMode ??
|
||||
normalizeQueueMode(params.sessionEntry?.queueMode) ??
|
||||
normalizeQueueMode(surfaceModeRaw) ??
|
||||
normalizeQueueMode(queueCfg?.mode) ??
|
||||
defaultQueueModeForSurface(surfaceKey);
|
||||
|
||||
const debounceRaw =
|
||||
params.inlineOptions?.debounceMs ??
|
||||
params.sessionEntry?.queueDebounceMs ??
|
||||
queueCfg?.debounceMs ??
|
||||
DEFAULT_QUEUE_DEBOUNCE_MS;
|
||||
const capRaw =
|
||||
params.inlineOptions?.cap ??
|
||||
params.sessionEntry?.queueCap ??
|
||||
queueCfg?.cap ??
|
||||
DEFAULT_QUEUE_CAP;
|
||||
const dropRaw =
|
||||
params.inlineOptions?.dropPolicy ??
|
||||
params.sessionEntry?.queueDrop ??
|
||||
normalizeQueueDropPolicy(queueCfg?.drop) ??
|
||||
DEFAULT_QUEUE_DROP;
|
||||
|
||||
return {
|
||||
mode: resolvedMode,
|
||||
debounceMs:
|
||||
typeof debounceRaw === "number" ? Math.max(0, debounceRaw) : undefined,
|
||||
cap: typeof capRaw === "number" ? Math.max(1, Math.floor(capRaw)) : undefined,
|
||||
dropPolicy: dropRaw,
|
||||
};
|
||||
params.sessionEntry?.queueMode ??
|
||||
surfaceMode ??
|
||||
queueCfg?.mode ??
|
||||
defaultQueueModeForSurface(surfaceKey)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getReplyFromConfig(
|
||||
@@ -908,9 +476,6 @@ export async function getReplyFromConfig(
|
||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||
queueMode: baseEntry?.queueMode,
|
||||
queueDebounceMs: baseEntry?.queueDebounceMs,
|
||||
queueCap: baseEntry?.queueCap,
|
||||
queueDrop: baseEntry?.queueDrop,
|
||||
displayName: baseEntry?.displayName,
|
||||
chatType: baseEntry?.chatType,
|
||||
surface: baseEntry?.surface,
|
||||
@@ -978,12 +543,6 @@ export async function getReplyFromConfig(
|
||||
queueMode: inlineQueueMode,
|
||||
queueReset: inlineQueueReset,
|
||||
rawMode: rawQueueMode,
|
||||
debounceMs: inlineQueueDebounceMs,
|
||||
cap: inlineQueueCap,
|
||||
dropPolicy: inlineQueueDrop,
|
||||
rawDebounce: rawQueueDebounce,
|
||||
rawCap: rawQueueCap,
|
||||
rawDrop: rawQueueDrop,
|
||||
hasDirective: hasQueueDirective,
|
||||
} = extractQueueDirective(modelCleaned);
|
||||
sessionCtx.Body = queueCleaned;
|
||||
@@ -1047,7 +606,9 @@ export async function getReplyFromConfig(
|
||||
agentCfg?.blockStreamingDefault === "off" ? "off" : "on";
|
||||
// TODO(steipete): Default to message_end for now; figure out why text_end breaks and whether we can revert.
|
||||
const resolvedBlockStreamingBreak =
|
||||
agentCfg?.blockStreamingBreak === "text_end" ? "text_end" : "message_end";
|
||||
agentCfg?.blockStreamingBreak === "text_end"
|
||||
? "text_end"
|
||||
: "message_end";
|
||||
const blockStreamingEnabled = resolvedBlockStreaming === "on";
|
||||
const streamedPayloadKeys = new Set<string>();
|
||||
const pendingBlockTasks = new Set<Promise<void>>();
|
||||
@@ -1198,50 +759,11 @@ export async function getReplyFromConfig(
|
||||
text: `Unrecognized verbose level "${rawVerboseLevel ?? ""}". Valid levels: off, on.`,
|
||||
};
|
||||
}
|
||||
const queueModeInvalid =
|
||||
hasQueueDirective &&
|
||||
!inlineQueueMode &&
|
||||
!inlineQueueReset &&
|
||||
Boolean(rawQueueMode);
|
||||
const queueDebounceInvalid =
|
||||
hasQueueDirective &&
|
||||
rawQueueDebounce !== undefined &&
|
||||
typeof inlineQueueDebounceMs !== "number";
|
||||
const queueCapInvalid =
|
||||
hasQueueDirective &&
|
||||
rawQueueCap !== undefined &&
|
||||
typeof inlineQueueCap !== "number";
|
||||
const queueDropInvalid =
|
||||
hasQueueDirective && rawQueueDrop !== undefined && !inlineQueueDrop;
|
||||
if (
|
||||
queueModeInvalid ||
|
||||
queueDebounceInvalid ||
|
||||
queueCapInvalid ||
|
||||
queueDropInvalid
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
if (queueModeInvalid) {
|
||||
errors.push(
|
||||
`Unrecognized queue mode "${rawQueueMode ?? ""}". Valid modes: steer, followup, collect, steer+backlog, interrupt.`,
|
||||
);
|
||||
}
|
||||
if (queueDebounceInvalid) {
|
||||
errors.push(
|
||||
`Invalid debounce "${rawQueueDebounce ?? ""}". Use ms/s/m (e.g. debounce:1500ms, debounce:2s).`,
|
||||
);
|
||||
}
|
||||
if (queueCapInvalid) {
|
||||
errors.push(
|
||||
`Invalid cap "${rawQueueCap ?? ""}". Use a positive integer (e.g. cap:10).`,
|
||||
);
|
||||
}
|
||||
if (queueDropInvalid) {
|
||||
errors.push(
|
||||
`Invalid drop policy "${rawQueueDrop ?? ""}". Use drop:old, drop:new, or drop:summarize.`,
|
||||
);
|
||||
}
|
||||
if (hasQueueDirective && !inlineQueueMode && !inlineQueueReset) {
|
||||
cleanupTyping();
|
||||
return { text: errors.join(" ") };
|
||||
return {
|
||||
text: `Unrecognized queue mode "${rawQueueMode ?? ""}". Valid modes: queue, interrupt.`,
|
||||
};
|
||||
}
|
||||
|
||||
let modelSelection:
|
||||
@@ -1306,20 +828,8 @@ export async function getReplyFromConfig(
|
||||
}
|
||||
if (hasQueueDirective && inlineQueueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
delete sessionEntry.queueDebounceMs;
|
||||
delete sessionEntry.queueCap;
|
||||
delete sessionEntry.queueDrop;
|
||||
} else if (hasQueueDirective) {
|
||||
if (inlineQueueMode) sessionEntry.queueMode = inlineQueueMode;
|
||||
if (typeof inlineQueueDebounceMs === "number") {
|
||||
sessionEntry.queueDebounceMs = inlineQueueDebounceMs;
|
||||
}
|
||||
if (typeof inlineQueueCap === "number") {
|
||||
sessionEntry.queueCap = inlineQueueCap;
|
||||
}
|
||||
if (inlineQueueDrop) {
|
||||
sessionEntry.queueDrop = inlineQueueDrop;
|
||||
}
|
||||
} else if (hasQueueDirective && inlineQueueMode) {
|
||||
sessionEntry.queueMode = inlineQueueMode;
|
||||
}
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
@@ -1357,17 +867,6 @@ export async function getReplyFromConfig(
|
||||
} else if (hasQueueDirective && inlineQueueReset) {
|
||||
parts.push(`${SYSTEM_MARK} Queue mode reset to default.`);
|
||||
}
|
||||
if (hasQueueDirective && typeof inlineQueueDebounceMs === "number") {
|
||||
parts.push(
|
||||
`${SYSTEM_MARK} Queue debounce set to ${inlineQueueDebounceMs}ms.`,
|
||||
);
|
||||
}
|
||||
if (hasQueueDirective && typeof inlineQueueCap === "number") {
|
||||
parts.push(`${SYSTEM_MARK} Queue cap set to ${inlineQueueCap}.`);
|
||||
}
|
||||
if (hasQueueDirective && inlineQueueDrop) {
|
||||
parts.push(`${SYSTEM_MARK} Queue drop set to ${inlineQueueDrop}.`);
|
||||
}
|
||||
const ack = parts.join(" ").trim();
|
||||
cleanupTyping();
|
||||
return { text: ack || "OK." };
|
||||
@@ -1430,9 +929,6 @@ export async function getReplyFromConfig(
|
||||
}
|
||||
if (hasQueueDirective && inlineQueueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
delete sessionEntry.queueDebounceMs;
|
||||
delete sessionEntry.queueCap;
|
||||
delete sessionEntry.queueDrop;
|
||||
updated = true;
|
||||
}
|
||||
if (updated) {
|
||||
@@ -1443,14 +939,6 @@ export async function getReplyFromConfig(
|
||||
}
|
||||
const perMessageQueueMode =
|
||||
hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined;
|
||||
const perMessageQueueOptions =
|
||||
hasQueueDirective && !inlineQueueReset
|
||||
? {
|
||||
debounceMs: inlineQueueDebounceMs,
|
||||
cap: inlineQueueCap,
|
||||
dropPolicy: inlineQueueDrop,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||
const isWhatsAppSurface =
|
||||
@@ -1646,15 +1134,12 @@ export async function getReplyFromConfig(
|
||||
activation === "always"
|
||||
? "Be extremely selective: reply only when you are directly addressed, asked a question, or can add clear value. Otherwise stay silent."
|
||||
: undefined;
|
||||
const lurkLine =
|
||||
"Be a good group participant: lurk and follow the conversation, but only chime in when you have something genuinely helpful or relevant to add. Don't feel obligated to respond to every message — quality over quantity. Even when lurking silently, you can use emoji reactions to acknowledge messages, show support, or react to humor — reactions are always appreciated and don't clutter the chat.";
|
||||
return [
|
||||
subjectLine,
|
||||
membersLine,
|
||||
activationLine,
|
||||
silenceLine,
|
||||
cautionLine,
|
||||
lurkLine,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
@@ -1829,18 +1314,17 @@ export async function getReplyFromConfig(
|
||||
.trim()
|
||||
: queueBodyBase;
|
||||
|
||||
const resolvedQueue = resolveQueueSettings({
|
||||
const resolvedQueueMode = resolveQueueMode({
|
||||
cfg,
|
||||
surface: sessionCtx.Surface,
|
||||
sessionEntry,
|
||||
inlineMode: perMessageQueueMode,
|
||||
inlineOptions: perMessageQueueOptions,
|
||||
});
|
||||
const sessionLaneKey = resolveEmbeddedSessionLane(
|
||||
sessionKey ?? sessionIdFinal,
|
||||
);
|
||||
const laneSize = getQueueSize(sessionLaneKey);
|
||||
if (resolvedQueue.mode === "interrupt" && laneSize > 0) {
|
||||
if (resolvedQueueMode === "interrupt" && laneSize > 0) {
|
||||
const cleared = clearCommandLane(sessionLaneKey);
|
||||
const aborted = abortEmbeddedPiRun(sessionIdFinal);
|
||||
logVerbose(
|
||||
@@ -1848,54 +1332,10 @@ export async function getReplyFromConfig(
|
||||
);
|
||||
}
|
||||
|
||||
const queueKey = sessionKey ?? sessionIdFinal;
|
||||
const isActive = isEmbeddedPiRunActive(sessionIdFinal);
|
||||
const isStreaming = isEmbeddedPiRunStreaming(sessionIdFinal);
|
||||
const shouldSteer =
|
||||
resolvedQueue.mode === "steer" || resolvedQueue.mode === "steer-backlog";
|
||||
const shouldFollowup =
|
||||
resolvedQueue.mode === "followup" ||
|
||||
resolvedQueue.mode === "collect" ||
|
||||
resolvedQueue.mode === "steer-backlog";
|
||||
|
||||
const followupRun: FollowupRun = {
|
||||
prompt: queuedBody,
|
||||
summaryLine: baseBodyTrimmedRaw,
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
sessionId: sessionIdFinal,
|
||||
sessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
skillsSnapshot,
|
||||
provider,
|
||||
model,
|
||||
thinkLevel: resolvedThinkLevel,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
timeoutMs,
|
||||
blockReplyBreak: resolvedBlockStreamingBreak,
|
||||
ownerNumbers: ownerList.length > 0 ? ownerList : undefined,
|
||||
extraSystemPrompt: groupIntro || undefined,
|
||||
enforceFinalTag: provider === "ollama" ? true : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
if (shouldSteer && isStreaming) {
|
||||
const steered = queueEmbeddedPiMessage(sessionIdFinal, queuedBody);
|
||||
if (steered && !shouldFollowup) {
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (isActive && (shouldFollowup || resolvedQueue.mode === "steer")) {
|
||||
enqueueFollowupRun(queueKey, followupRun, resolvedQueue);
|
||||
if (
|
||||
resolvedQueueMode === "queue" &&
|
||||
queueEmbeddedPiMessage(sessionIdFinal, queuedBody)
|
||||
) {
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
@@ -1905,143 +1345,6 @@ export async function getReplyFromConfig(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sendFollowupPayloads = async (payloads: ReplyPayload[]) => {
|
||||
if (!opts?.onBlockReply) {
|
||||
logVerbose("followup queue: no onBlockReply handler; dropping payloads");
|
||||
return;
|
||||
}
|
||||
for (const payload of payloads) {
|
||||
if (!payload?.text && !payload?.mediaUrl && !payload?.mediaUrls?.length) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
payload.text?.trim() === SILENT_REPLY_TOKEN &&
|
||||
!payload.mediaUrl &&
|
||||
!payload.mediaUrls?.length
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await startTypingOnText(payload.text);
|
||||
await opts.onBlockReply(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const runFollowupTurn = async (queued: FollowupRun) => {
|
||||
const runId = crypto.randomUUID();
|
||||
if (queued.run.sessionKey) {
|
||||
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey });
|
||||
}
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
try {
|
||||
runResult = await runEmbeddedPiAgent({
|
||||
sessionId: queued.run.sessionId,
|
||||
sessionKey: queued.run.sessionKey,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
config: queued.run.config,
|
||||
skillsSnapshot: queued.run.skillsSnapshot,
|
||||
prompt: queued.prompt,
|
||||
extraSystemPrompt: queued.run.extraSystemPrompt,
|
||||
ownerNumbers: queued.run.ownerNumbers,
|
||||
enforceFinalTag: queued.run.enforceFinalTag,
|
||||
provider: queued.run.provider,
|
||||
model: queued.run.model,
|
||||
thinkLevel: queued.run.thinkLevel,
|
||||
verboseLevel: queued.run.verboseLevel,
|
||||
timeoutMs: queued.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: queued.run.blockReplyBreak,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
defaultRuntime.error?.(
|
||||
`Followup agent failed before reply: ${message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadArray = runResult.payloads ?? [];
|
||||
if (payloadArray.length === 0) return;
|
||||
const sanitizedPayloads = payloadArray.flatMap((payload) => {
|
||||
const text = payload.text;
|
||||
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
const hasMedia =
|
||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return [];
|
||||
return [{ ...payload, text: stripped.text }];
|
||||
});
|
||||
|
||||
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
|
||||
.map((payload) => {
|
||||
const { cleaned, replyToId } = extractReplyToTag(payload.text);
|
||||
return {
|
||||
...payload,
|
||||
text: cleaned ? cleaned : undefined,
|
||||
replyToId: replyToId ?? payload.replyToId,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(payload) =>
|
||||
payload.text ||
|
||||
payload.mediaUrl ||
|
||||
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
||||
);
|
||||
|
||||
if (replyTaggedPayloads.length === 0) return;
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed = runResult.meta.agentMeta?.model ?? defaultModel;
|
||||
const contextTokensUsed =
|
||||
agentCfg?.contextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
sessionEntry?.contextTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
|
||||
if (usage) {
|
||||
const entry = sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens: promptTokens > 0 ? promptTokens : usage.total ?? input,
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
} else if (modelUsed || contextTokensUsed) {
|
||||
const entry = sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
model: modelUsed ?? entry.model,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await sendFollowupPayloads(replyTaggedPayloads);
|
||||
};
|
||||
|
||||
const finalizeWithFollowup = <T>(value: T): T => {
|
||||
scheduleFollowupDrain(queueKey, runFollowupTurn);
|
||||
return value;
|
||||
};
|
||||
|
||||
let didLogHeartbeatStrip = false;
|
||||
try {
|
||||
if (shouldEagerType) {
|
||||
@@ -2168,11 +1471,11 @@ export async function getReplyFromConfig(
|
||||
const isContextOverflow =
|
||||
/context.*overflow|too large|context window/i.test(message);
|
||||
defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
|
||||
return finalizeWithFollowup({
|
||||
return {
|
||||
text: isContextOverflow
|
||||
? "⚠️ Context overflow - conversation too long. Starting fresh might help!"
|
||||
: "⚠️ Agent failed. Check gateway logs.",
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -2189,7 +1492,7 @@ export async function getReplyFromConfig(
|
||||
}
|
||||
|
||||
const payloadArray = runResult.payloads ?? [];
|
||||
if (payloadArray.length === 0) return finalizeWithFollowup(undefined);
|
||||
if (payloadArray.length === 0) return undefined;
|
||||
if (pendingBlockTasks.size > 0) {
|
||||
await Promise.allSettled(pendingBlockTasks);
|
||||
}
|
||||
@@ -2235,7 +1538,7 @@ export async function getReplyFromConfig(
|
||||
)
|
||||
: replyTaggedPayloads;
|
||||
|
||||
if (filteredPayloads.length === 0) return finalizeWithFollowup(undefined);
|
||||
if (filteredPayloads.length === 0) return undefined;
|
||||
|
||||
const shouldSignalTyping = filteredPayloads.some((payload) => {
|
||||
const trimmed = payload.text?.trim();
|
||||
@@ -2300,9 +1603,7 @@ export async function getReplyFromConfig(
|
||||
];
|
||||
}
|
||||
|
||||
return finalizeWithFollowup(
|
||||
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
||||
);
|
||||
return finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads;
|
||||
} finally {
|
||||
cleanupTyping();
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
debc115fd2b264d3b3090e28b733551ca5f7532287d827c13fc579e5a7a95b9d
|
||||
ac7d607313f8977b3fd7b5807201e0bf79d2c7366dbeff48747352d55cd3b253
|
||||
@@ -24,7 +24,6 @@ import { registerGatewayCli } from "./gateway-cli.js";
|
||||
import { registerHooksCli } from "./hooks-cli.js";
|
||||
import { registerNodesCli } from "./nodes-cli.js";
|
||||
import { forceFreePort } from "./ports.js";
|
||||
import { registerTuiCli } from "./tui-cli.js";
|
||||
|
||||
export { forceFreePort };
|
||||
|
||||
@@ -395,7 +394,6 @@ Examples:
|
||||
registerCanvasCli(program);
|
||||
registerGatewayCli(program);
|
||||
registerNodesCli(program);
|
||||
registerTuiCli(program);
|
||||
registerCronCli(program);
|
||||
registerDnsCli(program);
|
||||
registerHooksCli(program);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { runTui } from "../tui/tui.js";
|
||||
|
||||
export function registerTuiCli(program: Command) {
|
||||
program
|
||||
.command("tui")
|
||||
.description("Open a terminal UI connected to the Gateway")
|
||||
.option(
|
||||
"--url <url>",
|
||||
"Gateway WebSocket URL (defaults to gateway.remote.url when configured)",
|
||||
)
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--password <password>", "Gateway password (if required)")
|
||||
.option(
|
||||
"--session <key>",
|
||||
"Session key (default: session.mainKey from config)",
|
||||
)
|
||||
.option("--deliver", "Deliver assistant replies", false)
|
||||
.option("--thinking <level>", "Thinking level override")
|
||||
.option("--timeout-ms <ms>", "Agent timeout in ms", "30000")
|
||||
.option("--history-limit <n>", "History entries to load", "200")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const timeoutMs = Number.parseInt(String(opts.timeoutMs ?? "30000"), 10);
|
||||
const historyLimit = Number.parseInt(String(opts.historyLimit ?? "200"), 10);
|
||||
await runTui({
|
||||
url: opts.url as string | undefined,
|
||||
token: opts.token as string | undefined,
|
||||
password: opts.password as string | undefined,
|
||||
session: opts.session as string | undefined,
|
||||
deliver: Boolean(opts.deliver),
|
||||
thinking: opts.thinking as string | undefined,
|
||||
timeoutMs: Number.isNaN(timeoutMs) ? undefined : timeoutMs,
|
||||
historyLimit: Number.isNaN(historyLimit) ? undefined : historyLimit,
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
/**
|
||||
* VPS-aware Antigravity OAuth flow.
|
||||
*
|
||||
* On local machines: Uses the standard pi-ai loginAntigravity with local server callback.
|
||||
* On VPS/SSH/headless: Shows URL and prompts user to paste the callback URL manually.
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { loginAntigravity, type OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
|
||||
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
const CLIENT_ID = decode("MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==");
|
||||
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
|
||||
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
// Antigravity requires these additional scopes
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
];
|
||||
// Fallback project ID when discovery fails (same as pi-ai)
|
||||
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
||||
|
||||
/**
|
||||
* Detect if running in WSL (Windows Subsystem for Linux).
|
||||
*/
|
||||
function isWSL(): boolean {
|
||||
if (process.platform !== "linux") return false;
|
||||
try {
|
||||
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return release.includes("microsoft") || release.includes("wsl");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if running in WSL2 specifically.
|
||||
*/
|
||||
function isWSL2(): boolean {
|
||||
if (!isWSL()) return false;
|
||||
try {
|
||||
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return version.includes("wsl2") || version.includes("microsoft-standard");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if running in a remote/headless environment where localhost callback won't work.
|
||||
*/
|
||||
export function isRemoteEnvironment(): boolean {
|
||||
// SSH session indicators
|
||||
if (
|
||||
process.env.SSH_CLIENT ||
|
||||
process.env.SSH_TTY ||
|
||||
process.env.SSH_CONNECTION
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Container/cloud environments
|
||||
if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Linux without display (and not WSL which can use wslview)
|
||||
if (
|
||||
process.platform === "linux" &&
|
||||
!process.env.DISPLAY &&
|
||||
!process.env.WAYLAND_DISPLAY &&
|
||||
!isWSL()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to skip the local OAuth callback server.
|
||||
*/
|
||||
export function shouldUseManualOAuthFlow(): boolean {
|
||||
return isWSL2() || isRemoteEnvironment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE verifier and challenge using Node.js crypto.
|
||||
*/
|
||||
function generatePKCESync(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256")
|
||||
.update(verifier)
|
||||
.digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Antigravity OAuth authorization URL.
|
||||
*/
|
||||
function buildAuthUrl(challenge: string, verifier: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES.join(" "),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state: verifier,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the OAuth callback URL or code input.
|
||||
*/
|
||||
function parseCallbackInput(
|
||||
input: string,
|
||||
expectedState: string,
|
||||
): { code: string; state: string } | { error: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return { error: "No input provided" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Try parsing as full URL
|
||||
const url = new URL(trimmed);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state") ?? expectedState;
|
||||
|
||||
if (!code) {
|
||||
return { error: "Missing 'code' parameter in URL" };
|
||||
}
|
||||
if (!state) {
|
||||
return { error: "Missing 'state' parameter. Paste the full URL." };
|
||||
}
|
||||
|
||||
return { code, state };
|
||||
} catch {
|
||||
// Not a URL - treat as raw code (need state from original request)
|
||||
if (!expectedState) {
|
||||
return { error: "Paste the full redirect URL, not just the code." };
|
||||
}
|
||||
return { code: trimmed, state: expectedState };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens.
|
||||
*/
|
||||
async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
verifier: string,
|
||||
): Promise<OAuthCredentials> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
if (!data.refresh_token) {
|
||||
throw new Error("No refresh token received. Please try again.");
|
||||
}
|
||||
|
||||
// Fetch user email
|
||||
const email = await getUserEmail(data.access_token);
|
||||
|
||||
// Fetch project ID
|
||||
const projectId = await fetchProjectId(data.access_token);
|
||||
|
||||
// Calculate expiry time (same as pi-ai: current time + expires_in - 5 min buffer)
|
||||
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
|
||||
|
||||
return {
|
||||
refresh: data.refresh_token,
|
||||
access: data.access_token,
|
||||
expires: expiresAt,
|
||||
projectId,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user email from access token.
|
||||
*/
|
||||
async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { email?: string };
|
||||
return data.email;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, email is optional
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the Antigravity project ID using the access token.
|
||||
*/
|
||||
async function fetchProjectId(accessToken: string): Promise<string> {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"Client-Metadata": JSON.stringify({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
}),
|
||||
};
|
||||
|
||||
// Try endpoints in order: prod first, then sandbox
|
||||
const endpoints = [
|
||||
"https://cloudcode-pa.googleapis.com",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) continue;
|
||||
|
||||
const data = (await response.json()) as {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
};
|
||||
|
||||
if (typeof data.cloudaicompanionProject === "string") {
|
||||
return data.cloudaicompanionProject;
|
||||
}
|
||||
if (
|
||||
data.cloudaicompanionProject &&
|
||||
typeof data.cloudaicompanionProject === "object" &&
|
||||
data.cloudaicompanionProject.id
|
||||
) {
|
||||
return data.cloudaicompanionProject.id;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Use fallback project ID
|
||||
return DEFAULT_PROJECT_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for input via readline.
|
||||
*/
|
||||
async function promptInput(message: string): Promise<string> {
|
||||
const rl = createInterface({ input: stdin, output: stdout });
|
||||
try {
|
||||
return (await rl.question(message)).trim();
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VPS-aware Antigravity OAuth login.
|
||||
*
|
||||
* On local machines: Uses the standard pi-ai flow with automatic localhost callback.
|
||||
* On VPS/SSH: Shows URL and prompts user to paste the callback URL manually.
|
||||
*/
|
||||
export async function loginAntigravityVpsAware(
|
||||
onUrl: (url: string) => void | Promise<void>,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<OAuthCredentials | null> {
|
||||
// Check if we're in a remote environment
|
||||
if (shouldUseManualOAuthFlow()) {
|
||||
return loginAntigravityManual(onUrl, onProgress);
|
||||
}
|
||||
|
||||
// Use the standard pi-ai flow for local environments
|
||||
try {
|
||||
return await loginAntigravity(
|
||||
async ({ url, instructions }) => {
|
||||
await onUrl(url);
|
||||
onProgress?.(instructions ?? "Complete sign-in in browser...");
|
||||
},
|
||||
(msg) => onProgress?.(msg),
|
||||
);
|
||||
} catch (err) {
|
||||
// If the local server fails (e.g., port in use), fall back to manual
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes("EADDRINUSE") ||
|
||||
err.message.includes("port") ||
|
||||
err.message.includes("listen"))
|
||||
) {
|
||||
onProgress?.("Local callback server failed. Switching to manual mode...");
|
||||
return loginAntigravityManual(onUrl, onProgress);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual Antigravity OAuth login for VPS/headless environments.
|
||||
*
|
||||
* Shows the OAuth URL and prompts user to paste the callback URL.
|
||||
*/
|
||||
export async function loginAntigravityManual(
|
||||
onUrl: (url: string) => void | Promise<void>,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<OAuthCredentials | null> {
|
||||
const { verifier, challenge } = generatePKCESync();
|
||||
const authUrl = buildAuthUrl(challenge, verifier);
|
||||
|
||||
// Show the URL to the user
|
||||
await onUrl(authUrl);
|
||||
|
||||
onProgress?.("Waiting for you to paste the callback URL...");
|
||||
|
||||
console.log("\n");
|
||||
console.log("=".repeat(60));
|
||||
console.log("VPS/Remote Mode - Manual OAuth");
|
||||
console.log("=".repeat(60));
|
||||
console.log("\n1. Open the URL above in your LOCAL browser");
|
||||
console.log("2. Complete the Google sign-in");
|
||||
console.log("3. Your browser will redirect to a localhost URL that won't load");
|
||||
console.log("4. Copy the ENTIRE URL from your browser's address bar");
|
||||
console.log("5. Paste it below\n");
|
||||
console.log("The URL will look like:");
|
||||
console.log("http://localhost:51121/oauth-callback?code=xxx&state=yyy\n");
|
||||
|
||||
const callbackInput = await promptInput("Paste the redirect URL here: ");
|
||||
|
||||
const parsed = parseCallbackInput(callbackInput, verifier);
|
||||
if ("error" in parsed) {
|
||||
throw new Error(parsed.error);
|
||||
}
|
||||
|
||||
// Verify state matches
|
||||
if (parsed.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - please try again");
|
||||
}
|
||||
|
||||
onProgress?.("Exchanging authorization code for tokens...");
|
||||
|
||||
return exchangeCodeForTokens(parsed.code, verifier);
|
||||
}
|
||||
@@ -10,15 +10,7 @@ import {
|
||||
spinner,
|
||||
text,
|
||||
} from "@clack/prompts";
|
||||
import {
|
||||
loginAnthropic,
|
||||
type OAuthCredentials,
|
||||
} from "@mariozechner/pi-ai";
|
||||
|
||||
import {
|
||||
loginAntigravityVpsAware,
|
||||
isRemoteEnvironment,
|
||||
} from "./antigravity-oauth.js";
|
||||
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import {
|
||||
@@ -231,17 +223,13 @@ async function promptAuthConfig(
|
||||
message: "Model/auth choice",
|
||||
options: [
|
||||
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
|
||||
{
|
||||
value: "antigravity",
|
||||
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
||||
},
|
||||
{ value: "apiKey", label: "Anthropic API key" },
|
||||
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
|
||||
{ value: "skip", label: "Skip for now" },
|
||||
],
|
||||
}),
|
||||
runtime,
|
||||
) as "oauth" | "antigravity" | "apiKey" | "minimax" | "skip";
|
||||
) as "oauth" | "apiKey" | "minimax" | "skip";
|
||||
|
||||
let next = cfg;
|
||||
|
||||
@@ -278,59 +266,6 @@ async function promptAuthConfig(
|
||||
spin.stop("OAuth failed");
|
||||
runtime.error(String(err));
|
||||
}
|
||||
} else if (authChoice === "antigravity") {
|
||||
const isRemote = isRemoteEnvironment();
|
||||
note(
|
||||
isRemote
|
||||
? [
|
||||
"You are running in a remote/VPS environment.",
|
||||
"A URL will be shown for you to open in your LOCAL browser.",
|
||||
"After signing in, copy the redirect URL and paste it back here.",
|
||||
].join("\n")
|
||||
: [
|
||||
"Browser will open for Google authentication.",
|
||||
"Sign in with your Google account that has Antigravity access.",
|
||||
"The callback will be captured automatically on localhost:51121.",
|
||||
].join("\n"),
|
||||
"Google Antigravity OAuth",
|
||||
);
|
||||
const spin = spinner();
|
||||
spin.start("Starting OAuth flow…");
|
||||
let oauthCreds: OAuthCredentials | null = null;
|
||||
try {
|
||||
oauthCreds = await loginAntigravityVpsAware(
|
||||
async (url) => {
|
||||
if (isRemote) {
|
||||
spin.stop("OAuth URL ready");
|
||||
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
|
||||
} else {
|
||||
spin.message("Complete sign-in in browser…");
|
||||
await openUrl(url);
|
||||
runtime.log(`Open: ${url}`);
|
||||
}
|
||||
},
|
||||
(msg) => spin.message(msg),
|
||||
);
|
||||
spin.stop("Antigravity OAuth complete");
|
||||
if (oauthCreds) {
|
||||
await writeOAuthCredentials("google-antigravity", oauthCreds);
|
||||
// Set default model to Claude Opus 4.5 via Antigravity
|
||||
next = {
|
||||
...next,
|
||||
agent: {
|
||||
...next.agent,
|
||||
model: "google-antigravity/claude-opus-4-5-thinking",
|
||||
},
|
||||
};
|
||||
note(
|
||||
"Default model set to google-antigravity/claude-opus-4-5-thinking",
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
spin.stop("Antigravity OAuth failed");
|
||||
runtime.error(String(err));
|
||||
}
|
||||
} else if (authChoice === "apiKey") {
|
||||
const key = guardCancel(
|
||||
await text({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { resolveClawdisAgentDir } from "../agents/agent-paths.js";
|
||||
@@ -9,7 +9,7 @@ import type { ClawdisConfig } from "../config/config.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
|
||||
export async function writeOAuthCredentials(
|
||||
provider: OAuthProvider,
|
||||
provider: "anthropic",
|
||||
creds: OAuthCredentials,
|
||||
): Promise<void> {
|
||||
const dir = path.join(CONFIG_DIR, "credentials");
|
||||
|
||||
@@ -9,15 +9,7 @@ import {
|
||||
spinner,
|
||||
text,
|
||||
} from "@clack/prompts";
|
||||
import {
|
||||
loginAnthropic,
|
||||
type OAuthCredentials,
|
||||
} from "@mariozechner/pi-ai";
|
||||
|
||||
import {
|
||||
loginAntigravityVpsAware,
|
||||
isRemoteEnvironment,
|
||||
} from "./antigravity-oauth.js";
|
||||
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import {
|
||||
@@ -206,10 +198,6 @@ export async function runInteractiveOnboarding(
|
||||
message: "Model/auth choice",
|
||||
options: [
|
||||
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
|
||||
{
|
||||
value: "antigravity",
|
||||
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
||||
},
|
||||
{ value: "apiKey", label: "Anthropic API key" },
|
||||
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
|
||||
{ value: "skip", label: "Skip for now" },
|
||||
@@ -251,59 +239,6 @@ export async function runInteractiveOnboarding(
|
||||
spin.stop("OAuth failed");
|
||||
runtime.error(String(err));
|
||||
}
|
||||
} else if (authChoice === "antigravity") {
|
||||
const isRemote = isRemoteEnvironment();
|
||||
note(
|
||||
isRemote
|
||||
? [
|
||||
"You are running in a remote/VPS environment.",
|
||||
"A URL will be shown for you to open in your LOCAL browser.",
|
||||
"After signing in, copy the redirect URL and paste it back here.",
|
||||
].join("\n")
|
||||
: [
|
||||
"Browser will open for Google authentication.",
|
||||
"Sign in with your Google account that has Antigravity access.",
|
||||
"The callback will be captured automatically on localhost:51121.",
|
||||
].join("\n"),
|
||||
"Google Antigravity OAuth",
|
||||
);
|
||||
const spin = spinner();
|
||||
spin.start("Starting OAuth flow…");
|
||||
let oauthCreds: OAuthCredentials | null = null;
|
||||
try {
|
||||
oauthCreds = await loginAntigravityVpsAware(
|
||||
async (url) => {
|
||||
if (isRemote) {
|
||||
spin.stop("OAuth URL ready");
|
||||
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
|
||||
} else {
|
||||
spin.message("Complete sign-in in browser…");
|
||||
await openUrl(url);
|
||||
runtime.log(`Open: ${url}`);
|
||||
}
|
||||
},
|
||||
(msg) => spin.message(msg),
|
||||
);
|
||||
spin.stop("Antigravity OAuth complete");
|
||||
if (oauthCreds) {
|
||||
await writeOAuthCredentials("google-antigravity", oauthCreds);
|
||||
// Set default model to Claude Opus 4.5 via Antigravity
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
agent: {
|
||||
...nextConfig.agent,
|
||||
model: "google-antigravity/claude-opus-4-5-thinking",
|
||||
},
|
||||
};
|
||||
note(
|
||||
"Default model set to google-antigravity/claude-opus-4-5-thinking",
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
spin.stop("Antigravity OAuth failed");
|
||||
runtime.error(String(err));
|
||||
}
|
||||
} else if (authChoice === "apiKey") {
|
||||
const key = guardCancel(
|
||||
await text({
|
||||
|
||||
@@ -98,8 +98,8 @@ export async function runNonInteractiveOnboarding(
|
||||
await setAnthropicApiKey(key);
|
||||
} else if (authChoice === "minimax") {
|
||||
nextConfig = applyMinimaxConfig(nextConfig);
|
||||
} else if (authChoice === "oauth" || authChoice === "antigravity") {
|
||||
runtime.error(`${authChoice === "oauth" ? "OAuth" : "Antigravity"} requires interactive mode.`);
|
||||
} else if (authChoice === "oauth") {
|
||||
runtime.error("OAuth requires interactive mode.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type OnboardMode = "local" | "remote";
|
||||
export type AuthChoice = "oauth" | "antigravity" | "apiKey" | "minimax" | "skip";
|
||||
export type AuthChoice = "oauth" | "apiKey" | "minimax" | "skip";
|
||||
export type GatewayAuthChoice = "off" | "token" | "password";
|
||||
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||
export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto";
|
||||
|
||||
+5
-68
@@ -221,24 +221,6 @@ export type DiscordSlashCommandConfig = {
|
||||
ephemeral?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordActionConfig = {
|
||||
reactions?: boolean;
|
||||
stickers?: boolean;
|
||||
polls?: boolean;
|
||||
permissions?: boolean;
|
||||
messages?: boolean;
|
||||
threads?: boolean;
|
||||
pins?: boolean;
|
||||
search?: boolean;
|
||||
memberInfo?: boolean;
|
||||
roleInfo?: boolean;
|
||||
roles?: boolean;
|
||||
channelInfo?: boolean;
|
||||
voiceStatus?: boolean;
|
||||
events?: boolean;
|
||||
moderation?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordConfig = {
|
||||
/** If false, do not start the Discord provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
@@ -247,8 +229,8 @@ export type DiscordConfig = {
|
||||
textChunkLimit?: number;
|
||||
mediaMaxMb?: number;
|
||||
historyLimit?: number;
|
||||
/** Per-action tool gating (default: true for all). */
|
||||
actions?: DiscordActionConfig;
|
||||
/** Allow agent-triggered Discord reactions (default: true). */
|
||||
enableReactions?: boolean;
|
||||
/** Control reply threading when reply tags are present (off|first|all). */
|
||||
replyToMode?: ReplyToMode;
|
||||
slashCommand?: DiscordSlashCommandConfig;
|
||||
@@ -309,15 +291,7 @@ export type IMessageConfig = {
|
||||
>;
|
||||
};
|
||||
|
||||
export type QueueMode =
|
||||
| "steer"
|
||||
| "followup"
|
||||
| "collect"
|
||||
| "steer-backlog"
|
||||
| "steer+backlog"
|
||||
| "queue"
|
||||
| "interrupt";
|
||||
export type QueueDropPolicy = "old" | "new" | "summarize";
|
||||
export type QueueMode = "queue" | "interrupt";
|
||||
|
||||
export type QueueModeBySurface = {
|
||||
whatsapp?: QueueMode;
|
||||
@@ -343,9 +317,6 @@ export type RoutingConfig = {
|
||||
queue?: {
|
||||
mode?: QueueMode;
|
||||
bySurface?: QueueModeBySurface;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
drop?: QueueDropPolicy;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -701,20 +672,7 @@ const GroupChatSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const QueueModeSchema = z.union([
|
||||
z.literal("steer"),
|
||||
z.literal("followup"),
|
||||
z.literal("collect"),
|
||||
z.literal("steer-backlog"),
|
||||
z.literal("steer+backlog"),
|
||||
z.literal("queue"),
|
||||
z.literal("interrupt"),
|
||||
]);
|
||||
const QueueDropSchema = z.union([
|
||||
z.literal("old"),
|
||||
z.literal("new"),
|
||||
z.literal("summarize"),
|
||||
]);
|
||||
const QueueModeSchema = z.union([z.literal("queue"), z.literal("interrupt")]);
|
||||
const ReplyToModeSchema = z.union([
|
||||
z.literal("off"),
|
||||
z.literal("first"),
|
||||
@@ -803,9 +761,6 @@ const RoutingSchema = z
|
||||
.object({
|
||||
mode: QueueModeSchema.optional(),
|
||||
bySurface: QueueModeBySurfaceSchema,
|
||||
debounceMs: z.number().int().nonnegative().optional(),
|
||||
cap: z.number().int().positive().optional(),
|
||||
drop: QueueDropSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -1077,25 +1032,7 @@ const ClawdisSchema = z.object({
|
||||
.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
stickers: z.boolean().optional(),
|
||||
polls: z.boolean().optional(),
|
||||
permissions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
threads: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
search: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
roleInfo: z.boolean().optional(),
|
||||
roles: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
voiceStatus: z.boolean().optional(),
|
||||
events: z.boolean().optional(),
|
||||
moderation: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
enableReactions: z.boolean().optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
dm: z
|
||||
.object({
|
||||
|
||||
+1
-11
@@ -34,17 +34,7 @@ export type SessionEntry = {
|
||||
modelOverride?: string;
|
||||
groupActivation?: "mention" | "always";
|
||||
groupActivationNeedsSystemIntro?: boolean;
|
||||
queueMode?:
|
||||
| "steer"
|
||||
| "followup"
|
||||
| "collect"
|
||||
| "steer-backlog"
|
||||
| "steer+backlog"
|
||||
| "queue"
|
||||
| "interrupt";
|
||||
queueDebounceMs?: number;
|
||||
queueCap?: number;
|
||||
queueDrop?: "old" | "new" | "summarize";
|
||||
queueMode?: "queue" | "interrupt";
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
|
||||
+2
-400
@@ -1,27 +1,7 @@
|
||||
import { PermissionsBitField, Routes } from "discord.js";
|
||||
import { Routes } from "discord.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
addRoleDiscord,
|
||||
banMemberDiscord,
|
||||
createThreadDiscord,
|
||||
deleteMessageDiscord,
|
||||
editMessageDiscord,
|
||||
fetchChannelPermissionsDiscord,
|
||||
fetchReactionsDiscord,
|
||||
listGuildEmojisDiscord,
|
||||
listThreadsDiscord,
|
||||
pinMessageDiscord,
|
||||
reactMessageDiscord,
|
||||
readMessagesDiscord,
|
||||
removeRoleDiscord,
|
||||
searchMessagesDiscord,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
timeoutMemberDiscord,
|
||||
unpinMessageDiscord,
|
||||
} from "./send.js";
|
||||
import { sendMessageDiscord } from "./send.js";
|
||||
|
||||
vi.mock("../web/media.js", () => ({
|
||||
loadWebMedia: vi.fn().mockResolvedValue({
|
||||
@@ -34,23 +14,11 @@ vi.mock("../web/media.js", () => ({
|
||||
|
||||
const makeRest = () => {
|
||||
const postMock = vi.fn();
|
||||
const putMock = vi.fn();
|
||||
const getMock = vi.fn();
|
||||
const patchMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
return {
|
||||
rest: {
|
||||
post: postMock,
|
||||
put: putMock,
|
||||
get: getMock,
|
||||
patch: patchMock,
|
||||
delete: deleteMock,
|
||||
} as unknown as import("discord.js").REST,
|
||||
postMock,
|
||||
putMock,
|
||||
getMock,
|
||||
patchMock,
|
||||
deleteMock,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -148,369 +116,3 @@ describe("sendMessageDiscord", () => {
|
||||
expect(secondBody?.message_reference).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactMessageDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("reacts with unicode emoji", async () => {
|
||||
const { rest, putMock } = makeRest();
|
||||
await reactMessageDiscord("chan1", "msg1", "✅", { rest, token: "t" });
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes variation selectors in unicode emoji", async () => {
|
||||
const { rest, putMock } = makeRest();
|
||||
await reactMessageDiscord("chan1", "msg1", "⭐️", { rest, token: "t" });
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%AD%90"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reacts with custom emoji syntax", async () => {
|
||||
const { rest, putMock } = makeRest();
|
||||
await reactMessageDiscord("chan1", "msg1", "<:party_blob:123>", {
|
||||
rest,
|
||||
token: "t",
|
||||
});
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessageOwnReaction("chan1", "msg1", "party_blob%3A123"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchReactionsDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns reactions with users", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock
|
||||
.mockResolvedValueOnce({
|
||||
reactions: [
|
||||
{ count: 2, emoji: { name: "✅", id: null } },
|
||||
{ count: 1, emoji: { name: "party_blob", id: "123" } },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "u1", username: "alpha", discriminator: "0001" },
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: "u2", username: "beta" }]);
|
||||
const res = await fetchReactionsDiscord("chan1", "msg1", {
|
||||
rest,
|
||||
token: "t",
|
||||
});
|
||||
expect(res).toEqual([
|
||||
{
|
||||
emoji: { id: null, name: "✅", raw: "✅" },
|
||||
count: 2,
|
||||
users: [{ id: "u1", username: "alpha", tag: "alpha#0001" }],
|
||||
},
|
||||
{
|
||||
emoji: { id: "123", name: "party_blob", raw: "party_blob:123" },
|
||||
count: 1,
|
||||
users: [{ id: "u2", username: "beta", tag: "beta" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchChannelPermissionsDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calculates permissions from guild roles", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
const perms = new PermissionsBitField([
|
||||
PermissionsBitField.Flags.ViewChannel,
|
||||
PermissionsBitField.Flags.SendMessages,
|
||||
]);
|
||||
getMock
|
||||
.mockResolvedValueOnce({
|
||||
id: "chan1",
|
||||
guild_id: "guild1",
|
||||
permission_overwrites: [],
|
||||
})
|
||||
.mockResolvedValueOnce({ id: "bot1" })
|
||||
.mockResolvedValueOnce({
|
||||
id: "guild1",
|
||||
roles: [
|
||||
{ id: "guild1", permissions: perms.bitfield.toString() },
|
||||
{ id: "role2", permissions: "0" },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ roles: ["role2"] });
|
||||
const res = await fetchChannelPermissionsDiscord("chan1", {
|
||||
rest,
|
||||
token: "t",
|
||||
});
|
||||
expect(res.guildId).toBe("guild1");
|
||||
expect(res.permissions).toContain("ViewChannel");
|
||||
expect(res.permissions).toContain("SendMessages");
|
||||
expect(res.isDm).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readMessagesDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes query params as URLSearchParams", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue([]);
|
||||
await readMessagesDiscord(
|
||||
"chan1",
|
||||
{ limit: 5, before: "10" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
const call = getMock.mock.calls[0];
|
||||
const options = call?.[1] as { query?: URLSearchParams };
|
||||
expect(options.query?.toString()).toBe("limit=5&before=10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit/delete message helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("edits message content", async () => {
|
||||
const { rest, patchMock } = makeRest();
|
||||
patchMock.mockResolvedValue({ id: "m1" });
|
||||
await editMessageDiscord(
|
||||
"chan1",
|
||||
"m1",
|
||||
{ content: "hello" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(patchMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessage("chan1", "m1"),
|
||||
expect.objectContaining({ body: { content: "hello" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes message", async () => {
|
||||
const { rest, deleteMock } = makeRest();
|
||||
deleteMock.mockResolvedValue({});
|
||||
await deleteMessageDiscord("chan1", "m1", { rest, token: "t" });
|
||||
expect(deleteMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessage("chan1", "m1"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pin helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("pins and unpins messages", async () => {
|
||||
const { rest, putMock, deleteMock } = makeRest();
|
||||
putMock.mockResolvedValue({});
|
||||
deleteMock.mockResolvedValue({});
|
||||
await pinMessageDiscord("chan1", "m1", { rest, token: "t" });
|
||||
await unpinMessageDiscord("chan1", "m1", { rest, token: "t" });
|
||||
expect(putMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1"));
|
||||
expect(deleteMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchMessagesDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses URLSearchParams for search", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue({ total_results: 0, messages: [] });
|
||||
await searchMessagesDiscord(
|
||||
{ guildId: "g1", content: "hello", limit: 5 },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
const call = getMock.mock.calls[0];
|
||||
const options = call?.[1] as { query?: URLSearchParams };
|
||||
expect(options.query?.toString()).toBe("content=hello&limit=5");
|
||||
});
|
||||
|
||||
it("supports channel/author arrays and clamps limit", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue({ total_results: 0, messages: [] });
|
||||
await searchMessagesDiscord(
|
||||
{
|
||||
guildId: "g1",
|
||||
content: "hello",
|
||||
channelIds: ["c1", "c2"],
|
||||
authorIds: ["u1"],
|
||||
limit: 99,
|
||||
},
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
const call = getMock.mock.calls[0];
|
||||
const options = call?.[1] as { query?: URLSearchParams };
|
||||
expect(options.query?.toString()).toBe(
|
||||
"content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("threads and moderation helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a thread", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "t1" });
|
||||
await createThreadDiscord(
|
||||
"chan1",
|
||||
{ name: "thread", messageId: "m1" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.threads("chan1", "m1"),
|
||||
expect.objectContaining({ body: { name: "thread" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists active threads by guild", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue({ threads: [] });
|
||||
await listThreadsDiscord({ guildId: "g1" }, { rest, token: "t" });
|
||||
expect(getMock).toHaveBeenCalledWith(Routes.guildActiveThreads("g1"));
|
||||
});
|
||||
|
||||
it("times out a member", async () => {
|
||||
const { rest, patchMock } = makeRest();
|
||||
patchMock.mockResolvedValue({ id: "m1" });
|
||||
await timeoutMemberDiscord(
|
||||
{ guildId: "g1", userId: "u1", durationMinutes: 10 },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(patchMock).toHaveBeenCalledWith(
|
||||
Routes.guildMember("g1", "u1"),
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
communication_disabled_until: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds and removes roles", async () => {
|
||||
const { rest, putMock, deleteMock } = makeRest();
|
||||
putMock.mockResolvedValue({});
|
||||
deleteMock.mockResolvedValue({});
|
||||
await addRoleDiscord(
|
||||
{ guildId: "g1", userId: "u1", roleId: "r1" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
await removeRoleDiscord(
|
||||
{ guildId: "g1", userId: "u1", roleId: "r1" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.guildMemberRole("g1", "u1", "r1"),
|
||||
);
|
||||
expect(deleteMock).toHaveBeenCalledWith(
|
||||
Routes.guildMemberRole("g1", "u1", "r1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("bans a member", async () => {
|
||||
const { rest, putMock } = makeRest();
|
||||
putMock.mockResolvedValue({});
|
||||
await banMemberDiscord(
|
||||
{ guildId: "g1", userId: "u1", deleteMessageDays: 2 },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.guildBan("g1", "u1"),
|
||||
expect.objectContaining({ body: { delete_message_days: 2 } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listGuildEmojisDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists emojis for a guild", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue([{ id: "e1", name: "party" }]);
|
||||
await listGuildEmojisDiscord("g1", { rest, token: "t" });
|
||||
expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendStickerDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sends sticker payloads", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
|
||||
const res = await sendStickerDiscord("channel:789", ["123"], {
|
||||
rest,
|
||||
token: "t",
|
||||
content: "hiya",
|
||||
});
|
||||
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
content: "hiya",
|
||||
sticker_ids: ["123"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPollDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sends polls with answers", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
|
||||
const res = await sendPollDiscord(
|
||||
"channel:789",
|
||||
{
|
||||
question: "Lunch?",
|
||||
answers: ["Pizza", "Sushi"],
|
||||
},
|
||||
{
|
||||
rest,
|
||||
token: "t",
|
||||
},
|
||||
);
|
||||
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
poll: {
|
||||
question: { text: "Lunch?" },
|
||||
answers: [
|
||||
{ poll_media: { text: "Pizza" } },
|
||||
{ poll_media: { text: "Sushi" } },
|
||||
],
|
||||
duration: 24,
|
||||
allow_multiselect: false,
|
||||
layout_type: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+3
-632
@@ -1,16 +1,4 @@
|
||||
import { PermissionsBitField, REST, Routes } from "discord.js";
|
||||
import { PollLayoutType } from "discord-api-types/payloads/v10";
|
||||
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
|
||||
import type {
|
||||
APIChannel,
|
||||
APIGuild,
|
||||
APIGuildMember,
|
||||
APIGuildScheduledEvent,
|
||||
APIMessage,
|
||||
APIRole,
|
||||
APIVoiceState,
|
||||
RESTPostAPIGuildScheduledEventJSONBody,
|
||||
} from "discord-api-types/v10";
|
||||
import { REST, Routes } from "discord.js";
|
||||
|
||||
import { chunkText } from "../auto-reply/chunk.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
@@ -18,10 +6,6 @@ import { loadWebMedia } from "../web/media.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
const DISCORD_TEXT_LIMIT = 2000;
|
||||
const DISCORD_MAX_STICKERS = 3;
|
||||
const DISCORD_POLL_MIN_ANSWERS = 2;
|
||||
const DISCORD_POLL_MAX_ANSWERS = 10;
|
||||
const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24;
|
||||
|
||||
type DiscordRecipient =
|
||||
| {
|
||||
@@ -46,88 +30,11 @@ export type DiscordSendResult = {
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
export type DiscordPollInput = {
|
||||
question: string;
|
||||
answers: string[];
|
||||
allowMultiselect?: boolean;
|
||||
durationHours?: number;
|
||||
};
|
||||
|
||||
export type DiscordReactOpts = {
|
||||
token?: string;
|
||||
rest?: REST;
|
||||
};
|
||||
|
||||
export type DiscordReactionUser = {
|
||||
id: string;
|
||||
username?: string;
|
||||
tag?: string;
|
||||
};
|
||||
|
||||
export type DiscordReactionSummary = {
|
||||
emoji: { id?: string | null; name?: string | null; raw: string };
|
||||
count: number;
|
||||
users: DiscordReactionUser[];
|
||||
};
|
||||
|
||||
export type DiscordPermissionsSummary = {
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
permissions: string[];
|
||||
raw: string;
|
||||
isDm: boolean;
|
||||
};
|
||||
|
||||
export type DiscordMessageQuery = {
|
||||
limit?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
around?: string;
|
||||
};
|
||||
|
||||
export type DiscordMessageEdit = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type DiscordThreadCreate = {
|
||||
name: string;
|
||||
messageId?: string;
|
||||
autoArchiveMinutes?: number;
|
||||
};
|
||||
|
||||
export type DiscordThreadList = {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
includeArchived?: boolean;
|
||||
before?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type DiscordSearchQuery = {
|
||||
guildId: string;
|
||||
content: string;
|
||||
channelIds?: string[];
|
||||
authorIds?: string[];
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type DiscordRoleChange = {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
export type DiscordModerationTarget = {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type DiscordTimeoutTarget = DiscordModerationTarget & {
|
||||
durationMinutes?: number;
|
||||
until?: string;
|
||||
};
|
||||
|
||||
function resolveToken(explicit?: string) {
|
||||
const cfgToken = loadConfig().discord?.token;
|
||||
const token = normalizeDiscordToken(
|
||||
@@ -149,7 +56,7 @@ function normalizeReactionEmoji(raw: string) {
|
||||
const customMatch = trimmed.match(/^<a?:([^:>]+):(\d+)>$/);
|
||||
const identifier = customMatch
|
||||
? `${customMatch[1]}:${customMatch[2]}`
|
||||
: trimmed.replace(/[\uFE0E\uFE0F]/g, "");
|
||||
: trimmed;
|
||||
return encodeURIComponent(identifier);
|
||||
}
|
||||
|
||||
@@ -183,49 +90,6 @@ function parseRecipient(raw: string): DiscordRecipient {
|
||||
return { kind: "channel", id: trimmed };
|
||||
}
|
||||
|
||||
function normalizeStickerIds(raw: string[]) {
|
||||
const ids = raw.map((entry) => entry.trim()).filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
throw new Error("At least one sticker id is required");
|
||||
}
|
||||
if (ids.length > DISCORD_MAX_STICKERS) {
|
||||
throw new Error("Discord supports up to 3 stickers per message");
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function normalizePollInput(input: DiscordPollInput): RESTAPIPoll {
|
||||
const question = input.question.trim();
|
||||
if (!question) {
|
||||
throw new Error("Poll question is required");
|
||||
}
|
||||
const answers = (input.answers ?? [])
|
||||
.map((answer) => answer.trim())
|
||||
.filter(Boolean);
|
||||
if (answers.length < DISCORD_POLL_MIN_ANSWERS) {
|
||||
throw new Error("Polls require at least 2 answers");
|
||||
}
|
||||
if (answers.length > DISCORD_POLL_MAX_ANSWERS) {
|
||||
throw new Error("Polls support up to 10 answers");
|
||||
}
|
||||
const durationRaw =
|
||||
typeof input.durationHours === "number" &&
|
||||
Number.isFinite(input.durationHours)
|
||||
? Math.floor(input.durationHours)
|
||||
: 24;
|
||||
const duration = Math.min(
|
||||
Math.max(durationRaw, 1),
|
||||
DISCORD_POLL_MAX_DURATION_HOURS,
|
||||
);
|
||||
return {
|
||||
question: { text: question },
|
||||
answers: answers.map((answer) => ({ poll_media: { text: answer } })),
|
||||
duration,
|
||||
allow_multiselect: input.allowMultiselect ?? false,
|
||||
layout_type: PollLayoutType.Default,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveChannelId(
|
||||
rest: REST,
|
||||
recipient: DiscordRecipient,
|
||||
@@ -312,31 +176,6 @@ async function sendDiscordMedia(
|
||||
return res;
|
||||
}
|
||||
|
||||
function buildReactionIdentifier(emoji: {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}) {
|
||||
if (emoji.id && emoji.name) {
|
||||
return `${emoji.name}:${emoji.id}`;
|
||||
}
|
||||
return emoji.name ?? "";
|
||||
}
|
||||
|
||||
function formatReactionEmoji(emoji: {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}) {
|
||||
return buildReactionIdentifier(emoji);
|
||||
}
|
||||
|
||||
async function fetchBotUserId(rest: REST) {
|
||||
const me = (await rest.get(Routes.user("@me"))) as { id?: string };
|
||||
if (!me?.id) {
|
||||
throw new Error("Failed to resolve bot user id");
|
||||
}
|
||||
return me.id;
|
||||
}
|
||||
|
||||
export async function sendMessageDiscord(
|
||||
to: string,
|
||||
text: string,
|
||||
@@ -368,52 +207,6 @@ export async function sendMessageDiscord(
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendStickerDiscord(
|
||||
to: string,
|
||||
stickerIds: string[],
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(rest, recipient);
|
||||
const content = opts.content?.trim();
|
||||
const stickers = normalizeStickerIds(stickerIds);
|
||||
const res = (await rest.post(Routes.channelMessages(channelId), {
|
||||
body: {
|
||||
content: content || undefined,
|
||||
sticker_ids: stickers,
|
||||
},
|
||||
})) as { id: string; channel_id: string };
|
||||
return {
|
||||
messageId: res.id ? String(res.id) : "unknown",
|
||||
channelId: String(res.channel_id ?? channelId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendPollDiscord(
|
||||
to: string,
|
||||
poll: DiscordPollInput,
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(rest, recipient);
|
||||
const content = opts.content?.trim();
|
||||
const payload = normalizePollInput(poll);
|
||||
const res = (await rest.post(Routes.channelMessages(channelId), {
|
||||
body: {
|
||||
content: content || undefined,
|
||||
poll: payload,
|
||||
},
|
||||
})) as { id: string; channel_id: string };
|
||||
return {
|
||||
messageId: res.id ? String(res.id) : "unknown",
|
||||
channelId: String(res.channel_id ?? channelId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function reactMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
@@ -423,428 +216,6 @@ export async function reactMessageDiscord(
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const encoded = normalizeReactionEmoji(emoji);
|
||||
await rest.put(
|
||||
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function fetchReactionsDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts & { limit?: number } = {},
|
||||
): Promise<DiscordReactionSummary[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const message = (await rest.get(
|
||||
Routes.channelMessage(channelId, messageId),
|
||||
)) as {
|
||||
reactions?: Array<{
|
||||
count: number;
|
||||
emoji: { id?: string | null; name?: string | null };
|
||||
}>;
|
||||
};
|
||||
const reactions = message.reactions ?? [];
|
||||
if (reactions.length === 0) return [];
|
||||
const limit =
|
||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||
? Math.min(Math.max(Math.floor(opts.limit), 1), 100)
|
||||
: 100;
|
||||
|
||||
const summaries: DiscordReactionSummary[] = [];
|
||||
for (const reaction of reactions) {
|
||||
const identifier = buildReactionIdentifier(reaction.emoji);
|
||||
if (!identifier) continue;
|
||||
const encoded = encodeURIComponent(identifier);
|
||||
const users = (await rest.get(
|
||||
Routes.channelMessageReaction(channelId, messageId, encoded),
|
||||
{ query: new URLSearchParams({ limit: String(limit) }) },
|
||||
)) as Array<{ id: string; username?: string; discriminator?: string }>;
|
||||
summaries.push({
|
||||
emoji: {
|
||||
id: reaction.emoji.id ?? null,
|
||||
name: reaction.emoji.name ?? null,
|
||||
raw: formatReactionEmoji(reaction.emoji),
|
||||
},
|
||||
count: reaction.count,
|
||||
users: users.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
tag:
|
||||
user.username && user.discriminator
|
||||
? `${user.username}#${user.discriminator}`
|
||||
: user.username,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
export async function fetchChannelPermissionsDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<DiscordPermissionsSummary> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel;
|
||||
const guildId = "guild_id" in channel ? channel.guild_id : undefined;
|
||||
if (!guildId) {
|
||||
return {
|
||||
channelId,
|
||||
permissions: [],
|
||||
raw: "0",
|
||||
isDm: true,
|
||||
};
|
||||
}
|
||||
|
||||
const botId = await fetchBotUserId(rest);
|
||||
const [guild, member] = await Promise.all([
|
||||
rest.get(Routes.guild(guildId)) as Promise<APIGuild>,
|
||||
rest.get(Routes.guildMember(guildId, botId)) as Promise<APIGuildMember>,
|
||||
]);
|
||||
|
||||
const rolesById = new Map<string, APIRole>(
|
||||
(guild.roles ?? []).map((role) => [role.id, role]),
|
||||
);
|
||||
const base = new PermissionsBitField();
|
||||
const everyoneRole = rolesById.get(guildId);
|
||||
if (everyoneRole?.permissions) {
|
||||
base.add(BigInt(everyoneRole.permissions));
|
||||
}
|
||||
for (const roleId of member.roles ?? []) {
|
||||
const role = rolesById.get(roleId);
|
||||
if (role?.permissions) {
|
||||
base.add(BigInt(role.permissions));
|
||||
}
|
||||
}
|
||||
|
||||
const permissions = new PermissionsBitField(base);
|
||||
const overwrites =
|
||||
"permission_overwrites" in channel
|
||||
? (channel.permission_overwrites ?? [])
|
||||
: [];
|
||||
for (const overwrite of overwrites) {
|
||||
if (overwrite.id === guildId) {
|
||||
permissions.remove(BigInt(overwrite.deny ?? "0"));
|
||||
permissions.add(BigInt(overwrite.allow ?? "0"));
|
||||
}
|
||||
}
|
||||
for (const overwrite of overwrites) {
|
||||
if (member.roles?.includes(overwrite.id)) {
|
||||
permissions.remove(BigInt(overwrite.deny ?? "0"));
|
||||
permissions.add(BigInt(overwrite.allow ?? "0"));
|
||||
}
|
||||
}
|
||||
for (const overwrite of overwrites) {
|
||||
if (overwrite.id === botId) {
|
||||
permissions.remove(BigInt(overwrite.deny ?? "0"));
|
||||
permissions.add(BigInt(overwrite.allow ?? "0"));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
channelId,
|
||||
guildId,
|
||||
permissions: permissions.toArray(),
|
||||
raw: permissions.bitfield.toString(),
|
||||
isDm: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readMessagesDiscord(
|
||||
channelId: string,
|
||||
query: DiscordMessageQuery = {},
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const limit =
|
||||
typeof query.limit === "number" && Number.isFinite(query.limit)
|
||||
? Math.min(Math.max(Math.floor(query.limit), 1), 100)
|
||||
: undefined;
|
||||
const params = new URLSearchParams();
|
||||
if (limit) params.set("limit", String(limit));
|
||||
if (query.before) params.set("before", query.before);
|
||||
if (query.after) params.set("after", query.after);
|
||||
if (query.around) params.set("around", query.around);
|
||||
return (await rest.get(Routes.channelMessages(channelId), {
|
||||
query: params,
|
||||
})) as APIMessage[];
|
||||
}
|
||||
|
||||
export async function editMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
payload: DiscordMessageEdit,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.patch(Routes.channelMessage(channelId, messageId), {
|
||||
body: { content: payload.content },
|
||||
})) as APIMessage;
|
||||
}
|
||||
|
||||
export async function deleteMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
await rest.delete(Routes.channelMessage(channelId, messageId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function pinMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
await rest.put(Routes.channelPin(channelId, messageId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function unpinMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
await rest.delete(Routes.channelPin(channelId, messageId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function listPinsDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
|
||||
}
|
||||
|
||||
export async function createThreadDiscord(
|
||||
channelId: string,
|
||||
payload: DiscordThreadCreate,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const body: Record<string, unknown> = { name: payload.name };
|
||||
if (payload.autoArchiveMinutes) {
|
||||
body.auto_archive_duration = payload.autoArchiveMinutes;
|
||||
}
|
||||
const route = Routes.threads(channelId, payload.messageId);
|
||||
return await rest.post(route, { body });
|
||||
}
|
||||
|
||||
export async function listThreadsDiscord(
|
||||
payload: DiscordThreadList,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
if (payload.includeArchived) {
|
||||
if (!payload.channelId) {
|
||||
throw new Error("channelId required to list archived threads");
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (payload.before) params.set("before", payload.before);
|
||||
if (payload.limit) params.set("limit", String(payload.limit));
|
||||
return await rest.get(Routes.channelThreads(payload.channelId, "public"), {
|
||||
query: params,
|
||||
});
|
||||
}
|
||||
return await rest.get(Routes.guildActiveThreads(payload.guildId));
|
||||
}
|
||||
|
||||
export async function searchMessagesDiscord(
|
||||
query: DiscordSearchQuery,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const params = new URLSearchParams();
|
||||
params.set("content", query.content);
|
||||
if (query.channelIds?.length) {
|
||||
for (const channelId of query.channelIds) {
|
||||
params.append("channel_id", channelId);
|
||||
}
|
||||
}
|
||||
if (query.authorIds?.length) {
|
||||
for (const authorId of query.authorIds) {
|
||||
params.append("author_id", authorId);
|
||||
}
|
||||
}
|
||||
if (query.limit) {
|
||||
const limit = Math.min(Math.max(Math.floor(query.limit), 1), 25);
|
||||
params.set("limit", String(limit));
|
||||
}
|
||||
return await rest.get(`/guilds/${query.guildId}/messages/search`, {
|
||||
query: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listGuildEmojisDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return await rest.get(Routes.guildEmojis(guildId));
|
||||
}
|
||||
|
||||
export async function fetchMemberInfoDiscord(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildMember> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(
|
||||
Routes.guildMember(guildId, userId),
|
||||
)) as APIGuildMember;
|
||||
}
|
||||
|
||||
export async function fetchRoleInfoDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIRole[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
|
||||
}
|
||||
|
||||
export async function addRoleDiscord(
|
||||
payload: DiscordRoleChange,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
await rest.put(
|
||||
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeRoleDiscord(
|
||||
payload: DiscordRoleChange,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
await rest.delete(
|
||||
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function fetchChannelInfoDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIChannel> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(Routes.channel(channelId))) as APIChannel;
|
||||
}
|
||||
|
||||
export async function listGuildChannelsDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIChannel[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
|
||||
}
|
||||
|
||||
export async function fetchVoiceStatusDiscord(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIVoiceState> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(
|
||||
Routes.guildVoiceState(guildId, userId),
|
||||
)) as APIVoiceState;
|
||||
}
|
||||
|
||||
export async function listScheduledEventsDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildScheduledEvent[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(
|
||||
Routes.guildScheduledEvents(guildId),
|
||||
)) as APIGuildScheduledEvent[];
|
||||
}
|
||||
|
||||
export async function createScheduledEventDiscord(
|
||||
guildId: string,
|
||||
payload: RESTPostAPIGuildScheduledEventJSONBody,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildScheduledEvent> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.post(Routes.guildScheduledEvents(guildId), {
|
||||
body: payload,
|
||||
})) as APIGuildScheduledEvent;
|
||||
}
|
||||
|
||||
export async function timeoutMemberDiscord(
|
||||
payload: DiscordTimeoutTarget,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildMember> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
let until = payload.until;
|
||||
if (!until && payload.durationMinutes) {
|
||||
const ms = payload.durationMinutes * 60 * 1000;
|
||||
until = new Date(Date.now() + ms).toISOString();
|
||||
}
|
||||
return (await rest.patch(
|
||||
Routes.guildMember(payload.guildId, payload.userId),
|
||||
{
|
||||
body: { communication_disabled_until: until ?? null },
|
||||
reason: payload.reason,
|
||||
},
|
||||
)) as APIGuildMember;
|
||||
}
|
||||
|
||||
export async function kickMemberDiscord(
|
||||
payload: DiscordModerationTarget,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
await rest.delete(Routes.guildMember(payload.guildId, payload.userId), {
|
||||
reason: payload.reason,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function banMemberDiscord(
|
||||
payload: DiscordModerationTarget & { deleteMessageDays?: number },
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const deleteMessageDays =
|
||||
typeof payload.deleteMessageDays === "number" &&
|
||||
Number.isFinite(payload.deleteMessageDays)
|
||||
? Math.min(Math.max(Math.floor(payload.deleteMessageDays), 0), 7)
|
||||
: undefined;
|
||||
await rest.put(Routes.guildBan(payload.guildId, payload.userId), {
|
||||
body:
|
||||
deleteMessageDays !== undefined
|
||||
? { delete_message_days: deleteMessageDays }
|
||||
: undefined,
|
||||
reason: payload.reason,
|
||||
});
|
||||
await rest.put(Routes.channelMessageReaction(channelId, messageId, encoded));
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -151,7 +151,6 @@ export async function applyHookMappings(
|
||||
}
|
||||
}
|
||||
|
||||
if (!base.action) return { ok: true, action: null, skipped: true };
|
||||
const merged = mergeAction(base.action, override, mapping.action);
|
||||
if (!merged.ok) return merged;
|
||||
return merged;
|
||||
|
||||
@@ -174,10 +174,6 @@ import {
|
||||
type HookMappingResolved,
|
||||
resolveHookMappings,
|
||||
} from "./hooks-mapping.js";
|
||||
import {
|
||||
startGmailWatcher,
|
||||
stopGmailWatcher,
|
||||
} from "../hooks/gmail-watcher.js";
|
||||
|
||||
ensureClawdisCliOnPath();
|
||||
|
||||
@@ -6863,20 +6859,6 @@ export async function startGatewayServer(
|
||||
logBrowser.error(`server failed to start: ${String(err)}`);
|
||||
}
|
||||
|
||||
// Start Gmail watcher if configured (hooks.gmail.account).
|
||||
if (process.env.CLAWDIS_SKIP_GMAIL_WATCHER !== "1") {
|
||||
try {
|
||||
const gmailResult = await startGmailWatcher(cfgAtStart);
|
||||
if (gmailResult.started) {
|
||||
logHooks.info("gmail watcher started");
|
||||
} else if (gmailResult.reason && gmailResult.reason !== "hooks not enabled" && gmailResult.reason !== "no gmail account configured") {
|
||||
logHooks.warn(`gmail watcher not started: ${gmailResult.reason}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logHooks.error(`gmail watcher failed to start: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Launch configured providers (WhatsApp Web, Discord, Telegram) so gateway replies via the
|
||||
// surface the message came from. Tests can opt out via CLAWDIS_SKIP_PROVIDERS.
|
||||
if (process.env.CLAWDIS_SKIP_PROVIDERS !== "1") {
|
||||
@@ -6935,7 +6917,6 @@ export async function startGatewayServer(
|
||||
await stopDiscordProvider();
|
||||
await stopSignalProvider();
|
||||
await stopIMessageProvider();
|
||||
await stopGmailWatcher();
|
||||
cron.stop();
|
||||
heartbeatRunner.stop();
|
||||
broadcast("shutdown", {
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Gmail Watcher Service
|
||||
*
|
||||
* Automatically starts `gog gmail watch serve` when the gateway starts,
|
||||
* if hooks.gmail is configured with an account.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { hasBinary } from "../agents/skills.js";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import {
|
||||
buildGogWatchServeArgs,
|
||||
buildGogWatchStartArgs,
|
||||
resolveGmailHookRuntimeConfig,
|
||||
type GmailHookRuntimeConfig,
|
||||
} from "./gmail.js";
|
||||
import { ensureTailscaleEndpoint } from "./gmail-setup-utils.js";
|
||||
|
||||
const log = createSubsystemLogger("gmail-watcher");
|
||||
|
||||
let watcherProcess: ChildProcess | null = null;
|
||||
let renewInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let shuttingDown = false;
|
||||
let currentConfig: GmailHookRuntimeConfig | null = null;
|
||||
|
||||
/**
|
||||
* Check if gog binary is available
|
||||
*/
|
||||
function isGogAvailable(): boolean {
|
||||
return hasBinary("gog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Gmail watch (registers with Gmail API)
|
||||
*/
|
||||
async function startGmailWatch(
|
||||
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
|
||||
): Promise<boolean> {
|
||||
const args = ["gog", ...buildGogWatchStartArgs(cfg)];
|
||||
try {
|
||||
const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 });
|
||||
if (result.code !== 0) {
|
||||
const message = result.stderr || result.stdout || "gog watch start failed";
|
||||
log.error(`watch start failed: ${message}`);
|
||||
return false;
|
||||
}
|
||||
log.info(`watch started for ${cfg.account}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.error(`watch start error: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the gog gmail watch serve process
|
||||
*/
|
||||
function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
|
||||
const args = buildGogWatchServeArgs(cfg);
|
||||
log.info(`starting gog ${args.join(" ")}`);
|
||||
|
||||
const child = spawn("gog", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) log.info(`[gog] ${line}`);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) log.warn(`[gog] ${line}`);
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
log.error(`gog process error: ${String(err)}`);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (shuttingDown) return;
|
||||
log.warn(`gog exited (code=${code}, signal=${signal}); restarting in 5s`);
|
||||
watcherProcess = null;
|
||||
setTimeout(() => {
|
||||
if (shuttingDown || !currentConfig) return;
|
||||
watcherProcess = spawnGogServe(currentConfig);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
export type GmailWatcherStartResult = {
|
||||
started: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the Gmail watcher service.
|
||||
* Called automatically by the gateway if hooks.gmail is configured.
|
||||
*/
|
||||
export async function startGmailWatcher(
|
||||
cfg: ClawdisConfig,
|
||||
): Promise<GmailWatcherStartResult> {
|
||||
// Check if gmail hooks are configured
|
||||
if (!cfg.hooks?.enabled) {
|
||||
return { started: false, reason: "hooks not enabled" };
|
||||
}
|
||||
|
||||
if (!cfg.hooks?.gmail?.account) {
|
||||
return { started: false, reason: "no gmail account configured" };
|
||||
}
|
||||
|
||||
// Check if gog is available
|
||||
const gogAvailable = isGogAvailable();
|
||||
if (!gogAvailable) {
|
||||
return { started: false, reason: "gog binary not found" };
|
||||
}
|
||||
|
||||
// Resolve the full runtime config
|
||||
const resolved = resolveGmailHookRuntimeConfig(cfg, {});
|
||||
if (!resolved.ok) {
|
||||
return { started: false, reason: resolved.error };
|
||||
}
|
||||
|
||||
const runtimeConfig = resolved.value;
|
||||
currentConfig = runtimeConfig;
|
||||
|
||||
// Set up Tailscale endpoint if needed
|
||||
if (runtimeConfig.tailscale.mode !== "off") {
|
||||
try {
|
||||
await ensureTailscaleEndpoint({
|
||||
mode: runtimeConfig.tailscale.mode,
|
||||
path: runtimeConfig.tailscale.path,
|
||||
port: runtimeConfig.serve.port,
|
||||
});
|
||||
log.info(
|
||||
`tailscale ${runtimeConfig.tailscale.mode} configured for port ${runtimeConfig.serve.port}`,
|
||||
);
|
||||
} catch (err) {
|
||||
log.error(`tailscale setup failed: ${String(err)}`);
|
||||
return { started: false, reason: `tailscale setup failed: ${String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Start the Gmail watch (register with Gmail API)
|
||||
const watchStarted = await startGmailWatch(runtimeConfig);
|
||||
if (!watchStarted) {
|
||||
log.warn("gmail watch start failed, but continuing with serve");
|
||||
}
|
||||
|
||||
// Spawn the gog serve process
|
||||
shuttingDown = false;
|
||||
watcherProcess = spawnGogServe(runtimeConfig);
|
||||
|
||||
// Set up renewal interval
|
||||
const renewMs = runtimeConfig.renewEveryMinutes * 60_000;
|
||||
renewInterval = setInterval(() => {
|
||||
if (shuttingDown) return;
|
||||
void startGmailWatch(runtimeConfig);
|
||||
}, renewMs);
|
||||
|
||||
log.info(
|
||||
`gmail watcher started for ${runtimeConfig.account} (renew every ${runtimeConfig.renewEveryMinutes}m)`,
|
||||
);
|
||||
|
||||
return { started: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Gmail watcher service.
|
||||
*/
|
||||
export async function stopGmailWatcher(): Promise<void> {
|
||||
shuttingDown = true;
|
||||
|
||||
if (renewInterval) {
|
||||
clearInterval(renewInterval);
|
||||
renewInterval = null;
|
||||
}
|
||||
|
||||
if (watcherProcess) {
|
||||
log.info("stopping gmail watcher");
|
||||
watcherProcess.kill("SIGTERM");
|
||||
|
||||
// Wait a bit for graceful shutdown
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (watcherProcess) {
|
||||
watcherProcess.kill("SIGKILL");
|
||||
}
|
||||
resolve();
|
||||
}, 3000);
|
||||
|
||||
watcherProcess?.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
watcherProcess = null;
|
||||
}
|
||||
|
||||
currentConfig = null;
|
||||
log.info("gmail watcher stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Gmail watcher is running.
|
||||
*/
|
||||
export function isGmailWatcherRunning(): boolean {
|
||||
return watcherProcess !== null && !shuttingDown;
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { GatewayClient } from "../gateway/client.js";
|
||||
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
export type GatewayConnectionOptions = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type ChatSendOptions = {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
thinking?: string;
|
||||
deliver?: boolean;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type GatewayEvent = {
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
export class GatewayChatClient {
|
||||
private client: GatewayClient;
|
||||
private readyPromise: Promise<void>;
|
||||
private resolveReady?: () => void;
|
||||
readonly connection: { url: string; token?: string; password?: string };
|
||||
|
||||
onEvent?: (evt: GatewayEvent) => void;
|
||||
onConnected?: () => void;
|
||||
onDisconnected?: (reason: string) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
|
||||
constructor(opts: GatewayConnectionOptions) {
|
||||
const resolved = resolveGatewayConnection(opts);
|
||||
this.connection = resolved;
|
||||
|
||||
this.readyPromise = new Promise((resolve) => {
|
||||
this.resolveReady = resolve;
|
||||
});
|
||||
|
||||
this.client = new GatewayClient({
|
||||
url: resolved.url,
|
||||
token: resolved.token,
|
||||
password: resolved.password,
|
||||
clientName: "clawdis-tui",
|
||||
clientVersion: VERSION,
|
||||
platform: process.platform,
|
||||
mode: "tui",
|
||||
instanceId: randomUUID(),
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
onHelloOk: () => {
|
||||
this.resolveReady?.();
|
||||
this.onConnected?.();
|
||||
},
|
||||
onEvent: (evt) => {
|
||||
this.onEvent?.({ event: evt.event, payload: evt.payload });
|
||||
},
|
||||
onClose: (_code, reason) => {
|
||||
this.onDisconnected?.(reason);
|
||||
},
|
||||
onGap: (info) => {
|
||||
this.onGap?.(info);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
this.client.start();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.client.stop();
|
||||
}
|
||||
|
||||
async waitForReady() {
|
||||
await this.readyPromise;
|
||||
}
|
||||
|
||||
async sendChat(opts: ChatSendOptions): Promise<{ runId: string }>
|
||||
{
|
||||
const runId = randomUUID();
|
||||
await this.client.request("chat.send", {
|
||||
sessionKey: opts.sessionKey,
|
||||
message: opts.message,
|
||||
thinking: opts.thinking,
|
||||
deliver: opts.deliver,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
idempotencyKey: runId,
|
||||
});
|
||||
return { runId };
|
||||
}
|
||||
|
||||
async abortChat(opts: { sessionKey: string; runId: string }) {
|
||||
return await this.client.request<{ ok: boolean; aborted: boolean }>(
|
||||
"chat.abort",
|
||||
{
|
||||
sessionKey: opts.sessionKey,
|
||||
runId: opts.runId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async loadHistory(opts: { sessionKey: string; limit?: number }) {
|
||||
return await this.client.request("chat.history", {
|
||||
sessionKey: opts.sessionKey,
|
||||
limit: opts.limit,
|
||||
});
|
||||
}
|
||||
|
||||
async listSessions(opts?: { limit?: number; activeMinutes?: number }) {
|
||||
return await this.client.request("sessions.list", {
|
||||
limit: opts?.limit,
|
||||
activeMinutes: opts?.activeMinutes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGatewayConnection(opts: GatewayConnectionOptions) {
|
||||
const config = loadConfig();
|
||||
const isRemoteMode = config.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? config.gateway?.remote : undefined;
|
||||
const authToken = config.gateway?.auth?.token;
|
||||
|
||||
const url =
|
||||
(typeof opts.url === "string" && opts.url.trim().length > 0
|
||||
? opts.url.trim()
|
||||
: undefined) ||
|
||||
(typeof remote?.url === "string" && remote.url.trim().length > 0
|
||||
? remote.url.trim()
|
||||
: undefined) ||
|
||||
"ws://127.0.0.1:18789";
|
||||
|
||||
const token =
|
||||
(typeof opts.token === "string" && opts.token.trim().length > 0
|
||||
? opts.token.trim()
|
||||
: undefined) ||
|
||||
(isRemoteMode
|
||||
? typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof authToken === "string" && authToken.trim().length > 0
|
||||
? authToken.trim()
|
||||
: undefined));
|
||||
|
||||
const password =
|
||||
(typeof opts.password === "string" && opts.password.trim().length > 0
|
||||
? opts.password.trim()
|
||||
: undefined) ||
|
||||
process.env.CLAWDIS_GATEWAY_PASSWORD?.trim() ||
|
||||
(typeof remote?.password === "string" && remote.password.trim().length > 0
|
||||
? remote.password.trim()
|
||||
: undefined);
|
||||
|
||||
return { url, token, password };
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
|
||||
export class ChatLayout implements Component {
|
||||
constructor(
|
||||
private header: Component,
|
||||
private messages: Component,
|
||||
private status: Component,
|
||||
private input: Component,
|
||||
) {}
|
||||
|
||||
invalidate(): void {
|
||||
this.header.invalidate?.();
|
||||
this.messages.invalidate?.();
|
||||
this.status.invalidate?.();
|
||||
this.input.invalidate?.();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const rows = process.stdout.rows ?? 24;
|
||||
const headerLines = this.header.render(width);
|
||||
const statusLines = this.status.render(width);
|
||||
const inputLines = this.input.render(width);
|
||||
|
||||
const reserved = headerLines.length + statusLines.length + inputLines.length;
|
||||
const available = Math.max(rows - reserved, 0);
|
||||
|
||||
const messageLines = this.messages.render(width);
|
||||
const slicedMessages =
|
||||
available > 0
|
||||
? messageLines.slice(Math.max(0, messageLines.length - available))
|
||||
: [];
|
||||
|
||||
const lines = [...headerLines, ...slicedMessages, ...statusLines, ...inputLines];
|
||||
if (lines.length < rows) {
|
||||
const padding = Array.from({ length: rows - lines.length }, () => "");
|
||||
return [...lines, ...padding];
|
||||
}
|
||||
return lines.slice(0, rows);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { MarkdownTheme, DefaultTextStyle } from "@mariozechner/pi-tui";
|
||||
import { theme } from "./theme.js";
|
||||
|
||||
export class MessageList extends Container {
|
||||
private assistantById = new Map<string, Markdown>();
|
||||
|
||||
constructor(
|
||||
private markdownTheme: MarkdownTheme,
|
||||
private styles: {
|
||||
user: DefaultTextStyle;
|
||||
assistant: DefaultTextStyle;
|
||||
system: DefaultTextStyle;
|
||||
tool: DefaultTextStyle;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.assistantById.clear();
|
||||
this.clear();
|
||||
}
|
||||
|
||||
addSystem(text: string): void {
|
||||
this.addMessage("system", text, this.styles.system);
|
||||
}
|
||||
|
||||
addTool(text: string): void {
|
||||
this.addMessage("tool", text, this.styles.tool);
|
||||
}
|
||||
|
||||
addUser(text: string): void {
|
||||
this.addMessage("user", text, this.styles.user);
|
||||
}
|
||||
|
||||
addAssistant(text: string, id?: string): string {
|
||||
const messageId = id ?? crypto.randomUUID();
|
||||
const label = new Text(theme.assistant("clawd"), 1, 0);
|
||||
const body = new Markdown(text, 1, 0, this.markdownTheme, this.styles.assistant);
|
||||
const group = new Container();
|
||||
group.addChild(label);
|
||||
group.addChild(body);
|
||||
this.addChild(group);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
this.assistantById.set(messageId, body);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
updateAssistant(id: string, text: string): void {
|
||||
const component = this.assistantById.get(id);
|
||||
if (!component) return;
|
||||
component.setText(text);
|
||||
}
|
||||
|
||||
private addMessage(
|
||||
role: MessageEntry["role"],
|
||||
text: string,
|
||||
style: DefaultTextStyle,
|
||||
) {
|
||||
const messageId = crypto.randomUUID();
|
||||
const label = new Text(
|
||||
role === "user"
|
||||
? theme.user("you")
|
||||
: role === "system"
|
||||
? theme.system("system")
|
||||
: theme.dim("tool"),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
const body = new Markdown(text, 1, 0, this.markdownTheme, style);
|
||||
const group = new Container();
|
||||
group.addChild(label);
|
||||
group.addChild(body);
|
||||
this.addChild(group);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import chalk from "chalk";
|
||||
import type { MarkdownTheme } from "@mariozechner/pi-tui";
|
||||
|
||||
export const markdownTheme: MarkdownTheme = {
|
||||
heading: (text) => chalk.bold.cyan(text),
|
||||
link: (text) => chalk.blue(text),
|
||||
linkUrl: (text) => chalk.gray(text),
|
||||
code: (text) => chalk.yellow(text),
|
||||
codeBlock: (text) => chalk.yellow(text),
|
||||
codeBlockBorder: (text) => chalk.gray(text),
|
||||
quote: (text) => chalk.gray(text),
|
||||
quoteBorder: (text) => chalk.gray(text),
|
||||
hr: (text) => chalk.gray(text),
|
||||
listBullet: (text) => chalk.cyan(text),
|
||||
bold: (text) => chalk.bold(text),
|
||||
italic: (text) => chalk.italic(text),
|
||||
strikethrough: (text) => chalk.strikethrough(text),
|
||||
underline: (text) => chalk.underline(text),
|
||||
};
|
||||
|
||||
export const theme = {
|
||||
header: (text: string) => chalk.bold.cyan(text),
|
||||
dim: (text: string) => chalk.gray(text),
|
||||
user: (text: string) => chalk.cyan(text),
|
||||
assistant: (text: string) => chalk.green(text),
|
||||
system: (text: string) => chalk.magenta(text),
|
||||
error: (text: string) => chalk.red(text),
|
||||
};
|
||||
-331
@@ -1,331 +0,0 @@
|
||||
import {
|
||||
type Component,
|
||||
Input,
|
||||
ProcessTerminal,
|
||||
Text,
|
||||
TUI,
|
||||
isCtrlC,
|
||||
isEscape,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { GatewayChatClient } from "./gateway-chat.js";
|
||||
import { ChatLayout } from "./layout.js";
|
||||
import { MessageList } from "./message-list.js";
|
||||
import { markdownTheme, theme } from "./theme.js";
|
||||
|
||||
export type TuiOptions = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
session?: string;
|
||||
deliver?: boolean;
|
||||
thinking?: string;
|
||||
timeoutMs?: number;
|
||||
historyLimit?: number;
|
||||
};
|
||||
|
||||
type ChatEvent = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
state: "delta" | "final" | "aborted" | "error";
|
||||
message?: unknown;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
class InputWrapper implements Component {
|
||||
constructor(
|
||||
private input: Input,
|
||||
private onAbort: () => void,
|
||||
private onExit: () => void,
|
||||
) {
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (isCtrlC(data)) {
|
||||
this.onExit();
|
||||
return;
|
||||
}
|
||||
if (isEscape(data)) {
|
||||
this.onAbort();
|
||||
return;
|
||||
}
|
||||
this.input.handleInput(data);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.input.render(width);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.input.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
function extractText(message?: unknown): string {
|
||||
if (!message || typeof message !== "object") return "";
|
||||
const record = message as Record<string, unknown>;
|
||||
const content = Array.isArray(record.content) ? record.content : [];
|
||||
const parts = content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object") return "";
|
||||
const b = block as Record<string, unknown>;
|
||||
if (b.type === "text" && typeof b.text === "string") return b.text;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
|
||||
function renderHistoryEntry(entry: unknown): { role: "user" | "assistant"; text: string } | null {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const role = record.role === "user" || record.role === "assistant" ? record.role : null;
|
||||
if (!role) return null;
|
||||
const text = extractText(record);
|
||||
if (!text) return null;
|
||||
return { role, text };
|
||||
}
|
||||
|
||||
export async function runTui(opts: TuiOptions) {
|
||||
const config = loadConfig();
|
||||
const defaultSession =
|
||||
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main";
|
||||
let currentSession = defaultSession;
|
||||
let activeRunId: string | null = null;
|
||||
let streamingMessageId: string | null = null;
|
||||
let historyLoaded = false;
|
||||
|
||||
const messages = new MessageList(markdownTheme, {
|
||||
user: { color: theme.user },
|
||||
assistant: { color: theme.assistant },
|
||||
system: { color: theme.system, italic: true },
|
||||
tool: { color: theme.dim, italic: true },
|
||||
});
|
||||
|
||||
const header = new Text("", 1, 0);
|
||||
const status = new Text("", 1, 0);
|
||||
const input = new Input();
|
||||
|
||||
const tui = new TUI(new ProcessTerminal());
|
||||
const inputWrapper = new InputWrapper(
|
||||
input,
|
||||
async () => {
|
||||
if (!activeRunId) return;
|
||||
try {
|
||||
await client.abortChat({ sessionKey: currentSession, runId: activeRunId });
|
||||
} catch (err) {
|
||||
messages.addSystem(`Abort failed: ${String(err)}`);
|
||||
}
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
setStatus("aborted");
|
||||
tui.requestRender();
|
||||
},
|
||||
() => {
|
||||
client.stop();
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
},
|
||||
);
|
||||
|
||||
const layout = new ChatLayout(header, messages, status, inputWrapper);
|
||||
tui.addChild(layout);
|
||||
tui.setFocus(inputWrapper);
|
||||
|
||||
const client = new GatewayChatClient({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
});
|
||||
|
||||
const updateHeader = () => {
|
||||
header.setText(
|
||||
theme.header(
|
||||
`clawdis tui - ${client.connection.url} - session ${currentSession}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const setStatus = (text: string) => {
|
||||
status.setText(theme.dim(text));
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const history = await client.loadHistory({
|
||||
sessionKey: currentSession,
|
||||
limit: opts.historyLimit ?? 200,
|
||||
});
|
||||
const historyRecord = history as { messages?: unknown[] } | undefined;
|
||||
messages.clearAll();
|
||||
messages.addSystem(`session ${currentSession}`);
|
||||
for (const entry of historyRecord?.messages ?? []) {
|
||||
const parsed = renderHistoryEntry(entry);
|
||||
if (!parsed) continue;
|
||||
if (parsed.role === "user") messages.addUser(parsed.text);
|
||||
if (parsed.role === "assistant") messages.addAssistant(parsed.text);
|
||||
}
|
||||
historyLoaded = true;
|
||||
tui.requestRender();
|
||||
} catch (err) {
|
||||
messages.addSystem(`history failed: ${String(err)}`);
|
||||
tui.requestRender();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatEvent = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const evt = payload as ChatEvent;
|
||||
if (evt.sessionKey !== currentSession) return;
|
||||
|
||||
if (evt.state === "delta") {
|
||||
const text = extractText(evt.message);
|
||||
if (!text) return;
|
||||
if (!streamingMessageId || activeRunId !== evt.runId) {
|
||||
streamingMessageId = messages.addAssistant(text, evt.runId);
|
||||
activeRunId = evt.runId;
|
||||
} else {
|
||||
messages.updateAssistant(streamingMessageId, text);
|
||||
}
|
||||
setStatus("streaming");
|
||||
}
|
||||
|
||||
if (evt.state === "final") {
|
||||
const text = extractText(evt.message);
|
||||
if (streamingMessageId && activeRunId === evt.runId) {
|
||||
messages.updateAssistant(streamingMessageId, text || "(no output)");
|
||||
} else if (text) {
|
||||
messages.addAssistant(text, evt.runId);
|
||||
}
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
setStatus("idle");
|
||||
}
|
||||
|
||||
if (evt.state === "aborted") {
|
||||
messages.addSystem("run aborted");
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
setStatus("aborted");
|
||||
}
|
||||
|
||||
if (evt.state === "error") {
|
||||
messages.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
setStatus("error");
|
||||
}
|
||||
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const handleCommand = async (raw: string) => {
|
||||
const [command, ...rest] = raw.slice(1).trim().split(/\s+/);
|
||||
const arg = rest.join(" ").trim();
|
||||
switch (command) {
|
||||
case "help": {
|
||||
messages.addSystem("/help /session <key> /abort /exit");
|
||||
break;
|
||||
}
|
||||
case "session": {
|
||||
if (!arg) {
|
||||
messages.addSystem("missing session key");
|
||||
break;
|
||||
}
|
||||
currentSession = arg;
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
historyLoaded = false;
|
||||
updateHeader();
|
||||
await loadHistory();
|
||||
break;
|
||||
}
|
||||
case "abort": {
|
||||
if (!activeRunId) {
|
||||
messages.addSystem("no active run");
|
||||
break;
|
||||
}
|
||||
await client.abortChat({ sessionKey: currentSession, runId: activeRunId });
|
||||
break;
|
||||
}
|
||||
case "exit":
|
||||
case "quit": {
|
||||
client.stop();
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
default:
|
||||
messages.addSystem(`unknown command: /${command}`);
|
||||
break;
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
try {
|
||||
messages.addUser(text);
|
||||
tui.requestRender();
|
||||
setStatus("sending");
|
||||
const { runId } = await client.sendChat({
|
||||
sessionKey: currentSession,
|
||||
message: text,
|
||||
thinking: opts.thinking,
|
||||
deliver: opts.deliver,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
activeRunId = runId;
|
||||
streamingMessageId = null;
|
||||
setStatus("waiting");
|
||||
} catch (err) {
|
||||
messages.addSystem(`send failed: ${String(err)}`);
|
||||
setStatus("error");
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
input.onSubmit = (value) => {
|
||||
const text = value.trim();
|
||||
input.setValue("");
|
||||
if (!text) return;
|
||||
if (text.startsWith("/")) {
|
||||
void handleCommand(text);
|
||||
return;
|
||||
}
|
||||
void sendMessage(text);
|
||||
};
|
||||
|
||||
client.onEvent = (evt) => {
|
||||
if (evt.event === "chat") handleChatEvent(evt.payload);
|
||||
};
|
||||
|
||||
client.onConnected = () => {
|
||||
setStatus("connected");
|
||||
updateHeader();
|
||||
if (!historyLoaded) {
|
||||
void loadHistory().then(() => {
|
||||
messages.addSystem("gateway connected");
|
||||
tui.requestRender();
|
||||
});
|
||||
} else {
|
||||
messages.addSystem("gateway reconnected");
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
client.onDisconnected = (reason) => {
|
||||
messages.addSystem(`gateway disconnected: ${reason || "closed"}`);
|
||||
setStatus("disconnected");
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
client.onGap = (info) => {
|
||||
messages.addSystem(`event gap: expected ${info.expected}, got ${info.received}`);
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
updateHeader();
|
||||
setStatus("connecting");
|
||||
messages.addSystem("connecting...");
|
||||
tui.start();
|
||||
client.start();
|
||||
}
|
||||
+7
-8
@@ -26,13 +26,12 @@ import type {
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
} from "./types";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
type CronFormState,
|
||||
type DiscordForm,
|
||||
type IMessageForm,
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
import type {
|
||||
CronFormState,
|
||||
DiscordForm,
|
||||
IMessageForm,
|
||||
SignalForm,
|
||||
TelegramForm,
|
||||
} from "./ui-types";
|
||||
import { loadChatHistory, sendChat, handleChatEvent } from "./controllers/chat";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
@@ -144,7 +143,7 @@ export class ClawdisApp extends LitElement {
|
||||
groupChannels: "",
|
||||
mediaMaxMb: "",
|
||||
historyLimit: "",
|
||||
actions: { ...defaultDiscordActions },
|
||||
enableReactions: true,
|
||||
slashEnabled: false,
|
||||
slashName: "",
|
||||
slashSessionPrefix: "",
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { ConfigSnapshot } from "../types";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
type DiscordActionForm,
|
||||
type DiscordForm,
|
||||
type IMessageForm,
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
} from "../ui-types";
|
||||
import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types";
|
||||
|
||||
export type ConfigState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -95,11 +88,6 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
|
||||
const discordDm = (discord.dm ?? {}) as Record<string, unknown>;
|
||||
const slash = (discord.slashCommand ?? {}) as Record<string, unknown>;
|
||||
const discordActions = (discord.actions ?? {}) as Record<string, unknown>;
|
||||
const readAction = (key: keyof DiscordActionForm) =>
|
||||
typeof discordActions[key] === "boolean"
|
||||
? (discordActions[key] as boolean)
|
||||
: defaultDiscordActions[key];
|
||||
state.discordForm = {
|
||||
enabled: typeof discord.enabled === "boolean" ? discord.enabled : true,
|
||||
token: typeof discord.token === "string" ? discord.token : "",
|
||||
@@ -111,23 +99,8 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
typeof discord.mediaMaxMb === "number" ? String(discord.mediaMaxMb) : "",
|
||||
historyLimit:
|
||||
typeof discord.historyLimit === "number" ? String(discord.historyLimit) : "",
|
||||
actions: {
|
||||
reactions: readAction("reactions"),
|
||||
stickers: readAction("stickers"),
|
||||
polls: readAction("polls"),
|
||||
permissions: readAction("permissions"),
|
||||
messages: readAction("messages"),
|
||||
threads: readAction("threads"),
|
||||
pins: readAction("pins"),
|
||||
search: readAction("search"),
|
||||
memberInfo: readAction("memberInfo"),
|
||||
roleInfo: readAction("roleInfo"),
|
||||
channelInfo: readAction("channelInfo"),
|
||||
voiceStatus: readAction("voiceStatus"),
|
||||
events: readAction("events"),
|
||||
roles: readAction("roles"),
|
||||
moderation: readAction("moderation"),
|
||||
},
|
||||
enableReactions:
|
||||
typeof discord.enableReactions === "boolean" ? discord.enableReactions : true,
|
||||
slashEnabled: typeof slash.enabled === "boolean" ? slash.enabled : false,
|
||||
slashName: typeof slash.name === "string" ? slash.name : "",
|
||||
slashSessionPrefix:
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import { parseList } from "../format";
|
||||
import type { ConfigSnapshot, ProvidersStatusSnapshot } from "../types";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
type DiscordActionForm,
|
||||
type DiscordForm,
|
||||
type IMessageForm,
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
} from "../ui-types";
|
||||
import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types";
|
||||
|
||||
export type ConnectionsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -123,14 +116,6 @@ export function updateDiscordForm(
|
||||
state: ConnectionsState,
|
||||
patch: Partial<DiscordForm>,
|
||||
) {
|
||||
if (patch.actions) {
|
||||
state.discordForm = {
|
||||
...state.discordForm,
|
||||
...patch,
|
||||
actions: { ...state.discordForm.actions, ...patch.actions },
|
||||
};
|
||||
return;
|
||||
}
|
||||
state.discordForm = { ...state.discordForm, ...patch };
|
||||
}
|
||||
|
||||
@@ -261,30 +246,10 @@ export async function saveDiscordConfig(state: ConnectionsState) {
|
||||
}
|
||||
}
|
||||
|
||||
const actions: Partial<DiscordActionForm> = {};
|
||||
const applyAction = (key: keyof DiscordActionForm) => {
|
||||
const value = form.actions[key];
|
||||
if (value !== defaultDiscordActions[key]) actions[key] = value;
|
||||
};
|
||||
applyAction("reactions");
|
||||
applyAction("stickers");
|
||||
applyAction("polls");
|
||||
applyAction("permissions");
|
||||
applyAction("messages");
|
||||
applyAction("threads");
|
||||
applyAction("pins");
|
||||
applyAction("search");
|
||||
applyAction("memberInfo");
|
||||
applyAction("roleInfo");
|
||||
applyAction("channelInfo");
|
||||
applyAction("voiceStatus");
|
||||
applyAction("events");
|
||||
applyAction("roles");
|
||||
applyAction("moderation");
|
||||
if (Object.keys(actions).length > 0) {
|
||||
discord.actions = actions;
|
||||
if (form.enableReactions) {
|
||||
delete discord.enableReactions;
|
||||
} else {
|
||||
delete discord.actions;
|
||||
discord.enableReactions = false;
|
||||
}
|
||||
|
||||
const slash = { ...(discord.slashCommand ?? {}) } as Record<string, unknown>;
|
||||
|
||||
+1
-37
@@ -16,49 +16,13 @@ export type DiscordForm = {
|
||||
groupChannels: string;
|
||||
mediaMaxMb: string;
|
||||
historyLimit: string;
|
||||
actions: DiscordActionForm;
|
||||
enableReactions: boolean;
|
||||
slashEnabled: boolean;
|
||||
slashName: string;
|
||||
slashSessionPrefix: string;
|
||||
slashEphemeral: boolean;
|
||||
};
|
||||
|
||||
export type DiscordActionForm = {
|
||||
reactions: boolean;
|
||||
stickers: boolean;
|
||||
polls: boolean;
|
||||
permissions: boolean;
|
||||
messages: boolean;
|
||||
threads: boolean;
|
||||
pins: boolean;
|
||||
search: boolean;
|
||||
memberInfo: boolean;
|
||||
roleInfo: boolean;
|
||||
channelInfo: boolean;
|
||||
voiceStatus: boolean;
|
||||
events: boolean;
|
||||
roles: boolean;
|
||||
moderation: boolean;
|
||||
};
|
||||
|
||||
export const defaultDiscordActions: DiscordActionForm = {
|
||||
reactions: true,
|
||||
stickers: true,
|
||||
polls: true,
|
||||
permissions: true,
|
||||
messages: true,
|
||||
threads: true,
|
||||
pins: true,
|
||||
search: true,
|
||||
memberInfo: true,
|
||||
roleInfo: true,
|
||||
channelInfo: true,
|
||||
voiceStatus: true,
|
||||
events: true,
|
||||
roles: false,
|
||||
moderation: false,
|
||||
};
|
||||
|
||||
export type SignalForm = {
|
||||
enabled: boolean;
|
||||
account: string;
|
||||
|
||||
@@ -2,13 +2,7 @@ import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { ProvidersStatusSnapshot } from "../types";
|
||||
import type {
|
||||
DiscordActionForm,
|
||||
DiscordForm,
|
||||
IMessageForm,
|
||||
SignalForm,
|
||||
TelegramForm,
|
||||
} from "../ui-types";
|
||||
import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types";
|
||||
|
||||
export type ConnectionsProps = {
|
||||
connected: boolean;
|
||||
@@ -54,24 +48,6 @@ export function renderConnections(props: ConnectionsProps) {
|
||||
const discord = props.snapshot?.discord ?? null;
|
||||
const signal = props.snapshot?.signal ?? null;
|
||||
const imessage = props.snapshot?.imessage ?? null;
|
||||
const discordActionOptions: Array<{ key: keyof DiscordActionForm; label: string }> =
|
||||
[
|
||||
{ key: "reactions", label: "Reactions" },
|
||||
{ key: "stickers", label: "Stickers" },
|
||||
{ key: "polls", label: "Polls" },
|
||||
{ key: "permissions", label: "Permissions" },
|
||||
{ key: "messages", label: "Messages" },
|
||||
{ key: "threads", label: "Threads" },
|
||||
{ key: "pins", label: "Pins" },
|
||||
{ key: "search", label: "Search" },
|
||||
{ key: "memberInfo", label: "Member info" },
|
||||
{ key: "roleInfo", label: "Role info" },
|
||||
{ key: "channelInfo", label: "Channel info" },
|
||||
{ key: "voiceStatus", label: "Voice status" },
|
||||
{ key: "events", label: "Events" },
|
||||
{ key: "roles", label: "Role changes" },
|
||||
{ key: "moderation", label: "Moderation" },
|
||||
];
|
||||
const providerOrder: ProviderKey[] = [
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
@@ -546,6 +522,19 @@ function renderProvider(
|
||||
placeholder="20"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reactions</span>
|
||||
<select
|
||||
.value=${props.discordForm.enableReactions ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
enableReactions: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slash command</span>
|
||||
<select
|
||||
@@ -596,28 +585,6 @@ function renderProvider(
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card-sub" style="margin-top: 16px;">Tool actions</div>
|
||||
<div class="form-grid" style="margin-top: 8px;">
|
||||
${discordActionOptions.map(
|
||||
(action) => html`<label class="field">
|
||||
<span>${action.label}</span>
|
||||
<select
|
||||
.value=${props.discordForm.actions[action.key] ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
actions: {
|
||||
...props.discordForm.actions,
|
||||
[action.key]: (e.target as HTMLSelectElement).value === "yes",
|
||||
},
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
${props.discordTokenLocked
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
DISCORD_BOT_TOKEN is set in the environment. Config edits will not override it.
|
||||
|
||||
Reference in new issue
Block a user