diff --git a/.env.example b/.env.example index 669b5c1..a3f55f8 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,14 @@ DISCORD_CLIENT_ID=your_application_client_id_here DISCORD_GUILD_ID=your_guild_id_here DISCORD_ALLOWED_USER_IDS=123,456 # comma-separated Discord user IDs allowed to use slash commands DISCORD_MENTION_USER_ID= # optional: Discord user ID to @mention when --mention is used + +# --- Slack provider (loaded only if 'slack' is in ENABLED_PROVIDERS) --- +SLACK_BOT_TOKEN=xoxb-your-bot-token-here +SLACK_SIGNING_SECRET=your_signing_secret_here +SLACK_TEAM_ID=T0123456789 +SLACK_APP_ID=A0123456789 +SLACK_SOCKET_MODE_TOKEN= # optional: app-level token (xapp-...) for Socket Mode; if set, Socket Mode is used instead of HTTP webhooks +SLACK_BOT_PUBLIC_URL= # optional: public HTTPS URL for Bolt's ExpressReceiver (webhook mode only) +SLACK_PORT=3000 # optional: HTTP port for Bolt's ExpressReceiver (webhook mode only, default 3000) +SLACK_ALLOWED_USER_IDS=U123,U456 # optional: comma-separated Slack user IDs allowed to use slash commands +SLACK_MENTION_USER_ID= # optional: Slack user ID to @mention when --mention is used diff --git a/AGENTS-providers.md b/AGENTS-providers.md index 5792821..b6e5895 100644 --- a/AGENTS-providers.md +++ b/AGENTS-providers.md @@ -1,8 +1,8 @@ # Provider development guide -This document is the deep-dive companion to [`AGENTS.md`](AGENTS.md) (and [`docs/architecture.md`](docs/architecture.md)) for adding a new chat-platform provider to Maestro Relay. Everything below is what you'd need to know to ship a Slack, Teams, Matrix, etc. adapter without touching the kernel. +This document is the deep-dive companion to [`AGENTS.md`](AGENTS.md) (and [`docs/architecture.md`](docs/architecture.md)) for adding a new chat-platform provider to Maestro Relay. Discord and Slack are already built-in (see [`docs/discord.md`](docs/discord.md) and [`docs/slack.md`](docs/slack.md)); everything below is what you'd need to know to ship a Teams, Matrix, etc. adapter without touching the kernel. -If you're adding behavior to the existing Discord provider rather than building a new one, work in `src/providers/discord/` and consult [`docs/discord.md`](docs/discord.md) instead. +If you're adding behavior to an existing provider rather than building a new one, work in `src/providers/discord/` or `src/providers/slack/` and consult the matching `docs/.md` instead. ## The kernel/provider boundary @@ -107,12 +107,13 @@ Things to keep out of `src/core/`: SDK imports (`discord.js`, `@slack/bolt`, etc ### 1. Register the adapter -In `src/core/providers.ts`, add a `case` to `loadProvider`: +In `src/core/providers.ts`, add a `case` to `loadProvider` (alongside the existing `discord` and `slack` cases): ```ts -case 'slack': - const { SlackProvider } = await import('../providers/slack/adapter.js'); - return new SlackProvider(); +case 'teams': { + const { TeamsProvider } = await import('../providers/teams/adapter'); + return new TeamsProvider(); +} ``` This is the only kernel file the provider should touch. @@ -122,10 +123,10 @@ This is the only kernel file the provider should touch. Add a section to `.env.example`: ```env -# --- Slack provider (loaded only if 'slack' is in ENABLED_PROVIDERS) --- -SLACK_BOT_TOKEN=your_xoxb_token_here -SLACK_APP_TOKEN=your_xapp_token_here -SLACK_SIGNING_SECRET=your_signing_secret_here +# --- Teams provider (loaded only if 'teams' is in ENABLED_PROVIDERS) --- +TEAMS_BOT_TOKEN=your_token_here +TEAMS_APP_ID=your_app_id_here +TEAMS_TENANT_ID=your_tenant_id_here ``` Validate creds in `start(ctx)`, throwing a clear error if missing. Don't validate at module load — a disabled provider must not fail the bridge on missing env. diff --git a/AGENTS.md b/AGENTS.md index 8ae9131..b47df9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Agent Guide -This repo is **Maestro Relay** — a chat-platform-to-Maestro bridge built around a provider-agnostic kernel. Discord is the first provider; Slack/Teams plug in alongside it without touching the kernel. `CLAUDE.md` is a symlink to this file. +This repo is **Maestro Relay** — a chat-platform-to-Maestro bridge built around a provider-agnostic kernel. Discord and Slack ship in the box; Teams/Matrix plug in alongside them without touching the kernel. `CLAUDE.md` is a symlink to this file. ## Development workflow @@ -30,6 +30,10 @@ This repo is **Maestro Relay** — a chat-platform-to-Maestro bridge built aroun Lives under `src/providers/discord/` (`adapter.ts`, `messageCreate.ts`, `voice.ts`, `commands/`, `deploy.ts`, `channelsDb.ts`, `threadsDb.ts`, `embed.ts`, `config.ts`). For Discord-specific runtime behavior, env vars, slash commands, and bot setup see [docs/discord.md](docs/discord.md). Voice transcription is documented in [docs/voice.md](docs/voice.md). +### Slack provider + +Lives under `src/providers/slack/` (`adapter.ts`, `messageCreate.ts`, `commands/`, `channelsDb.ts`, `conversationsDb.ts`, `config.ts`). Uses `@slack/bolt` (Socket Mode in dev, ExpressReceiver in production). Thread registry (`slack_agent_conversations`) is keyed on `thread_ts`. For Slack-specific runtime behavior, env vars, slash commands, and app setup see [docs/slack.md](docs/slack.md). + ### CLI - `src/cli/maestro-relay.ts` — verb dispatcher (`send`, `notify`, `status`) @@ -59,7 +63,7 @@ TL;DR: ## Installer module switch -- `install.sh` supports `MAESTRO_RELAY_MODULE` (fallback `MAESTRO_BRIDGE_MODULE`), currently accepting only `discord`. +- `install.sh` supports `MAESTRO_RELAY_MODULE` (fallback `MAESTRO_BRIDGE_MODULE`), currently accepting `discord` and `slack`. - Keep installer module selection aligned with runtime `ENABLED_PROVIDERS` and CLI `--provider` support. - When adding a provider, update installer validation/prompting and `maestro-relay-ctl deploy` routing so deploy behavior is module-aware. @@ -73,7 +77,7 @@ TL;DR: ## Expectations for changes -- Follow existing patterns in `src/core/` and `src/providers/discord/` before introducing new abstractions. -- Provider-specific code (Discord types, slash commands, threads) lives in `src/providers/discord/` — keep `src/core/` free of `discord.js` imports. +- Follow existing patterns in `src/core/` and `src/providers/{discord,slack}/` before introducing new abstractions. +- Provider-specific code (Discord types, Slack Bolt handlers, slash commands, threads) lives under `src/providers//` — keep `src/core/` free of `discord.js` and `@slack/bolt` imports. - Keep changes minimal and focused. - Update docs when behavior or setup changes. diff --git a/README.md b/README.md index 5add5a9..556dd44 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ [![Made with Maestro](https://raw.githubusercontent.com/RunMaestro/Maestro/main/docs/assets/made-with-maestro.svg)](https://github.com/RunMaestro/Maestro) -**Maestro Relay** connects chat platforms to [Maestro](https://runmaestro.ai) AI agents through `maestro-cli`. Discord ships in the box; Slack, Teams, and others can be added by dropping in a provider adapter — the kernel is provider-agnostic. +**Maestro Relay** connects chat platforms to [Maestro](https://runmaestro.ai) AI agents through `maestro-cli`. Discord and Slack ship in the box; Teams, Matrix, and others can be added by dropping in a provider adapter — the kernel is provider-agnostic. > **Migrating from `discord-maestro`?** Same codebase, new name. The legacy `maestro-discord` binary is preserved as an alias and all `DISCORD_*` env vars work unchanged. See "Migration" below. ## Features -- Provider-pluggable kernel — Discord today, Slack/Teams next +- Provider-pluggable kernel — Discord and Slack today, Teams/Matrix next - Creates dedicated channels for Maestro agents - Per-user session threads (`/session new` or by mentioning the bot) - Per-conversation FIFO queue with typing/reaction indicators @@ -18,7 +18,7 @@ ## Prerequisites - Node.js 22+ -- A Discord application + bot token (if running the Discord provider) +- A bot token for at least one supported provider (Discord or Slack) - [Maestro CLI](https://docs.runmaestro.ai/cli) on your `PATH` ## Install (production one-liner) @@ -49,7 +49,7 @@ The legacy aliases `maestro-bridge-ctl` and `maestro-discord-ctl` still work for | systemd user / launchd agent | Auto-start unit | Override any of these with `MAESTRO_RELAY_HOME`, `XDG_CONFIG_HOME`, or `MAESTRO_RELAY_BIN_DIR`. Pin a specific version with `MAESTRO_RELAY_VERSION=v1.0.0`. -Choose a provider module at install time via `MAESTRO_RELAY_MODULE` (currently only `discord` is supported). +Choose a provider module at install time via `MAESTRO_RELAY_MODULE` (`discord` or `slack`). ## Install (development from source) @@ -70,11 +70,11 @@ cp .env.example .env Set core values in `.env`: ``` -ENABLED_PROVIDERS=discord # comma-separated; default 'discord' +ENABLED_PROVIDERS=discord # comma-separated; default 'discord'. Use 'slack' or 'discord,slack' for multi-provider deployments API_PORT=3457 # optional, default 3457 ``` -Then fill in the provider-specific keys. The Discord provider needs `DISCORD_BOT_TOKEN`, `DISCORD_CLIENT_ID`, and `DISCORD_GUILD_ID` — see [docs/discord.md](docs/discord.md) for bot setup, the full env-var reference, and slash-command deployment. For optional voice transcription, see [docs/voice.md](docs/voice.md). +Then fill in the provider-specific keys. The Discord provider needs `DISCORD_BOT_TOKEN`, `DISCORD_CLIENT_ID`, and `DISCORD_GUILD_ID` — see [docs/discord.md](docs/discord.md) for bot setup, the full env-var reference, and slash-command deployment. The Slack provider needs `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `SLACK_TEAM_ID`, and `SLACK_APP_ID` — see [docs/slack.md](docs/slack.md). For optional voice transcription (Discord), see [docs/voice.md](docs/voice.md). 3. Deploy slash commands (Discord): @@ -118,9 +118,10 @@ npm run build && node --test --experimental-test-coverage dist/__tests__/**/*.te | Provider | Docs | Status | | -------- | ---- | ------ | | Discord | [docs/discord.md](docs/discord.md) — bot setup, env vars, slash commands, runtime behavior | Built-in | -| Slack / Teams / Matrix | [AGENTS-providers.md](AGENTS-providers.md) — provider development guide | Add your own | +| Slack | [docs/slack.md](docs/slack.md) — app setup, env vars, slash commands, runtime behavior | Built-in | +| Teams / Matrix / … | [AGENTS-providers.md](AGENTS-providers.md) — provider development guide | Add your own | -Optional voice transcription (whisper.cpp): [docs/voice.md](docs/voice.md). +Optional voice transcription (whisper.cpp, Discord-only today): [docs/voice.md](docs/voice.md). ## How it works diff --git a/bin/maestro-relay-ctl.sh b/bin/maestro-relay-ctl.sh index 51315aa..c4de906 100755 --- a/bin/maestro-relay-ctl.sh +++ b/bin/maestro-relay-ctl.sh @@ -145,7 +145,17 @@ cmd_logs() { config_complete() { local file="$1" key value [ -f "$file" ] || return 1 - for key in DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID; do + local enabled_module + enabled_module="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*/\1/p' "$file" | head -n1)" + enabled_module="${enabled_module#\"}"; enabled_module="${enabled_module%\"}" + enabled_module="${enabled_module#\'}"; enabled_module="${enabled_module%\'}" + local required_keys + if [ "$enabled_module" = "slack" ]; then + required_keys="SLACK_BOT_TOKEN SLACK_SIGNING_SECRET SLACK_TEAM_ID SLACK_APP_ID" + else + required_keys="DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID" + fi + for key in $required_keys; do value="$(sed -nE "s/^${key}=([^#[:space:]]+).*/\1/p" "$file" | head -n1)" [ -n "$value" ] || return 1 case "$value" in diff --git a/docs/slack.md b/docs/slack.md new file mode 100644 index 0000000..31978c4 --- /dev/null +++ b/docs/slack.md @@ -0,0 +1,92 @@ +# Slack provider + +The Slack provider lets Maestro Relay run inside a Slack workspace alongside or instead of Discord. This document covers everything Slack-specific: app creation, scopes, slash commands, and runtime behavior. For the kernel/provider boundary, see [architecture.md](architecture.md). + +The provider only loads if `slack` is in `ENABLED_PROVIDERS`. To run Slack and Discord simultaneously: `ENABLED_PROVIDERS=discord,slack`. + +## App setup + +1. Create an app at https://api.slack.com/apps (choose **From scratch**, pick a workspace). +2. **OAuth & Permissions → Bot Token Scopes**, add: + - `app_mentions:read` + - `channels:history`, `channels:read`, `channels:join`, `channels:manage` + - `chat:write`, `chat:write.public` + - `commands` + - `reactions:write` + - `users:read` +3. **Event Subscriptions**: + - Subscribe to bot events: `app_mention`, `message.channels`. + - If you're using Socket Mode, enable it under **Settings → Socket Mode** and generate an **App-Level Token** with `connections:write`. This becomes `SLACK_SOCKET_MODE_TOKEN`. + - Otherwise (webhook mode) point the **Request URL** at `https:///slack/events` and set `SLACK_BOT_PUBLIC_URL` accordingly. +4. **Slash Commands** — create one entry per command (`/health`, `/agents`, `/session`). Request URL is the same `…/slack/events` (webhook mode) or unused (Socket Mode). +5. Install the app to the workspace and copy: + - **Bot User OAuth Token** (`xoxb-…`) → `SLACK_BOT_TOKEN` + - **Signing Secret** (Basic Information → App Credentials) → `SLACK_SIGNING_SECRET` + - Workspace **Team ID** (`T…`) → `SLACK_TEAM_ID` + - **App ID** (`A…`) → `SLACK_APP_ID` + +## Configuration + +Slack provider keys read from `.env`: + +| Key | Required | Purpose | +| ------------------------- | -------- | ---------------------------------------------------------------------------------------- | +| `SLACK_BOT_TOKEN` | yes | Bot User OAuth Token (`xoxb-…`) | +| `SLACK_SIGNING_SECRET` | yes | App-credentials signing secret (HTTP webhook verification) | +| `SLACK_TEAM_ID` | yes | Workspace ID (`T…`) | +| `SLACK_APP_ID` | yes | App ID (`A…`) | +| `SLACK_SOCKET_MODE_TOKEN` | no | App-level token (`xapp-…`); when set, Socket Mode is used instead of HTTP webhooks | +| `SLACK_BOT_PUBLIC_URL` | no | Public HTTPS URL for Bolt's `ExpressReceiver` (webhook mode only) | +| `SLACK_PORT` | no | HTTP port for `ExpressReceiver` (webhook mode only, default `3000`) | +| `SLACK_ALLOWED_USER_IDS` | no | Comma-separated Slack user IDs allowed to use slash commands; empty allows everyone | +| `SLACK_MENTION_USER_ID` | no | User ID to `@mention` when API callers pass `mention=true` | + +The Slack adapter loads its config lazily, so a deployment that disables Slack (`ENABLED_PROVIDERS=discord`) does **not** fail at startup for missing `SLACK_*` keys. + +### Choosing Socket Mode vs webhook mode + +- **Socket Mode** (`SLACK_SOCKET_MODE_TOKEN` set) — easiest for development and self-hosted production. The relay opens an outbound WebSocket to Slack; no public HTTPS endpoint required. +- **Webhook mode** (`SLACK_SOCKET_MODE_TOKEN` empty) — requires a publicly reachable HTTPS URL pointed at the relay (set `SLACK_BOT_PUBLIC_URL`, optionally override `SLACK_PORT`). Use this when you need a stateless deployment behind a load balancer. + +## Slash commands + +| Command | Description | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `/health` | Verify the relay process is healthy | +| `/agents list` | Show all available Maestro agents | +| `/agents new ` | Create (or reopen) a dedicated public channel `#maestro-` and register it for the agent | +| `/agents disconnect [agent-id]` | (Run inside an agent channel) Unregister the binding and archive the channel | +| `/agents readonly ` | (Run inside an agent channel) Toggle read-only mode for the bound agent | +| `/session new [name]` | Post a parent message in the current agent channel and bind a new owner-scoped thread to the invoking user | + +The Slack provider deliberately ships a smaller command surface than Discord — the playbook, gist, notes, and auto-run flows are Discord-only today. + +## Runtime behavior + +- **Mentioning the bot** in a registered agent channel posts a new top-level message and binds it as a thread to the invoking user. Subsequent replies in that thread are forwarded to the agent. +- **`/session new`** does the same thing without requiring a mention; an optional name is shown in the parent message. +- **Owner-bound threads**: only the user who created the thread can drive the agent. Messages from other users are silently ignored. +- **Reactions**: `⏳` (`hourglass_flowing_sand`) while a message is queued. The Slack API requires emoji *names*, not Unicode characters; the adapter maps `⏳ 🎧 ✅ ❌` to the corresponding Slack names — pass any of them to `provider.react()` and the mapping happens automatically. +- **Typing indicator**: not exposed by Slack's Web API; `sendTyping` is a no-op on this provider. +- **Usage stats** are appended below each agent reply (tokens, cost, context %). +- **Channel naming**: agent channels are named `maestro--`, where `id-prefix` is the first 8 alphanumeric characters of the agent ID. The agent ID makes the name unique even when two different agents normalize to the same display name. The whole result is capped at 80 characters. Both `/agents new` and the HTTP-API auto-create path (`POST /api/send`) use the same helper. If the channel already exists but is archived, the adapter unarchives it; if unarchive fails, it falls back to creating a fresh channel with a `-` suffix appended to the base name. + +## Storage + +- The shared `agent_channels` table stores Slack channel ↔ agent bindings with `provider='slack'`. +- `slack_agent_conversations` is a Slack-only thread registry keyed on `thread_ts`. It records `(channel_id, agent_id, owner_user_id, session_id)` and is dropped along with its parent channel when `/agents disconnect` runs. + +## Security + +- Slash command access can be locked down with `SLACK_ALLOWED_USER_IDS`. When empty, all workspace members may use slash commands. +- Threads created by mention or `/session new` are bound to a single owner; non-owner messages in the thread are ignored silently. +- The bot only auto-creates **public** channels (`is_private: false`). To use private channels, create them manually and run `/agents new` from inside. + +## Troubleshooting + +- **`/health` posts but slash commands return `dispatch_failed`** → confirm the slash commands are registered in **Slack App → Slash Commands** with the right Request URL (Socket Mode users can leave it blank). +- **`⏳` reaction never appears** → check the bot has `reactions:write` and was reinstalled to the workspace after adding the scope. The adapter logs reaction failures via `logger.error('queue:react', …)`. +- **`signing_secret_missing` or HTTP 401 from Slack** → fill in `SLACK_SIGNING_SECRET`. Required even in Socket Mode setups for parts of the Bolt SDK. +- **Bot is online but ignores thread replies** → confirm the thread is in `slack_agent_conversations` (`/session new` or a mention created it) and that the message author matches `owner_user_id`. +- **Channel creation fails with `name_taken`** → an archived channel with the same `maestro-<…>-` name exists and unarchive failed. The adapter falls back to `-`; if that also fails (e.g. workspace channel limits, scope missing), create the channel manually and re-run `/agents new`. +- **Slack rejects the emoji name** → the adapter maps `⏳ 🎧 ✅ ❌`. Other Unicode emoji are passed through unchanged; if you call `provider.react()` from custom code with a Unicode emoji that's not in the map, add it to `UNICODE_TO_SLACK` in `src/providers/slack/adapter.ts`. diff --git a/install.sh b/install.sh index d814149..ab64d51 100755 --- a/install.sh +++ b/install.sh @@ -96,7 +96,8 @@ normalize_module() { raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" case "$raw" in discord|'') echo "discord" ;; - *) die "Unsupported module/provider: $raw (supported today: discord)" ;; + slack) echo "slack" ;; + *) die "Unsupported module/provider: $raw (supported: discord, slack)" ;; esac } @@ -227,10 +228,20 @@ write_config() { local interactive=0 can_read_tty && interactive=1 local have_required=0 - if [ -n "${DISCORD_BOT_TOKEN:-}" ] \ - && [ -n "${DISCORD_CLIENT_ID:-}" ] \ - && [ -n "${DISCORD_GUILD_ID:-}" ]; then - have_required=1 + MODULE="$(normalize_module "$MODULE")" + if [ "$MODULE" = "slack" ]; then + if [ -n "${SLACK_BOT_TOKEN:-}" ] \ + && [ -n "${SLACK_SIGNING_SECRET:-}" ] \ + && [ -n "${SLACK_TEAM_ID:-}" ] \ + && [ -n "${SLACK_APP_ID:-}" ]; then + have_required=1 + fi + else + if [ -n "${DISCORD_BOT_TOKEN:-}" ] \ + && [ -n "${DISCORD_CLIENT_ID:-}" ] \ + && [ -n "${DISCORD_GUILD_ID:-}" ]; then + have_required=1 + fi fi if [ "$interactive" -eq 0 ] && [ "$have_required" -eq 0 ]; then @@ -242,17 +253,16 @@ write_config() { fi if [ "$interactive" -eq 1 ]; then - info "Configuring $env_file" - echo " Find these values in https://discord.com/developers/applications" + if [ "$MODULE" = "slack" ]; then + info "Configuring $env_file" + echo " Find these values in https://api.slack.com/apps" + else + info "Configuring $env_file" + echo " Find these values in https://discord.com/developers/applications" + fi else info "Writing config from environment to $env_file" fi - local token client_id guild_id allowed - MODULE="$(normalize_module "$MODULE")" - token="$(prompt_var DISCORD_BOT_TOKEN 'Discord bot token')" - client_id="$(prompt_var DISCORD_CLIENT_ID 'Discord application (client) ID')" - guild_id="$(prompt_var DISCORD_GUILD_ID 'Discord guild (server) ID')" - allowed="$(prompt_var DISCORD_ALLOWED_USER_IDS 'Allowed user IDs (comma-separated, optional)')" local tmp_env tmp_env="$(mktemp "${env_file}.XXXXXX")" @@ -261,11 +271,36 @@ write_config() { printf '# Generated by install.sh on %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf 'ENABLED_PROVIDERS=%s\n' "$MODULE" printf 'API_PORT=3457\n' - printf 'DISCORD_BOT_TOKEN=%s\n' "$token" - printf 'DISCORD_CLIENT_ID=%s\n' "$client_id" - printf 'DISCORD_GUILD_ID=%s\n' "$guild_id" - printf 'DISCORD_ALLOWED_USER_IDS=%s\n' "$allowed" - printf 'DISCORD_MENTION_USER_ID=\n' + if [ "$MODULE" = "slack" ]; then + local bot_token signing_secret team_id app_id socket_token slack_allowed mention_user slack_port + bot_token="$(prompt_var SLACK_BOT_TOKEN 'Slack bot token (xoxb-...)')" + signing_secret="$(prompt_var SLACK_SIGNING_SECRET 'Slack signing secret')" + team_id="$(prompt_var SLACK_TEAM_ID 'Slack team (workspace) ID')" + app_id="$(prompt_var SLACK_APP_ID 'Slack app ID')" + socket_token="$(prompt_var SLACK_SOCKET_MODE_TOKEN 'Slack Socket Mode app-level token (xapp-..., optional)')" + slack_allowed="$(prompt_var SLACK_ALLOWED_USER_IDS 'Allowed Slack user IDs (comma-separated, optional)')" + mention_user="$(prompt_var SLACK_MENTION_USER_ID 'Slack mention user ID (optional)')" + slack_port="$(prompt_var SLACK_PORT 'Slack HTTP port (optional, default 3000)')" + printf 'SLACK_BOT_TOKEN=%s\n' "$bot_token" + printf 'SLACK_SIGNING_SECRET=%s\n' "$signing_secret" + printf 'SLACK_TEAM_ID=%s\n' "$team_id" + printf 'SLACK_APP_ID=%s\n' "$app_id" + printf 'SLACK_SOCKET_MODE_TOKEN=%s\n' "$socket_token" + printf 'SLACK_ALLOWED_USER_IDS=%s\n' "$slack_allowed" + printf 'SLACK_MENTION_USER_ID=%s\n' "$mention_user" + printf 'SLACK_PORT=%s\n' "${slack_port:-3000}" + else + local token client_id guild_id allowed + token="$(prompt_var DISCORD_BOT_TOKEN 'Discord bot token')" + client_id="$(prompt_var DISCORD_CLIENT_ID 'Discord application (client) ID')" + guild_id="$(prompt_var DISCORD_GUILD_ID 'Discord guild (server) ID')" + allowed="$(prompt_var DISCORD_ALLOWED_USER_IDS 'Allowed user IDs (comma-separated, optional)')" + printf 'DISCORD_BOT_TOKEN=%s\n' "$token" + printf 'DISCORD_CLIENT_ID=%s\n' "$client_id" + printf 'DISCORD_GUILD_ID=%s\n' "$guild_id" + printf 'DISCORD_ALLOWED_USER_IDS=%s\n' "$allowed" + printf 'DISCORD_MENTION_USER_ID=\n' + fi printf 'FFMPEG_PATH=%s\n' "${VOICE_FFMPEG:-ffmpeg}" printf 'WHISPER_CLI_PATH=%s\n' "${VOICE_WHISPER:-whisper-cli}" printf 'WHISPER_MODEL_PATH=%s\n' "${VOICE_MODEL:-models/${DEFAULT_MODEL_NAME}}" @@ -278,7 +313,17 @@ write_config() { config_complete() { local file="$1" key value [ -f "$file" ] || return 1 - for key in DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID; do + local enabled_module + enabled_module="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*/\1/p' "$file" | head -n1)" + enabled_module="${enabled_module#\"}"; enabled_module="${enabled_module%\"}" + enabled_module="${enabled_module#\'}"; enabled_module="${enabled_module%\'}" + local required_keys + if [ "$enabled_module" = "slack" ]; then + required_keys="SLACK_BOT_TOKEN SLACK_SIGNING_SECRET SLACK_TEAM_ID SLACK_APP_ID" + else + required_keys="DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID" + fi + for key in $required_keys; do value="$(sed -nE "s/^${key}=([^#[:space:]]+).*/\1/p" "$file" | head -n1)" [ -n "$value" ] || return 1 case "$value" in diff --git a/package-lock.json b/package-lock.json index 85a5867..5692dbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "maestro-relay", - "version": "0.1.3", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro-relay", - "version": "0.1.3", + "version": "0.2.0", "license": "MIT", "dependencies": { + "@slack/bolt": "^4.6.0", "better-sqlite3": "^12.8.0", "discord.js": "^14.0.0", "dotenv": "^16.0.0" @@ -21,7 +22,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/better-sqlite3": "^7.6.13", - "@types/node": "^20.0.0", + "@types/node": "^22.0.0", "eslint": "^10.2.0", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.2", @@ -789,6 +790,113 @@ "npm": ">=7.0.0" } }, + "node_modules/@slack/bolt": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/@slack/bolt/-/bolt-4.7.2.tgz", + "integrity": "sha512-ALHtaS2iaP2WAWgX08yXsoCxEDitC6AqZs26ot6smXJQzBFMM4slVP+w3blLwzUV551xZ/+9RlBmWHsZDJJ5HA==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/oauth": "^3.0.5", + "@slack/socket-mode": "^2.0.7", + "@slack/types": "^2.20.1", + "@slack/web-api": "^7.15.1", + "axios": "^1.12.0", + "express": "^5.0.0", + "path-to-regexp": "^8.1.0", + "raw-body": "^3", + "tsscmp": "^1.0.6" + }, + "engines": { + "node": ">=18", + "npm": ">=8.6.0" + }, + "peerDependencies": { + "@types/express": "^5.0.0" + } + }, + "node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/oauth": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@slack/oauth/-/oauth-3.0.5.tgz", + "integrity": "sha512-exqFQySKhNDptWYSWhvRUJ4/+ndu2gayIy7vg/JfmJq3wGtGdHk531P96fAZyBm5c1Le3yaPYqv92rL4COlU3A==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", + "@types/jsonwebtoken": "^9", + "@types/node": ">=18", + "jsonwebtoken": "^9" + }, + "engines": { + "node": ">=18", + "npm": ">=8.6.0" + } + }, + "node_modules/@slack/socket-mode": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-2.0.7.tgz", + "integrity": "sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", + "@types/node": ">=18", + "@types/ws": "^8", + "eventemitter3": "^5", + "ws": "^8" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.21.1.tgz", + "integrity": "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.15.2", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.15.2.tgz", + "integrity": "sha512-/m9qVFkiq85Oa/FSQwYIRDa/AO4qNYkDh4sRBK1WqEc2+RyG7w4tbU6rBIwUOcc/TmWOIr24Nraquxg7um5mYw==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.21.0", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.15.0", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -827,6 +935,27 @@ "@types/node": "*" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -841,6 +970,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT", + "peer": true + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -848,15 +1009,72 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { - "version": "20.19.35", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", - "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -1106,6 +1324,19 @@ "npm": ">=7.0.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1166,6 +1397,23 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1230,6 +1478,30 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -1267,6 +1539,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1274,12 +1552,102 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -1306,7 +1674,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1351,6 +1718,24 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1418,6 +1803,44 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1427,6 +1850,51 @@ "once": "^1.4.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", @@ -1465,6 +1933,12 @@ "@esbuild/win32-x64": "0.18.20" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1646,6 +2120,21 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1655,6 +2144,49 @@ "node": ">=6" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1712,6 +2244,27 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1750,18 +2303,93 @@ "dev": true, "license": "ISC" }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -1771,6 +2399,52 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -1803,6 +2477,93 @@ "node": ">=10.13.0" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1855,6 +2616,21 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1878,6 +2654,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1906,6 +2700,49 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -1952,6 +2789,48 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", @@ -1971,6 +2850,61 @@ "dev": true, "license": "ISC" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -2018,7 +2952,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/napi-build-utils": { @@ -2034,6 +2967,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-abi": { "version": "3.87.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", @@ -2046,6 +2988,30 @@ "node": ">=10" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2073,6 +3039,15 @@ "node": ">= 0.8.0" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -2105,6 +3080,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2125,6 +3156,16 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -2191,6 +3232,28 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -2211,6 +3274,45 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -2250,6 +3352,31 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2270,6 +3397,12 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2282,6 +3415,57 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2305,6 +3489,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2371,6 +3627,15 @@ "source-map": "^0.6.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2434,6 +3699,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2503,6 +3777,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tsx": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-3.14.0.tgz", @@ -2546,6 +3829,20 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2599,6 +3896,15 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2622,6 +3928,15 @@ "dev": true, "license": "MIT" }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index f6d2e7e..2c42a7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maestro-relay", - "version": "0.1.3", + "version": "0.2.0", "description": "Maestro Relay — connect chat platforms (Discord today, Slack/Teams next) to Maestro AI agents via maestro-cli.", "main": "dist/index.js", "bin": { @@ -22,6 +22,7 @@ "author": "", "license": "MIT", "dependencies": { + "@slack/bolt": "^4.6.0", "better-sqlite3": "^12.8.0", "discord.js": "^14.0.0", "dotenv": "^16.0.0" @@ -29,7 +30,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/better-sqlite3": "^7.6.13", - "@types/node": "^20.0.0", + "@types/node": "^22.0.0", "eslint": "^10.2.0", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.2", diff --git a/src/__tests__/slack-channelName.test.ts b/src/__tests__/slack-channelName.test.ts new file mode 100644 index 0000000..f3b3d7e --- /dev/null +++ b/src/__tests__/slack-channelName.test.ts @@ -0,0 +1,126 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildAgentChannelName, + buildFallbackChannelName, + findChannelByName, +} from '../providers/slack/adapter'; + +test('builds maestro-- from a clean name', () => { + const out = buildAgentChannelName({ id: 'abcd1234efgh', name: 'My Agent' }); + assert.equal(out, 'maestro-my-agent-abcd1234'); +}); + +test('lowercases, strips disallowed characters, and collapses dashes', () => { + const out = buildAgentChannelName({ id: 'abcd1234', name: 'Foo!! Bar??' }); + assert.equal(out, 'maestro-foo-bar-abcd1234'); +}); + +test('different agents with the same normalized name get different channel names', () => { + const a = buildAgentChannelName({ id: 'abcd1234', name: 'My Agent' }); + const b = buildAgentChannelName({ id: 'wxyz9876', name: 'My Agent' }); + assert.notEqual(a, b); + assert.equal(a, 'maestro-my-agent-abcd1234'); + assert.equal(b, 'maestro-my-agent-wxyz9876'); +}); + +test('falls back to "agent" when the sanitized name is empty', () => { + const out = buildAgentChannelName({ id: 'abcd1234', name: '!!!' }); + assert.equal(out, 'maestro-agent-abcd1234'); +}); + +test('caps overall length at 80 characters', () => { + const out = buildAgentChannelName({ + id: 'abcd1234', + name: 'a'.repeat(200), + }); + assert.ok(out.length <= 80, `expected <=80, got ${out.length}`); + assert.match(out, /^maestro-/); +}); + +test('strips leading and trailing dashes from the sanitized name', () => { + const out = buildAgentChannelName({ id: 'abcd1234', name: '!!!agent!!!' }); + assert.equal(out, 'maestro-agent-abcd1234'); +}); + +test('handles agent.id with non-alphanumeric characters in the suffix', () => { + const out = buildAgentChannelName({ id: 'a-b-c-d-1-2-3-4-5', name: 'My Agent' }); + assert.equal(out, 'maestro-my-agent-abcd1234'); +}); + +test('buildFallbackChannelName preserves the full - suffix even at max length', () => { + const base = 'a'.repeat(80); + const out = buildFallbackChannelName(base, 1234567890123); + assert.ok(out.length <= 80, `expected <=80, got ${out.length}`); + // Suffix is `-` + last 6 digits of the timestamp = 7 chars. + assert.match(out, /-890123$/); +}); + +test('buildFallbackChannelName trims base to make room for the suffix', () => { + const base = 'maestro-agent-abcdefgh'; + const out = buildFallbackChannelName(base, 1700000000123); + assert.equal(out, 'maestro-agent-abcdefgh-000123'); + assert.ok(out.length <= 80); +}); + +test('findChannelByName paginates until match found', async () => { + const pages: Array<{ + channels: Array<{ id: string; name: string; is_archived?: boolean }>; + response_metadata: { next_cursor?: string }; + }> = [ + { + channels: [{ id: 'C100', name: 'general' }], + response_metadata: { next_cursor: 'cursor-2' }, + }, + { + channels: [{ id: 'C200', name: 'random' }], + response_metadata: { next_cursor: 'cursor-3' }, + }, + { + channels: [{ id: 'C300', name: 'maestro-target-abcd1234', is_archived: true }], + response_metadata: { next_cursor: '' }, + }, + ]; + let calls = 0; + const list = async (args: { cursor?: string }) => { + calls++; + if (!args.cursor) return pages[0]; + if (args.cursor === 'cursor-2') return pages[1]; + if (args.cursor === 'cursor-3') return pages[2]; + throw new Error(`unexpected cursor: ${args.cursor}`); + }; + const result = await findChannelByName(list, 'maestro-target-abcd1234'); + assert.deepEqual(result, { id: 'C300', is_archived: true }); + assert.equal(calls, 3, 'should have walked all three pages'); +}); + +test('findChannelByName returns null when name is on no page', async () => { + const list = async (args: { cursor?: string }) => { + if (!args.cursor) { + return { + channels: [{ id: 'C1', name: 'a' }], + response_metadata: { next_cursor: 'next' }, + }; + } + return { + channels: [{ id: 'C2', name: 'b' }], + response_metadata: { next_cursor: '' }, + }; + }; + const result = await findChannelByName(list, 'maestro-missing'); + assert.equal(result, null); +}); + +test('findChannelByName stops on the first page when next_cursor is empty', async () => { + let calls = 0; + const list = async () => { + calls++; + return { + channels: [{ id: 'C1', name: 'maestro-x-abcd1234' }], + response_metadata: { next_cursor: '' }, + }; + }; + const result = await findChannelByName(list, 'maestro-x-abcd1234'); + assert.deepEqual(result, { id: 'C1', is_archived: false }); + assert.equal(calls, 1); +}); diff --git a/src/__tests__/slack-config.test.ts b/src/__tests__/slack-config.test.ts new file mode 100644 index 0000000..25f33af --- /dev/null +++ b/src/__tests__/slack-config.test.ts @@ -0,0 +1,73 @@ +import test, { beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { slackConfig } from '../providers/slack/config'; + +const SAVED: Record = {}; +const KEYS = ['SLACK_PORT', 'SLACK_ALLOWED_USER_IDS']; + +beforeEach(() => { + for (const k of KEYS) SAVED[k] = process.env[k]; +}); + +afterEach(() => { + for (const k of KEYS) { + if (SAVED[k] === undefined) delete process.env[k]; + else process.env[k] = SAVED[k]; + } +}); + +test('port defaults to 3000 when SLACK_PORT is unset', () => { + delete process.env.SLACK_PORT; + assert.equal(slackConfig.port, 3000); +}); + +test('port parses a valid integer', () => { + process.env.SLACK_PORT = '4000'; + assert.equal(slackConfig.port, 4000); +}); + +test('port falls back to 3000 for non-numeric value', () => { + process.env.SLACK_PORT = 'not-a-number'; + assert.equal(slackConfig.port, 3000); +}); + +test('port falls back to 3000 for empty string', () => { + process.env.SLACK_PORT = ''; + assert.equal(slackConfig.port, 3000); +}); + +test('port rejects values below 1', () => { + process.env.SLACK_PORT = '0'; + assert.equal(slackConfig.port, 3000); + process.env.SLACK_PORT = '-1'; + assert.equal(slackConfig.port, 3000); +}); + +test('port rejects values above 65535', () => { + process.env.SLACK_PORT = '65536'; + assert.equal(slackConfig.port, 3000); + process.env.SLACK_PORT = '70000'; + assert.equal(slackConfig.port, 3000); +}); + +test('port accepts boundary values 1 and 65535', () => { + process.env.SLACK_PORT = '1'; + assert.equal(slackConfig.port, 1); + process.env.SLACK_PORT = '65535'; + assert.equal(slackConfig.port, 65535); +}); + +test('allowedUserIds returns empty array when unset', () => { + delete process.env.SLACK_ALLOWED_USER_IDS; + assert.deepEqual(slackConfig.allowedUserIds, []); +}); + +test('allowedUserIds parses comma-separated values', () => { + process.env.SLACK_ALLOWED_USER_IDS = 'U001,U002, U003 '; + assert.deepEqual(slackConfig.allowedUserIds, ['U001', 'U002', 'U003']); +}); + +test('allowedUserIds filters empty entries', () => { + process.env.SLACK_ALLOWED_USER_IDS = 'U001,,U002'; + assert.deepEqual(slackConfig.allowedUserIds, ['U001', 'U002']); +}); diff --git a/src/__tests__/slack-conversationsDb.test.ts b/src/__tests__/slack-conversationsDb.test.ts new file mode 100644 index 0000000..019a446 --- /dev/null +++ b/src/__tests__/slack-conversationsDb.test.ts @@ -0,0 +1,95 @@ +import test, { afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import Database from 'better-sqlite3'; +import { createConversationDb } from '../providers/slack/conversationsDb'; + +function makeDb() { + const db = new Database(':memory:'); + db.exec(` + CREATE TABLE slack_agent_conversations ( + thread_ts TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + owner_user_id TEXT, + session_id TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `); + return db; +} + +let db: InstanceType; +let conversationDb: ReturnType; + +afterEach(() => { + db?.close(); +}); + +function setup() { + db = makeDb(); + conversationDb = createConversationDb(db); +} + +test('register and get round-trip', () => { + setup(); + conversationDb.register('1234567890.123456', 'C001', 'agent-1', 'U001'); + const row = conversationDb.get('1234567890.123456'); + assert.ok(row); + assert.equal(row.channel_id, 'C001'); + assert.equal(row.agent_id, 'agent-1'); + assert.equal(row.owner_user_id, 'U001'); + assert.equal(row.session_id, null); +}); + +test('register is idempotent — duplicate thread_ts does not throw (INSERT OR IGNORE)', () => { + setup(); + conversationDb.register('1111111111.000001', 'C001', 'agent-1', 'U001'); + assert.doesNotThrow(() => { + conversationDb.register('1111111111.000001', 'C002', 'agent-2', 'U002'); + }); + // First registration wins + const row = conversationDb.get('1111111111.000001'); + assert.equal(row?.channel_id, 'C001'); +}); + +test('updateSession persists sessionId', () => { + setup(); + conversationDb.register('2222222222.000001', 'C001', 'agent-1', 'U001'); + conversationDb.updateSession('2222222222.000001', 'ses_abc123'); + const row = conversationDb.get('2222222222.000001'); + assert.equal(row?.session_id, 'ses_abc123'); +}); + +test('updateSession can clear sessionId to null', () => { + setup(); + conversationDb.register('3333333333.000001', 'C001', 'agent-1', 'U001'); + conversationDb.updateSession('3333333333.000001', 'ses_xyz'); + conversationDb.updateSession('3333333333.000001', null); + const row = conversationDb.get('3333333333.000001'); + assert.equal(row?.session_id, null); +}); + +test('remove deletes the row', () => { + setup(); + conversationDb.register('4444444444.000001', 'C001', 'agent-1', 'U001'); + conversationDb.remove('4444444444.000001'); + assert.equal(conversationDb.get('4444444444.000001'), undefined); +}); + +test('listByChannel returns all conversations for a channel', () => { + setup(); + conversationDb.register('5555555555.000001', 'C-CHAN', 'agent-1', 'U001'); + conversationDb.register('5555555555.000002', 'C-CHAN', 'agent-1', 'U002'); + conversationDb.register('5555555555.000003', 'C-OTHER', 'agent-2', 'U003'); + const rows = conversationDb.listByChannel('C-CHAN'); + assert.equal(rows.length, 2); +}); + +test('register with null owner does not throw', () => { + setup(); + assert.doesNotThrow(() => { + conversationDb.register('6666666666.000001', 'C001', 'agent-1', null); + }); + const row = conversationDb.get('6666666666.000001'); + assert.equal(row?.owner_user_id, null); +}); diff --git a/src/__tests__/slack-findOrCreateSlackChannel.test.ts b/src/__tests__/slack-findOrCreateSlackChannel.test.ts new file mode 100644 index 0000000..06608f4 --- /dev/null +++ b/src/__tests__/slack-findOrCreateSlackChannel.test.ts @@ -0,0 +1,181 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { WebClient } from '@slack/web-api'; +import { findOrCreateSlackChannel } from '../providers/slack/adapter'; + +type ListArgs = { cursor?: string; limit?: number; types?: string; exclude_archived?: boolean }; +type ListResult = { + channels: Array<{ id: string; name: string; is_archived?: boolean }>; + response_metadata: { next_cursor: string }; +}; +type CreateResult = { channel: { id: string } | undefined }; + +interface FakeClient { + conversations: { + list: (args: ListArgs) => Promise; + unarchive: (args: { channel: string }) => Promise; + create: (args: { name: string; is_private: boolean }) => Promise; + }; +} + +function makeClient(opts: { + list: (args: ListArgs) => Promise; + unarchive?: (args: { channel: string }) => Promise; + create?: (args: { name: string; is_private: boolean }) => Promise; +}): { client: WebClient; calls: { list: number; unarchive: number; create: number; createNames: string[] } } { + const calls = { list: 0, unarchive: 0, create: 0, createNames: [] as string[] }; + const fake: FakeClient = { + conversations: { + list: async (args) => { + calls.list++; + return opts.list(args); + }, + unarchive: async (args) => { + calls.unarchive++; + if (!opts.unarchive) throw new Error('unarchive not stubbed'); + return opts.unarchive(args); + }, + create: async (args) => { + calls.create++; + calls.createNames.push(args.name); + if (!opts.create) throw new Error('create not stubbed'); + return opts.create(args); + }, + }, + }; + return { client: fake as unknown as WebClient, calls }; +} + +const AGENT = { id: 'abcd1234efgh', name: 'My Agent' }; +const EXPECTED_NAME = 'maestro-my-agent-abcd1234'; + +test('returns existing channel when found and not archived', async () => { + const { client, calls } = makeClient({ + list: async () => ({ + channels: [{ id: 'C-EXISTING', name: EXPECTED_NAME, is_archived: false }], + response_metadata: { next_cursor: '' }, + }), + }); + + const result = await findOrCreateSlackChannel(client, AGENT); + + assert.deepEqual(result, { channelId: 'C-EXISTING', isNew: false }); + assert.equal(calls.create, 0, 'must not create when an open channel exists'); + assert.equal(calls.unarchive, 0, 'must not unarchive an already-open channel'); +}); + +test('unarchives and returns existing channel when found archived', async () => { + const { client, calls } = makeClient({ + list: async () => ({ + channels: [{ id: 'C-ARCHIVED', name: EXPECTED_NAME, is_archived: true }], + response_metadata: { next_cursor: '' }, + }), + unarchive: async () => undefined, + }); + + const result = await findOrCreateSlackChannel(client, AGENT); + + assert.deepEqual(result, { channelId: 'C-ARCHIVED', isNew: false }); + assert.equal(calls.unarchive, 1, 'must unarchive the archived channel'); + assert.equal(calls.create, 0, 'must not create when unarchive succeeds'); +}); + +test('falls back to timestamped create when unarchive fails', async () => { + const { client, calls } = makeClient({ + list: async () => ({ + channels: [{ id: 'C-LOCKED', name: EXPECTED_NAME, is_archived: true }], + response_metadata: { next_cursor: '' }, + }), + unarchive: async () => { + throw new Error('channel_locked'); + }, + create: async () => ({ channel: { id: 'C-FRESH' } }), + }); + + const result = await findOrCreateSlackChannel(client, AGENT); + + assert.equal(result.channelId, 'C-FRESH'); + assert.equal(result.isNew, true); + assert.equal(calls.create, 1, 'must create a fallback channel when unarchive fails'); + assert.equal(calls.createNames.length, 1); + // The fallback name keeps the original base and appends -<6 digits>. + assert.match(calls.createNames[0], new RegExp(`^${EXPECTED_NAME}-\\d{6}$`)); +}); + +test('creates with primary name when channel does not exist', async () => { + const { client, calls } = makeClient({ + list: async () => ({ + channels: [{ id: 'C-OTHER', name: 'unrelated' }], + response_metadata: { next_cursor: '' }, + }), + create: async () => ({ channel: { id: 'C-NEW' } }), + }); + + const result = await findOrCreateSlackChannel(client, AGENT); + + assert.deepEqual(result, { channelId: 'C-NEW', isNew: true }); + assert.equal(calls.create, 1); + assert.deepEqual(calls.createNames, [EXPECTED_NAME], 'must create with the primary (un-suffixed) name'); +}); + +test('proceeds to create when conversations.list throws', async () => { + // Network/auth error during list shouldn't block channel provisioning; + // the adapter swallows the list error and falls through to create. + const { client, calls } = makeClient({ + list: async () => { + throw new Error('rate_limited'); + }, + create: async () => ({ channel: { id: 'C-NEW' } }), + }); + + const result = await findOrCreateSlackChannel(client, AGENT); + + assert.deepEqual(result, { channelId: 'C-NEW', isNew: true }); + assert.equal(calls.create, 1); +}); + +test('throws when conversations.create returns no channel id', async () => { + const { client } = makeClient({ + list: async () => ({ + channels: [], + response_metadata: { next_cursor: '' }, + }), + create: async () => ({ channel: undefined }), + }); + + await assert.rejects( + findOrCreateSlackChannel(client, AGENT), + /Failed to create Slack channel for agent/, + ); +}); + +test('walks pagination during channel lookup', async () => { + const pages: ListResult[] = [ + { + channels: [{ id: 'C1', name: 'general' }], + response_metadata: { next_cursor: 'cursor-1' }, + }, + { + channels: [{ id: 'C2', name: 'random' }], + response_metadata: { next_cursor: 'cursor-2' }, + }, + { + channels: [{ id: 'C-MATCH', name: EXPECTED_NAME, is_archived: false }], + response_metadata: { next_cursor: '' }, + }, + ]; + const { client, calls } = makeClient({ + list: async (args) => { + if (!args.cursor) return pages[0]; + if (args.cursor === 'cursor-1') return pages[1]; + if (args.cursor === 'cursor-2') return pages[2]; + throw new Error(`unexpected cursor: ${args.cursor}`); + }, + }); + + const result = await findOrCreateSlackChannel(client, AGENT); + + assert.deepEqual(result, { channelId: 'C-MATCH', isNew: false }); + assert.equal(calls.list, 3, 'must walk all three pages'); + assert.equal(calls.create, 0); +}); diff --git a/src/__tests__/slack-react.test.ts b/src/__tests__/slack-react.test.ts new file mode 100644 index 0000000..271dc53 --- /dev/null +++ b/src/__tests__/slack-react.test.ts @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { toSlackEmojiName, isThreadTs } from '../providers/slack/adapter'; + +test('⏳ maps to hourglass_flowing_sand', () => { + assert.equal(toSlackEmojiName('⏳'), 'hourglass_flowing_sand'); +}); + +test('🎧 maps to headphones', () => { + assert.equal(toSlackEmojiName('🎧'), 'headphones'); +}); + +test('✅ maps to white_check_mark', () => { + assert.equal(toSlackEmojiName('✅'), 'white_check_mark'); +}); + +test('❌ maps to x', () => { + assert.equal(toSlackEmojiName('❌'), 'x'); +}); + +test('unknown emoji passes through unchanged', () => { + assert.equal(toSlackEmojiName('🚀'), '🚀'); +}); + +test('isThreadTs matches valid Slack timestamps', () => { + assert.ok(isThreadTs('1234567890.123456')); + assert.ok(isThreadTs('1777189034.828869')); + assert.equal(isThreadTs('C001'), false); + assert.equal(isThreadTs('not-a-ts'), false); + assert.equal(isThreadTs(''), false); +}); diff --git a/src/core/db/migrations.ts b/src/core/db/migrations.ts index ae9d26a..7986d5e 100644 --- a/src/core/db/migrations.ts +++ b/src/core/db/migrations.ts @@ -8,6 +8,7 @@ import type Database from 'better-sqlite3'; * 2. Add `owner_user_id` to agent_threads (legacy) * 3. Add `provider` column + composite PK (provider, channel_id) to agent_channels * 4. Rename `agent_threads` → `discord_agent_threads` + * 5. Add `slack_agent_conversations` thread/timestamp registry */ export function runMigrations(db: Database.Database): void { ensureReadOnlyColumn(db); @@ -15,6 +16,7 @@ export function runMigrations(db: Database.Database): void { renameAgentThreadsTable(db); ensureDiscordThreadsTable(db); ensureOwnerUserIdColumn(db); + ensureSlackConversationsTable(db); } export function ensureOwnerUserIdColumn(database: Database.Database): void { @@ -119,3 +121,16 @@ function ensureDiscordThreadsTable(database: Database.Database): void { ) `); } + +function ensureSlackConversationsTable(database: Database.Database): void { + database.exec(` + CREATE TABLE IF NOT EXISTS slack_agent_conversations ( + thread_ts TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + owner_user_id TEXT, + session_id TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `); +} diff --git a/src/core/providers.ts b/src/core/providers.ts index 7809119..a724533 100644 --- a/src/core/providers.ts +++ b/src/core/providers.ts @@ -22,6 +22,10 @@ async function loadProvider(name: string): Promise { const { DiscordProvider } = await import('../providers/discord/adapter'); return new DiscordProvider(); } + case 'slack': { + const { SlackProvider } = await import('../providers/slack/adapter'); + return new SlackProvider(); + } default: console.warn(`[providers] Unknown provider "${name}" — ignoring.`); return null; diff --git a/src/core/queue.ts b/src/core/queue.ts index b036ef8..db5f2ef 100644 --- a/src/core/queue.ts +++ b/src/core/queue.ts @@ -112,8 +112,11 @@ export function createQueue(deps: QueueDeps) { if (provider.react) { try { reaction = await provider.react(messageTarget, '⏳'); - } catch { - // best-effort indicator; ignore failures + } catch (err) { + void deps.logger.error( + 'queue:react', + `provider=${message.provider} channel=${message.channelId} error=${err instanceof Error ? err.message : String(err)}`, + ); } } @@ -179,21 +182,29 @@ export function createQueue(deps: QueueDeps) { // ignore cleanup failure } - if (!result.success || !result.response) { - const reason = result.error ?? 'The agent could not complete this request.'; + if (result.response) { + if (!result.success) { + void deps.logger.error( + 'queue:agent-soft-failure', + `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} error=${result.error}`, + ); + } + const parts = split(result.response); + for (const part of parts) { + await provider.send(target, { text: part }); + } + } else { const hint = conv.readOnly ? '\n-# The agent is in **read-only** mode and cannot modify files.' : ''; + const rawError = result.error ?? '(no error detail)'; void deps.logger.error( 'queue:agent-failure', - `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} reason=${reason}`, + `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} error=${rawError}`, ); - await provider.send(target, { text: `⚠️ ${reason}${hint}` }); - } else { - const parts = split(result.response); - for (const part of parts) { - await provider.send(target, { text: part }); - } + await provider.send(target, { + text: `⚠️ The agent could not complete this request.${hint}`, + }); } const cost = (result.usage?.totalCostUsd ?? 0).toFixed(4); @@ -216,7 +227,7 @@ export function createQueue(deps: QueueDeps) { `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} error=${errMsg}`, ); await provider.send(target, { - text: `❌ Failed to get response from agent:\n\`\`\`\n${errMsg}\n\`\`\``, + text: '❌ Failed to get response from agent. Check relay logs for details.', }); } diff --git a/src/index.ts b/src/index.ts index 3ecf6ed..123aa63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import './core/db'; // ensure DB is initialized + migrated on startup +import { db } from './core/db'; // initializes + migrates DB on startup import { config } from './core/config'; import { logger } from './core/logger'; import { maestro } from './core/maestro'; @@ -49,6 +49,12 @@ async function main() { console.error(`[bridge] error stopping provider "${name}":`, err); } } + try { + db.exec('PRAGMA wal_checkpoint(RESTART);'); + db.close(); + } catch (err) { + console.error('[bridge] db shutdown error:', err); + } process.exit(0); }; diff --git a/src/providers/slack/adapter.ts b/src/providers/slack/adapter.ts new file mode 100644 index 0000000..7230004 --- /dev/null +++ b/src/providers/slack/adapter.ts @@ -0,0 +1,413 @@ +import { App, ExpressReceiver, SocketModeReceiver } from '@slack/bolt'; +import { WebClient } from '@slack/web-api'; +import type { + AgentChannelInfo, + BridgeProvider, + ChannelTarget, + ConversationRecord, + IncomingMessage, + KernelContext, + MessageTarget, + OutgoingMessage, + ReactionHandle, +} from '../../core/types'; +import { maestro } from '../../core/maestro'; +import { logger } from '../../core/logger'; +import { slackConfig } from './config'; +import { channelDb } from './channelsDb'; +import { conversationDb } from './conversationsDb'; +import { createMessageHandler } from './messageCreate'; +import * as health from './commands/health'; +import * as agents from './commands/agents'; +import * as session from './commands/session'; + +const UNICODE_TO_SLACK: Record = { + '⏳': 'hourglass_flowing_sand', + '🎧': 'headphones', + '✅': 'white_check_mark', + '❌': 'x', +}; + +export function toSlackEmojiName(emoji: string): string { + return UNICODE_TO_SLACK[emoji] ?? emoji; +} + +/** Matches a Slack message timestamp: digits.digits */ +export function isThreadTs(id: string): boolean { + return /^\d+\.\d+$/.test(id); +} + +/** + * Build a Slack channel name for an agent. + * + * Format: `maestro--` capped at 80 chars. + * The id-prefix (8 alphanumeric chars from agent.id) is what makes the + * name unique — without it, two agents whose names normalize to the + * same string would collapse to the same channel. + */ +export function buildAgentChannelName(agent: { id: string; name: string }): string { + const sanitizedName = agent.name + .toLowerCase() + .replace(/[^a-z0-9-_]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .substring(0, 60); + const baseName = sanitizedName || 'agent'; + const idPrefix = agent.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 8); + const suffix = idPrefix ? `-${idPrefix}` : ''; + return `maestro-${baseName}${suffix}`.slice(0, 80); +} + +/** + * Build a fallback channel name when unarchive fails. + * + * Reserves space for the timestamp suffix BEFORE concatenation so a + * full-length 80-char base doesn't have its suffix sliced away — that + * would re-collide with the original name and trigger another + * `name_taken`. + */ +export function buildFallbackChannelName(base: string, now: number = Date.now()): string { + const suffix = `-${now.toString().slice(-6)}`; + const maxBase = 80 - suffix.length; + return `${base.slice(0, maxBase)}${suffix}`; +} + +/** Slack `conversations.list` page shape we actually consume. */ +type ConversationsListPage = { + channels?: Array<{ id?: string; name?: string; is_archived?: boolean }>; + response_metadata?: { next_cursor?: string }; +}; +type ConversationsLister = (args: { cursor?: string }) => Promise; + +/** + * Walk every page of `conversations.list` looking for a channel with + * the given name. Workspaces with >1000 public channels would + * otherwise miss matches on later pages and surface as `name_taken` + * on create. + */ +export async function findChannelByName( + list: ConversationsLister, + name: string, +): Promise<{ id: string; is_archived: boolean } | null> { + let cursor: string | undefined; + do { + const res = await list({ cursor }); + const match = res.channels?.find((ch) => ch.name === name); + if (match?.id) { + return { id: match.id, is_archived: !!match.is_archived }; + } + cursor = res.response_metadata?.next_cursor || undefined; + } while (cursor); + return null; +} + +/** + * Look up an existing Slack channel for an agent or create a fresh one. + * Returns `{ channelId, isNew }`. If the channel exists but is archived, + * tries to unarchive; if that fails, creates a new channel with a + * `-` suffix to avoid `name_taken`. + */ +export async function findOrCreateSlackChannel( + client: WebClient, + agent: { id: string; name: string }, +): Promise<{ channelId: string; isNew: boolean }> { + const channelName = buildAgentChannelName(agent); + + let existing: { id: string; is_archived: boolean } | null = null; + try { + existing = await findChannelByName( + (args) => + client.conversations.list({ + exclude_archived: false, + types: 'public_channel', + limit: 1000, + cursor: args.cursor, + }) as Promise, + channelName, + ); + } catch { + // ignore — will fall through to create + } + + if (existing && existing.is_archived) { + try { + await client.conversations.unarchive({ channel: existing.id }); + return { channelId: existing.id, isNew: false }; + } catch { + // Unarchive failed (e.g. permissions, channel locked). Fall back + // to a fresh timestamped channel — same trick the slash command + // uses, mirrored here so HTTP-API-driven flows behave the same. + const fallbackName = buildFallbackChannelName(channelName); + const res = await client.conversations.create({ + name: fallbackName, + is_private: false, + }); + if (!res.channel?.id) { + throw new Error(`Failed to create Slack channel for agent ${agent.id}`); + } + return { channelId: res.channel.id, isNew: true }; + } + } + + if (existing) { + return { channelId: existing.id, isNew: false }; + } + + const res = await client.conversations.create({ name: channelName, is_private: false }); + if (!res.channel?.id) { + throw new Error(`Failed to create Slack channel for agent ${agent.id}`); + } + return { channelId: res.channel.id, isNew: true }; +} + +export class SlackProvider implements BridgeProvider { + readonly name = 'slack'; + private app: App | null = null; + private client: WebClient | null = null; + private started = false; + private pendingChannels = new Map>(); + + async start(ctx: KernelContext): Promise { + const socketModeToken = slackConfig.socketModeToken; + let receiver: SocketModeReceiver | ExpressReceiver; + + if (socketModeToken) { + receiver = new SocketModeReceiver({ appToken: socketModeToken }); + } else { + receiver = new ExpressReceiver({ + signingSecret: slackConfig.signingSecret, + }); + } + + const app = new App({ + token: slackConfig.token, + receiver, + }); + this.app = app; + this.client = new WebClient(slackConfig.token); + + const handleMessage = createMessageHandler(ctx); + + // message events (thread replies only) + app.event('message', async ({ event }) => { + await handleMessage({ event: event as unknown as Record }); + }); + + // app_mention creates a new conversation thread + app.event('app_mention', async ({ event, say }) => { + const eventData = event as unknown as Record; + const text = String(eventData['text'] ?? ''); + const rawUser = eventData['user']; + const channel = String(eventData['channel'] ?? ''); + + if (!rawUser || typeof rawUser !== 'string') { + await say('Could not identify the user. Please try again.'); + return; + } + const user = rawUser; + + const allowed = slackConfig.allowedUserIds; + if (allowed.length > 0 && !allowed.includes(user)) { + return; + } + + const channelInfo = channelDb.get(channel); + if (!channelInfo) { + await say('This channel is not registered with an agent. Use `/agents new ` to register one.'); + return; + } + + // Strip all Slack user mentions before forwarding to the agent. + // The bot's own mention is the trigger that brought us here, and + // other users' mentions would just surface as opaque <@U123> tokens + // to the agent — Slack still notifies those users via the original + // message, so dropping them from the agent-bound text is safe. + const cleanText = text.replace(/<@[^>]+>/g, '').trim(); + if (!cleanText) { + await say('I received your mention, but no message. Please include a message.'); + return; + } + + try { + const result = await this.client!.chat.postMessage({ + channel, + text: cleanText, + }); + + if (!result.ts) { + await say('Failed to create conversation thread.'); + return; + } + + const threadTs = result.ts; + conversationDb.register(threadTs, channel, channelInfo.agent_id, user); + + // Enqueue the initial message from the mention + const message: IncomingMessage = { + provider: 'slack', + messageId: threadTs, + channelId: threadTs, + authorId: user, + authorName: user, + content: cleanText, + attachments: [], + isThread: true, + raw: eventData, + }; + ctx.enqueue(message); + } catch (err) { + void logger.error('slack/app_mention', String(err)); + await say('Failed to create conversation thread.'); + } + }); + + // slash commands + app.command('/health', async (args) => { await health.handle(args); }); + app.command('/agents', async (args) => { await agents.handle(args); }); + app.command('/session', async (args) => { await session.handle(args); }); + + if (socketModeToken) { + await app.start(); + } else { + await app.start(slackConfig.port); + } + this.started = true; + } + + async stop(): Promise { + if (this.app) { + await this.app.stop(); + this.app = null; + this.client = null; + this.started = false; + } + } + + isReady(): boolean { + return this.started; + } + + resolveConversation(message: IncomingMessage): ConversationRecord | null { + if (message.isThread) { + // channelId is the thread_ts for Slack thread messages + const convo = conversationDb.get(message.channelId); + if (!convo) return null; + const channelInfo = channelDb.get(convo.channel_id); + return { + agentId: convo.agent_id, + sessionId: convo.session_id ?? null, + readOnly: !!(channelInfo?.read_only), + persistSession: (sessionId: string) => + conversationDb.updateSession(message.channelId, sessionId), + }; + } + + const channelInfo = channelDb.get(message.channelId); + if (!channelInfo) return null; + return { + agentId: channelInfo.agent_id, + sessionId: channelInfo.session_id ?? null, + readOnly: !!channelInfo.read_only, + persistSession: (sessionId: string) => + channelDb.updateSession(message.channelId, sessionId), + }; + } + + async send(target: ChannelTarget, msg: OutgoingMessage): Promise { + if (!this.client) throw new Error('Slack client not initialised'); + + let text = msg.text; + if (msg.mention && slackConfig.mentionUserId) { + text = `<@${slackConfig.mentionUserId}> ${text}`; + } + + if (isThreadTs(target.channelId)) { + // target is a thread_ts — look up parent channel + const convo = conversationDb.get(target.channelId); + if (!convo) { + // The thread is orphaned — its row was likely removed when the + // bound channel was disconnected, or the DB was reset. Log the + // mismatch specifically so operators can distinguish it from + // generic Slack/network errors before surfacing to the kernel. + void logger.error('slack/send:orphan-thread', `thread_ts=${target.channelId}`); + throw new Error(`No conversation found for thread_ts ${target.channelId}`); + } + await this.client.chat.postMessage({ + channel: convo.channel_id, + thread_ts: target.channelId, + text, + }); + } else { + await this.client.chat.postMessage({ channel: target.channelId, text }); + } + } + + async react(target: MessageTarget, emoji: string): Promise { + if (!this.client) throw new Error('Slack client not initialised'); + + // Resolve channel: target.channelId may be a thread_ts or a channel ID + let channel: string; + let timestamp: string; + + if (isThreadTs(target.channelId)) { + const convo = conversationDb.get(target.channelId); + if (!convo) { + void logger.error('slack/react:orphan-thread', `thread_ts=${target.channelId}`); + throw new Error(`No conversation found for thread_ts ${target.channelId}`); + } + channel = convo.channel_id; + timestamp = target.messageId; + } else { + channel = target.channelId; + timestamp = target.messageId; + } + + const name = toSlackEmojiName(emoji); + await this.client.reactions.add({ channel, timestamp, name }); + + return { + remove: async () => { + if (!this.client) return; + await this.client.reactions.remove({ channel, timestamp, name }); + }, + }; + } + + // Slack does not expose a per-user typing indicator via the Web API + async sendTyping(_target: ChannelTarget): Promise { + // no-op + } + + async findOrCreateAgentChannel(agentId: string): Promise { + const existing = channelDb.getByAgentId(agentId); + if (existing) { + return { + channelId: existing.channel_id, + agentId: existing.agent_id, + agentName: existing.agent_name, + }; + } + + const pending = this.pendingChannels.get(agentId); + if (pending) return pending; + + const promise = (async () => { + if (!this.client) throw new Error('Slack client not initialised'); + + const allAgents = await maestro.listAgents(); + const agent = allAgents.find((a) => a.id === agentId); + if (!agent) throw new Error(`Agent not found: ${agentId}`); + + const { channelId } = await findOrCreateSlackChannel(this.client, agent); + channelDb.register(channelId, agent.id, agent.name); + return { channelId, agentId: agent.id, agentName: agent.name }; + })(); + + this.pendingChannels.set(agentId, promise); + try { + return await promise; + } finally { + this.pendingChannels.delete(agentId); + } + } +} diff --git a/src/providers/slack/channelsDb.ts b/src/providers/slack/channelsDb.ts new file mode 100644 index 0000000..86624e5 --- /dev/null +++ b/src/providers/slack/channelsDb.ts @@ -0,0 +1,31 @@ +import { channelDb as core, type AgentChannel } from '../../core/db'; + +/** + * Slack-side wrapper around the provider-aware core channel registry. + * Pre-binds `provider='slack'` so adapter code reads naturally. + */ +export const channelDb = { + register(channelId: string, agentId: string, agentName: string): void { + core.register('slack', channelId, agentId, agentName, null); + }, + get(channelId: string): AgentChannel | undefined { + return core.get('slack', channelId); + }, + getByAgentId(agentId: string): AgentChannel | undefined { + return core.getByAgentId('slack', agentId); + }, + updateSession(channelId: string, sessionId: string | null): void { + core.updateSession('slack', channelId, sessionId); + }, + setReadOnly(channelId: string, readOnly: boolean): void { + core.setReadOnly('slack', channelId, readOnly); + }, + remove(channelId: string): void { + core.remove('slack', channelId); + }, + listByAgentId(agentId: string): AgentChannel[] { + return core.listByAgentId('slack', agentId); + }, +}; + +export type { AgentChannel } from '../../core/db'; diff --git a/src/providers/slack/commands/agents.ts b/src/providers/slack/commands/agents.ts new file mode 100644 index 0000000..df400aa --- /dev/null +++ b/src/providers/slack/commands/agents.ts @@ -0,0 +1,193 @@ +import type { SlackCommandMiddlewareArgs, SayFn } from '@slack/bolt'; +import type { KnownBlock } from '@slack/types'; +import { WebClient } from '@slack/web-api'; +import { slackConfig } from '../config'; +import { channelDb } from '../channelsDb'; +import { conversationDb } from '../conversationsDb'; +import { maestro } from '../../../core/maestro'; +import { logger } from '../../../core/logger'; +import { findOrCreateSlackChannel } from '../adapter'; + +export async function handle({ + ack, + say, + command, +}: SlackCommandMiddlewareArgs): Promise { + await ack(); + + const allowed = slackConfig.allowedUserIds; + if (allowed.length > 0 && !allowed.includes(command.user_id)) { + await say('You are not authorized to use this command.'); + return; + } + + try { + const [subcommand, ...args] = (command.text || '').trim().split(/\s+/); + + switch (subcommand?.toLowerCase()) { + case 'new': + await handleNew(say, command.channel_id, args[0], command.user_id); + break; + case 'disconnect': + await handleDisconnect(say, command.channel_id, args[0]); + break; + case 'readonly': + await handleReadonly(say, command.channel_id, args[0]); + break; + case 'list': + case '': + case undefined: + await handleList(say); + break; + default: + await say( + `Unknown subcommand: \`${subcommand}\`. Try: \`list\`, \`new\`, \`disconnect\`, \`readonly\``, + ); + } + } catch (err) { + void logger.error('slack/agents', err instanceof Error ? err.message : String(err)); + await say('Failed to execute agents command.'); + } +} + +async function handleList(say: SayFn): Promise { + const agents = await maestro.listAgents(); + + if (agents.length === 0) { + await say('No agents available.'); + return; + } + + const blocks: KnownBlock[] = [ + { + type: 'section', + text: { type: 'mrkdwn', text: '*Available Maestro Agents:*' }, + }, + ]; + + for (const agent of agents) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: `• *${agent.name}* (\`${agent.id}\`)` }, + }); + } + + blocks.push({ type: 'divider' }); + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: '*Register an agent:* `/agents new `\n*Unregister (run inside the agent channel):* `/agents disconnect`\n*Toggle read-only (run inside the agent channel):* `/agents readonly `', + }, + }); + + await say({ blocks }); +} + +async function handleNew( + say: SayFn, + channelId: string, + agentId: string | undefined, + userId?: string, +): Promise { + if (!agentId) { + await say('Usage: `/agents new `'); + return; + } + + // Lookup mirrors Discord's /agents new: exact id, id-prefix, or exact name. + // Keeping the two providers identical here means agent IDs/names that work + // in one chat platform work in the other. + const agents = await maestro.listAgents(); + const agent = agents.find( + (a) => a.id === agentId || a.id.startsWith(agentId) || a.name === agentId, + ); + if (!agent) { + await say(`Agent \`${agentId}\` not found. Use \`/agents list\` to see available agents.`); + return; + } + + const client = new WebClient(slackConfig.token); + + let newChannelId: string; + try { + const result = await findOrCreateSlackChannel(client, agent); + newChannelId = result.channelId; + } catch (err) { + void logger.error( + 'slack/agents:findOrCreate', + err instanceof Error ? err.message : String(err), + ); + await say('Failed to create channel for agent.'); + return; + } + + if (userId) { + try { + await client.conversations.invite({ channel: newChannelId, users: userId }); + } catch { + // non-fatal + } + } + + channelDb.register(newChannelId, agent.id, agent.name); + + await client.chat.postMessage({ + channel: newChannelId, + text: `*${agent.name}* agent is ready.\n\nMention me (@app) in this channel to start a conversation thread.`, + }); + + await say(`Created channel <#${newChannelId}> for *${agent.name}* (\`${agent.id}\`)`); +} + +async function handleDisconnect( + say: SayFn, + channelId: string, + agentId: string | undefined, +): Promise { + const existing = channelDb.get(channelId); + + if (!existing) { + await say('No agent is registered in this channel.'); + return; + } + + if (agentId && existing.agent_id !== agentId) { + await say(`Agent \`${agentId}\` is not registered in this channel.`); + return; + } + + const client = new WebClient(slackConfig.token); + await say(`Agent *${existing.agent_name}* has been disconnected. This channel is now archived.`); + + conversationDb.removeByChannel(channelId); + channelDb.remove(channelId); + + try { + await client.conversations.archive({ channel: channelId }); + } catch { + // non-fatal if archive fails + } +} + +async function handleReadonly( + say: SayFn, + channelId: string, + mode: string | undefined, +): Promise { + const existing = channelDb.get(channelId); + if (!existing) { + await say('No agent is registered in this channel.'); + return; + } + + const normalized = mode?.toLowerCase(); + if (normalized !== 'on' && normalized !== 'off') { + await say('Usage: `/agents readonly `'); + return; + } + const readOnly = normalized === 'on'; + channelDb.setReadOnly(channelId, readOnly); + const status = readOnly ? 'read-only' : 'read-write'; + await say(`Agent *${existing.agent_name}* is now in ${status} mode for this channel.`); +} diff --git a/src/providers/slack/commands/health.ts b/src/providers/slack/commands/health.ts new file mode 100644 index 0000000..0563b61 --- /dev/null +++ b/src/providers/slack/commands/health.ts @@ -0,0 +1,6 @@ +import type { SlackCommandMiddlewareArgs } from '@slack/bolt'; + +export async function handle({ ack, say }: SlackCommandMiddlewareArgs): Promise { + await ack(); + await say('Maestro relay is healthy and running.'); +} diff --git a/src/providers/slack/commands/session.ts b/src/providers/slack/commands/session.ts new file mode 100644 index 0000000..bfc4e0c --- /dev/null +++ b/src/providers/slack/commands/session.ts @@ -0,0 +1,71 @@ +import type { SlackCommandMiddlewareArgs, SayFn } from '@slack/bolt'; +import { WebClient } from '@slack/web-api'; +import { slackConfig } from '../config'; +import { channelDb } from '../channelsDb'; +import { conversationDb } from '../conversationsDb'; +import { logger } from '../../../core/logger'; + +export async function handle({ + ack, + say, + command, +}: SlackCommandMiddlewareArgs): Promise { + await ack(); + + const allowed = slackConfig.allowedUserIds; + if (allowed.length > 0 && !allowed.includes(command.user_id)) { + await say('You are not authorized to use this command.'); + return; + } + + try { + const [subcommand, ...args] = (command.text || '').trim().split(/\s+/); + + switch (subcommand?.toLowerCase()) { + case 'new': + await handleNew(say, command.channel_id, args[0], command.user_id); + break; + default: + await say(`Unknown subcommand: \`${subcommand}\`. Try: \`new [session-name]\``); + } + } catch (err) { + void logger.error('slack/session', err instanceof Error ? err.message : String(err)); + await say('Failed to execute session command.'); + } +} + +async function handleNew( + say: SayFn, + channelId: string, + sessionName: string | undefined, + userId?: string, +): Promise { + const agentChannel = channelDb.get(channelId); + if (!agentChannel) { + await say('No agent is registered in this channel. Use `/agents new ` first.'); + return; + } + + const { agent_id: agentId, agent_name: agentName } = agentChannel; + const client = new WebClient(slackConfig.token); + const sessionLabel = sessionName ? ` — ${sessionName}` : ''; + + const msgRes = await client.chat.postMessage({ + channel: channelId, + text: `*${agentName}* — ready for a new session${sessionLabel}.\nType your first message to begin.${userId ? ` Only <@${userId}> can interact with the agent in this thread.` : ''}`, + }); + + if (!msgRes.ts) { + await say('Failed to create session message.'); + return; + } + + const threadTs = msgRes.ts; + conversationDb.register(threadTs, channelId, agentId, userId ?? null); + + await client.chat.postMessage({ + channel: channelId, + thread_ts: threadTs, + text: 'Session ready. Send your first message here to start.', + }); +} diff --git a/src/providers/slack/config.ts b/src/providers/slack/config.ts new file mode 100644 index 0000000..efcb80c --- /dev/null +++ b/src/providers/slack/config.ts @@ -0,0 +1,47 @@ +import { required } from '../../core/config'; + +function csv(key: string): string[] { + const val = process.env[key]; + if (!val) return []; + return val + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +/** + * Slack adapter configuration. Loaded lazily so a deployment that + * disables Slack (ENABLED_PROVIDERS=discord) does not fail at startup + * for missing SLACK_BOT_TOKEN. + */ +export const slackConfig = { + get token() { + return required('SLACK_BOT_TOKEN'); + }, + get signingSecret() { + return required('SLACK_SIGNING_SECRET'); + }, + get teamId() { + return required('SLACK_TEAM_ID'); + }, + get appId() { + return required('SLACK_APP_ID'); + }, + get socketModeToken() { + return process.env.SLACK_SOCKET_MODE_TOKEN || ''; + }, + get allowedUserIds() { + return csv('SLACK_ALLOWED_USER_IDS'); + }, + get mentionUserId() { + return process.env.SLACK_MENTION_USER_ID || ''; + }, + get publicUrl() { + return process.env.SLACK_BOT_PUBLIC_URL || ''; + }, + get port() { + const parsed = parseInt(process.env.SLACK_PORT ?? '', 10); + if (Number.isNaN(parsed) || parsed < 1 || parsed > 65535) return 3000; + return parsed; + }, +}; diff --git a/src/providers/slack/conversationsDb.ts b/src/providers/slack/conversationsDb.ts new file mode 100644 index 0000000..1ce5da8 --- /dev/null +++ b/src/providers/slack/conversationsDb.ts @@ -0,0 +1,63 @@ +import type { Database } from 'better-sqlite3'; +import { db } from '../../core/db'; + +export interface SlackAgentConversation { + thread_ts: string; + channel_id: string; + agent_id: string; + owner_user_id: string | null; + session_id: string | null; + created_at: number; +} + +export function createConversationDb(database: Database) { + return { + register( + threadTs: string, + channelId: string, + agentId: string, + ownerUserId: string | null, + ): void { + database.prepare( + `INSERT OR IGNORE INTO slack_agent_conversations (thread_ts, channel_id, agent_id, owner_user_id) + VALUES (?, ?, ?, ?)`, + ).run(threadTs, channelId, agentId, ownerUserId); + }, + + get(threadTs: string): SlackAgentConversation | undefined { + return database + .prepare('SELECT * FROM slack_agent_conversations WHERE thread_ts = ?') + .get(threadTs) as SlackAgentConversation | undefined; + }, + + updateSession(threadTs: string, sessionId: string | null): void { + database.prepare( + 'UPDATE slack_agent_conversations SET session_id = ? WHERE thread_ts = ?', + ).run(sessionId, threadTs); + }, + + remove(threadTs: string): void { + database.prepare('DELETE FROM slack_agent_conversations WHERE thread_ts = ?').run(threadTs); + }, + + listByChannel(channelId: string): SlackAgentConversation[] { + return database + .prepare( + 'SELECT * FROM slack_agent_conversations WHERE channel_id = ? ORDER BY created_at DESC', + ) + .all(channelId) as SlackAgentConversation[]; + }, + + getByAgentId(agentId: string): SlackAgentConversation | undefined { + return database + .prepare('SELECT * FROM slack_agent_conversations WHERE agent_id = ? ORDER BY created_at DESC LIMIT 1') + .get(agentId) as SlackAgentConversation | undefined; + }, + + removeByChannel(channelId: string): void { + database.prepare('DELETE FROM slack_agent_conversations WHERE channel_id = ?').run(channelId); + }, + }; +} + +export const conversationDb = createConversationDb(db); diff --git a/src/providers/slack/messageCreate.ts b/src/providers/slack/messageCreate.ts new file mode 100644 index 0000000..50e84f2 --- /dev/null +++ b/src/providers/slack/messageCreate.ts @@ -0,0 +1,54 @@ +import type { KernelContext, IncomingMessage } from '../../core/types'; +import { conversationDb } from './conversationsDb'; + +/** + * Factory that returns a handler for Slack `message` events. + * Only processes threaded replies to registered conversations. + */ +export function createMessageHandler(ctx: KernelContext) { + return async function handleMessage(args: { + event: Record; + }): Promise { + const event = args.event; + + // Ignore bot messages and empty messages + if (event['bot_id'] || !String(event['text'] ?? '').trim()) { + return; + } + + const threadTs = event['thread_ts'] as string | undefined; + const text = event['text'] as string; + const user = event['user'] as string | undefined; + const channel = event['channel'] as string; + const ts = event['ts'] as string; + + // Only process messages inside known threads + if (!threadTs) { + return; + } + + const convo = conversationDb.get(threadTs); + if (!convo) { + return; + } + + // Only the thread owner can interact + if (convo.owner_user_id && convo.owner_user_id !== user) { + return; + } + + const message: IncomingMessage = { + provider: 'slack', + messageId: ts, + channelId: threadTs, + authorId: user ?? '', + authorName: user ?? '', + content: text, + attachments: [], + isThread: true, + raw: event, + }; + + ctx.enqueue(message); + }; +} diff --git a/templates/sh.maestro.relay.plist b/templates/sh.maestro.relay.plist index c6a0ee4..f436629 100644 --- a/templates/sh.maestro.relay.plist +++ b/templates/sh.maestro.relay.plist @@ -10,7 +10,7 @@ /bin/bash -c - set -a; . "@CONFIG_DIR@/.env"; set +a; exec "@NODE_BIN@" "@INSTALL_DIR@/dist/index.js" + set -a; . "@CONFIG_DIR@/.env"; set +a; export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin${PATH:+:$PATH}"; exec "@NODE_BIN@" "@INSTALL_DIR@/dist/index.js" RunAtLoad