From d6f12df0bde3d4af545a6d358caaaf26e69377c5 Mon Sep 17 00:00:00 2001 From: dodaa08 Date: Tue, 8 Sep 2026 18:08:54 +0530 Subject: [PATCH 1/4] Added support for attachment path --- docs/ARCHITECTURE.md | 27 +++++++++++++++++++++++++++ src/service/inbound.ts | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3800860..e9b2207 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -123,6 +123,33 @@ Plugin DDP Client | **Post** | DDP: send typing stop signal; REST: post message | | **Attachments** | Download → upload via REST → attach reference | +### Inbound media context + +`buildMediaContext()` (`src/service/inbound.ts`) downloads inbound Rocket.Chat file +attachments to temp paths (or keeps URLs) and exposes them to OpenClaw core media +understanding. It emits **both** the legacy `Media*` fields and the newer +`Attachment*` compatibility names, so all the following context keys are available: + +| Family | Keys | +| --------------- | --------------------------------------------------------------------- | +| **Path** | `MediaPath`/`MediaPaths`, `AttachmentPath`/`AttachmentPaths` | +| **URL** | `MediaUrl`/`MediaUrls`, `AttachmentUrl`/`AttachmentUrls` | +| **Type/MIME** | `MediaType`/`MediaTypes`, `AttachmentContentType`/`AttachmentContentTypes` | +| **Directory** | `AttachmentDir`/`AttachmentDirs` (path dirname) | +| **Index** | `AttachmentIndex`/`AttachmentIndexes` | + +Media understanding in core reads the `MediaPath`/`MediaUrls`/`MediaType` family +via `normalizeAttachments()`; the `Attachment*` names are the current CLI-template +tokens the docs reference. + +> **Why the audio CLI config uses `{{MediaPath}}`, not `{{AttachmentPath}}`** +> For a `whisper-cli` audio CLI entry, core's `resolveCliMediaPath()` transcodes +> non-WAV audio (e.g. Rocket.Chat `.ogg` voice notes) to a 16 kHz mono WAV and sets +> that converted path as `templCtx.MediaPath`. `{{AttachmentPath}}` resolves to the +> **original** (unconverted) file from the inbound context and would bypass that +> transcode. Keep `{{MediaPath}}` in `tools.media.audio.models[].args` so +> whisper-cli always receives the transcoded WAV. + ## Commands Commands are parsed by `CommandParser.parse()` and route three ways: diff --git a/src/service/inbound.ts b/src/service/inbound.ts index 6ddd980..b89ec35 100644 --- a/src/service/inbound.ts +++ b/src/service/inbound.ts @@ -9,6 +9,7 @@ import type { import type { RocketChatClient } from "../client/rest.js"; import type { GroupHistoryEntry } from "./group-history.js"; import { parsePluginConfig } from "../config/schema.js"; +import { dirname } from "node:path"; const DEFAULT_OWNER_ONLY_SKILLS = ["email"]; @@ -241,21 +242,53 @@ async function buildMediaContext( const mediaUrls: string[] = []; const mediaPaths: string[] = []; const mediaTypes: string[] = []; + const attachmentPaths: string[] = []; + const attachmentUrls: string[] = []; + const attachmentContentTypes: string[] = []; + const attachmentDirs: string[] = []; + const attachmentIndexes: number[] = []; + let index = 0; for (const r of results) { if (!r) continue; if (r.kind === "path") { mediaPaths.push(r.value); + attachmentPaths.push(r.value); + attachmentDirs.push(dirname(r.value)); } else { mediaUrls.push(r.value); + attachmentUrls.push(r.value); } - if (r.mimeType) mediaTypes.push(r.mimeType); + if (r.mimeType) { + mediaTypes.push(r.mimeType); + attachmentContentTypes.push(r.mimeType); + } + attachmentIndexes.push(index); + index += 1; } return { ...(mediaUrls.length > 0 ? { MediaUrl: mediaUrls[0], MediaUrls: mediaUrls } : {}), ...(mediaPaths.length > 0 ? { MediaPath: mediaPaths[0], MediaPaths: mediaPaths } : {}), ...(mediaTypes.length > 0 ? { MediaType: mediaTypes[0], MediaTypes: mediaTypes } : {}), + ...(attachmentUrls.length > 0 + ? { AttachmentUrl: attachmentUrls[0], AttachmentUrls: attachmentUrls } + : {}), + ...(attachmentPaths.length > 0 + ? { AttachmentPath: attachmentPaths[0], AttachmentPaths: attachmentPaths } + : {}), + ...(attachmentContentTypes.length > 0 + ? { + AttachmentContentType: attachmentContentTypes[0], + AttachmentContentTypes: attachmentContentTypes, + } + : {}), + ...(attachmentDirs.length > 0 + ? { AttachmentDir: attachmentDirs[0], AttachmentDirs: attachmentDirs } + : {}), + ...(attachmentIndexes.length > 0 + ? { AttachmentIndex: attachmentIndexes[0], AttachmentIndexes: attachmentIndexes } + : {}), }; } From d632ef8769d2764d08c8c6232de25518dc876e50 Mon Sep 17 00:00:00 2001 From: dodaa08 Date: Wed, 9 Sep 2026 17:17:42 +0530 Subject: [PATCH 2/4] Removed email from command menu skills, and moved out non native skills from plugin command runner --- docs/ARCHITECTURE.md | 63 +++++++- docs/COMMANDS.md | 51 +------ docs/SETUP.md | 204 +++++-------------------- docs/SKILLS/{CronJobs.md => Cron.md} | 0 docs/SKILLS/Email.md | 3 +- docs/SKILLS/{Collections.md => doc.md} | 10 +- src/service/channel.ts | 119 +-------------- src/service/skill-commands.ts | 4 +- 8 files changed, 104 insertions(+), 350 deletions(-) rename docs/SKILLS/{CronJobs.md => Cron.md} (100%) rename docs/SKILLS/{Collections.md => doc.md} (79%) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e9b2207..0d5eead 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -130,13 +130,13 @@ attachments to temp paths (or keeps URLs) and exposes them to OpenClaw core medi understanding. It emits **both** the legacy `Media*` fields and the newer `Attachment*` compatibility names, so all the following context keys are available: -| Family | Keys | -| --------------- | --------------------------------------------------------------------- | -| **Path** | `MediaPath`/`MediaPaths`, `AttachmentPath`/`AttachmentPaths` | -| **URL** | `MediaUrl`/`MediaUrls`, `AttachmentUrl`/`AttachmentUrls` | -| **Type/MIME** | `MediaType`/`MediaTypes`, `AttachmentContentType`/`AttachmentContentTypes` | -| **Directory** | `AttachmentDir`/`AttachmentDirs` (path dirname) | -| **Index** | `AttachmentIndex`/`AttachmentIndexes` | +| Family | Keys | +| ------------- | -------------------------------------------------------------------------- | +| **Path** | `MediaPath`/`MediaPaths`, `AttachmentPath`/`AttachmentPaths` | +| **URL** | `MediaUrl`/`MediaUrls`, `AttachmentUrl`/`AttachmentUrls` | +| **Type/MIME** | `MediaType`/`MediaTypes`, `AttachmentContentType`/`AttachmentContentTypes` | +| **Directory** | `AttachmentDir`/`AttachmentDirs` (path dirname) | +| **Index** | `AttachmentIndex`/`AttachmentIndexes` | Media understanding in core reads the `MediaPath`/`MediaUrls`/`MediaType` family via `normalizeAttachments()`; the `Attachment*` names are the current CLI-template @@ -262,6 +262,55 @@ Rocket.Chat Server - Access control per-bot - Scale horizontally (add more bots as needed) +## Per-Agent (Per-Bot) Config + +### Location + +Agent-level config lives in two places: + +``` +~/.openclaw/ +│ +├─ openclaw.json # agents.list[] entries: per-agent model selection +└─ agents// + └─ agent/ + └─ models.json # Per-agent provider/model catalog +``` + +### Structure + +Each entry in `agents.list[]` in `openclaw.json` can carry its own `model` selector: + +```json +{ + "agents": { + "list": [ + { + "id": "rc-openclaw2nd", + "workspace": "/home/me/.openclaw/agents/rc-openclaw2nd", + "agentDir": "/home/me/.openclaw/agents/rc-openclaw2nd/agent", + "model": { + "primary": "nvidia-nim/claude-3-freecc-no-thinking/nvidia_nim/nvidia/nemotron-3-super-120b-a12b", + "fallbacks": ["openrouter/google/gemini-2.0-flash-thinking-exp:free", "ollama/mistral:7b"] + } + } + ] + } +} +``` + +Field meanings: + +- `model.primary` — the provider/model ref used first for that agent's replies. Convention: `/`. +- `model.fallbacks` — ordered list of alternate provider/model refs tried automatically on overload, timeout, or availability errors. When a primary fails, OpenClaw walks this chain instead of surfacing the error. +- Omit `model` to inherit `agents.defaults.model` (the global primary). + +Per-agent behavior is then resolved as: + +1. Agent's own `model` (if set) → overrides `agents.defaults.model` +2. Each ref is a provider/model in `agents.defaults.models` or the agent's own catalog +3. On failure, the runtime advances through `fallbacks` (log marker `model_fallback_decision`) + ## Data Deduplication Prevents message replay after restart or duplicate receipt: diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 580ceb0..6f44891 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -92,14 +92,10 @@ Control how the agent responds. ## Tools & Skills -| Command | Description | -| ------------------ | ------------------------------------- | -| `!tools` | List tools available to the agent | -| `!skills` | List installed skills with usage info | -| `!skill ` | Run a specific skill | -| `!skill cron` | Show cron skill help | -| `!skill email` | Show email skill help | -| `!skill configure` | Show setup status for skills | +| Command | Description | +| --------------- | -------------------------------------- | +| `!tools` | List tools available to the agent | +| `!skill ` | Run a skill (handled by OpenClaw core) | ## Cron Jobs @@ -124,43 +120,6 @@ Schedule one-shot reminders or repeating tasks. !cron stop check disk space ``` -## Email - -Send, fetch, and summarize emails. Requires env vars — see [SETUP.md](SETUP.md#email-skills). - -| Command | Description | -| --------------------------------------- | ------------------------------------ | -| `!email send : : ` | Send an email | -| `!email fetch [account]` | Fetch recent emails (max 100) | -| `!email summarize [account]` | Fetch + AI-summarize emails (max 10) | -| `!email` or `!email help` | Show email usage | - -**Examples:** - -``` -!email send alice@example.com : Meeting : Let's meet at 3pm -!email fetch 5 -!email fetch 10 user@gmail.com -!email summarize 5 -``` - -**Requirements:** - -- **Send:** `AGENTMAIL_API_KEY` or `EMAIL_SMTP_USER` + `EMAIL_SMTP_PASS` env var -- **Fetch:** `GMAIL_APP_PASSWORD` env var + `GMAIL_ACCOUNT` (or pass account as arg) - -See [SETUP.md](./SETUP.md) for full reference. - -## Configure - -Check skill setup status and get configuration steps. - -| Command | Description | -| ------------ | ------------------------------------------------------- | -| `!configure` | Show which skills are configured and how to set them up | - -Returns the status of email send/fetch and shows the env vars needed for each. - ## Permission Model Commands are split into two tiers: @@ -170,7 +129,7 @@ Commands are split into two tiers: | **Public** | Anyone in a room where the bot is present | | **Owner** | Only the bot owner (set in `openclaw.json` under `accounts..owner`) | -Owner-only commands: `add-bot`, `remove-bot`, `add-group`, `revoke`, `access`, `bots`, `email`, `configure` +Owner-only commands: `add-bot`, `remove-bot`, `add-group`, `revoke`, `access`, `bots` Non-owners see a permission error when trying owner-only commands. diff --git a/docs/SETUP.md b/docs/SETUP.md index 5975a7b..d2f6a2c 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -102,11 +102,10 @@ This deletes: - Bot user from Rocket.Chat server - Bot config + credentials - Agent workspace -- All associated data ## Clean Everything Up -To completely remove the plugin from your machine: +To completely remove the plugin from your machine so it doesn't take space in your machine: ```bash # Delete all Rocket.Chat plugin data @@ -124,6 +123,33 @@ rm -rf ~/.openclaw/media/inbound/ agents/rc-/ # Dedicated agent config + sessions ``` +### Per-Bot (Per-Agent) Model Config + +Each bot is backed by an agent entry in `agents.list[]` inside `~/.openclaw/openclaw.json`. You can give each bot its own primary model and an automatic fallback chain (tried on overload/timeout) without touching the global default: + +```json +{ + "agents": { + "list": [ + { + "id": "rc-openclaw2nd", + "model": { + "primary": "nvidia-nim/claude-3-freecc-no-thinking/nvidia_nim/nvidia/nemotron-3-super-120b-a12b", + "fallbacks": ["openrouter/google/gemini-2.0-flash-thinking-exp:free", "ollama/mistral:7b"] + } + } + ] + } +} +``` + +- `model.primary` → `/` used for that bot's replies (omit to inherit `agents.defaults.model`). +- `model.fallbacks` → ordered refs tried automatically when the primary is overloaded or times out. +- The per-agent provider/model catalog lives at `agents//agent/models.json`. +- Restart the gateway after edits: `openclaw gateway restart`. + +See ARCHITECTURE.md → "Per-Agent (Per-Bot) Config" for the full structure. + ## Media (Temporary) ``` @@ -147,172 +173,14 @@ Optional overrides for paths and email skills. Set these in your shell before st --- -## Email Setup (for command menu email skill) - -Email skills enable the `!email send` and `!email fetch` commands in Rocket.Chat. -Without the correct credentials, these commands will not work. - -### Overview - -| Purpose | Option 1 (Simplest) | Option 2 (Recommended / more robust) | -| --------- | --------------------- | --------------------------------------- | -| **Send** | Environment variables | `~/.netrc` (Linux/macOS) | -| **Fetch** | Environment variables | systemd / shell profile / permanent env | - ---- - -### Option 1 – Environment Variables Only (works on all OS) - -This is the quickest way and works on Linux, macOS, and Windows. - -```bash -# Send + Fetch (Gmail App Password) -export EMAIL_SMTP_USER="you@gmail.com" -export EMAIL_SMTP_PASS="xxxx xxxx xxxx xxxx" # Gmail App Password -export GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx" -export GMAIL_ACCOUNT="you@gmail.com" -export EMAIL_FROM="you@gmail.com" -``` - -**Windows (PowerShell):** - -```powershell -$env:EMAIL_SMTP_USER = "you@gmail.com" -$env:EMAIL_SMTP_PASS = "xxxx xxxx xxxx xxxx" -$env:GMAIL_APP_PASSWORD = "xxxx xxxx xxxx xxxx" -$env:GMAIL_ACCOUNT = "you@gmail.com" -$env:EMAIL_FROM = "you@gmail.com" -``` - -Then restart the gateway: - -```bash -openclaw gateway restart -``` - -> Tip: To make these permanent, add them to your shell profile (`~/.zshrc`, `~/.bashrc`, `$PROFILE`) or System Environment Variables on Windows. - ---- - -### Option 2 – OS-native / recommended methods - -#### Sending Emails - -**Linux & macOS (recommended)** – use `~/.netrc` (no environment variables needed): - -```bash -nano ~/.netrc -``` - -Add: - -``` -machine smtp.gmail.com login you@gmail.com password "xxxx xxxx xxxx xxxx" -``` - -Lock the file: - -```bash -chmod 0600 ~/.netrc -``` - -`s-nail` will pick this up automatically. - -**Windows** – stick with Option 1 (environment variables). There is no clean equivalent of `~/.netrc` for this use case. - -#### Fetching Emails (Gmail App Password) - -You still need a Gmail **App Password** (not your normal password). -Generate one at: Google Account → Security → 2-Step Verification → App passwords → “Mail”. - -**Linux (systemd – best for production):** - -```bash -systemctl --user edit --full openclaw-gateway.service -``` - -Add under `[Service]`: - -``` -Environment="GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx" -Environment=GMAIL_ACCOUNT=you@gmail.com -Environment=EMAIL_FROM=you@gmail.com -``` - -**Important:** Quote the entire `KEY=value` pair because Gmail app passwords contain spaces. - -Reload & restart: - -```bash -systemctl --user daemon-reload -systemctl --user restart openclaw-gateway.service -``` - -Verify the password loaded: - -```bash -systemctl --user show openclaw-gateway.service -p Environment | grep GMAIL -``` - -> Note: OpenClaw may regenerate the service file on updates. Re-check and re-add these lines after each upgrade. - -**macOS / Linux (shell profile):** - -```bash -# ~/.zshrc, ~/.bashrc or ~/.profile -export GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx" -export GMAIL_ACCOUNT="you@gmail.com" -export EMAIL_FROM="you@gmail.com" -``` - -Then: - -```bash -source ~/.zshrc # or the file you edited -openclaw gateway restart -``` - -**Windows** – use Option 1 (environment variables) and make them permanent via System Properties or `$PROFILE`. - ---- - -### Getting the Keys - -- **Gmail App Password** (for fetch): - Google Account → Security → 2-Step Verification → App passwords → generate one for "Mail". - -- **SMTP credentials** (for send): - Use `smtp.gmail.com` with the same App Password. Prefer `~/.netrc` on Linux/macOS. - -### Verifying Setup - -In Rocket.Chat run: - -``` -!configure -``` - -You should see something like: - -``` -Email Configuration: -Send: ✅ netrc / SMTP configured -Fetch: ✅ Gmail app password configured -``` - -If a skill shows ❌, set the corresponding credentials and restart the gateway. - ---- - ## Troubleshooting Setup -| Issue | Fix | -| ------------------------- | ------------------------------------------------------------------------------------- | -| "Can't connect to server" | Check server URL is correct + reachable | -| "Admin login failed" | Verify admin username/password; try deleting `admin.json` and re-running setup | -| "Bot creation failed" | Check you have admin rights; try manual `!add-bot` after setup | -| "2FA keeps failing" | Check TOTP app time is synced; email OTP expires after ~5 min | -| Email send/fetch fails | Run `!configure` and confirm both show ✅. Restart gateway after changing credentials | +| Issue | Fix | +| ------------------------- | ------------------------------------------------------------------------------ | +| "Can't connect to server" | Check server URL is correct + reachable | +| "Admin login failed" | Verify admin username/password; try deleting `admin.json` and re-running setup | +| "Bot creation failed" | Check you have admin rights; try manual `!add-bot` after setup | +| "2FA keeps failing" | Check TOTP app time is synced; email OTP expires after ~5 min | --- @@ -368,7 +236,3 @@ failed_messages (message_id, room_id, reason) -- debugging: what went wrong ``` Limits: 250 seen messages, 100 failed records per bot (auto-pruned). - -``` - -``` diff --git a/docs/SKILLS/CronJobs.md b/docs/SKILLS/Cron.md similarity index 100% rename from docs/SKILLS/CronJobs.md rename to docs/SKILLS/Cron.md diff --git a/docs/SKILLS/Email.md b/docs/SKILLS/Email.md index 7f9dfb7..f8ebac4 100644 --- a/docs/SKILLS/Email.md +++ b/docs/SKILLS/Email.md @@ -14,7 +14,7 @@ metadata: himalaya is blocked for sending. Use s-nail instead. -Sending requires SMTP or Agentmail credentials configured on the gateway (see `!configure`). +Sending requires SMTP or Agentmail credentials configured on the gateway (via env vars, `~/.netrc`, or the OpenClaw core skill config). ```bash echo "Body text here" | s-nail -s "Subject" recipient@example.com @@ -38,7 +38,6 @@ fetch-emails 5 ## CRITICAL rules -- NEVER use himalaya for sending — it's blocked - NEVER pass $GMAIL_APP_PASSWORD in any command — fetch-emails reads it from the environment (or `~/.config/gmail/`) internally - Pass an explicit `` unless `GMAIL_ACCOUNT` is set — never assume a default - Present the fetched result exactly ONCE — do not re-fetch or re-summarize the same data diff --git a/docs/SKILLS/Collections.md b/docs/SKILLS/doc.md similarity index 79% rename from docs/SKILLS/Collections.md rename to docs/SKILLS/doc.md index 3dfc063..71c9da4 100644 --- a/docs/SKILLS/Collections.md +++ b/docs/SKILLS/doc.md @@ -39,19 +39,15 @@ Two ready-made example skills are included to get you started: - **`email`** : send/read email via `s-nail` and `fetch-emails` Reference: [SKILLS/Email.md](./Email.md) - **`cron`** : schedule one-shot and recurring reminders via the `openclaw cron` CLI - Reference: [SKILLS/CronJobs.md](./CronJobs.md) + Reference: [SKILLS/Cron.md](./Cron.md) -These two are directly accessible from the **command menu**. +Of these, the `cron` skill is exposed natively in the command menu (`!cron`). The `email` skill is used like any other skill via OpenClaw core (`!skill `). > Copy the contents of the reference file into `~/.openclaw/workspace/skills//SKILL.md` to use it as-is, or edit it to fit your setup. -## 4. Skills beyond the command menu - -Not every skill needs to live in the command menu. right now other skills aren't directly triggered from the command menu instead, they're picked up and used automatically through OpenClaw's **inbound message handling**, exactly as OpenClaw is designed to work. - ## 5. Explore and add more skills -Want more skills? Check out: +Want more skills and install them like npm packages ? Check out: - 🔗 Awesome OpenClaw Skills (community repo): [https://github.com/VoltAgent/awesome-openclaw-skills](https://github.com/VoltAgent/awesome-openclaw-skills) - 🔗 Skills website: [https://clawskills.sh/](https://clawskills.sh/) diff --git a/src/service/channel.ts b/src/service/channel.ts index b071992..15c1789 100644 --- a/src/service/channel.ts +++ b/src/service/channel.ts @@ -1,10 +1,8 @@ import type { InboundEvent } from "../types.js"; import type { ChannelRuleOptions } from "../types.js"; -import { DM_SCOPE, CommandParser, resolveOpenClawDir } from "../utils.js"; +import { DM_SCOPE, CommandParser } from "../utils.js"; import { RocketChatClient } from "../client/rest.js"; import type { RCLoginResult } from "../types.js"; -import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; -import { resolve, join } from "node:path"; import { readConfig, readDefaultModel, @@ -39,17 +37,7 @@ import { loadAdmin, removeBotCredentials } from "../cli/credentials.js"; import { startGateway } from "./gateway.js"; import { activeClients, connectionStatus } from "./runtime-state.js"; import { AccessStore } from "../config/access-store.js"; -import { - runCronCommand, - runEmailCommand, - runConfigureCommand, - CRON_USAGE, - CRON_HEADING, - EMAIL_USAGE, - EMAIL_HEADING, - CONFIGURE_USAGE, - CONFIGURE_HEADING, -} from "./skill-commands.js"; +import { runCronCommand } from "./skill-commands.js"; const BROADCAST_MENTIONS = new Set(["here", "all", "everyone"]); @@ -152,8 +140,6 @@ const OWNER_ONLY_COMMANDS = new Set([ "revoke", "access", "bots", - "email", - "configure", ]); function isOwner(ctx: CommandContext): boolean { @@ -217,37 +203,8 @@ async function runCommand( return await runModel(argStr); case "tools": return { action: "openclaw-command", command: `/tools${argStr ? " " + argStr : ""}` }; - case "skill": { - const skillName = argStr.trim().split(/\s+/)[0] ?? ""; - const owner = isOwner(ctx); - if (skillName.toLowerCase() === "cron") { - return { action: "reply", replyText: [CRON_HEADING, CRON_USAGE].join("\n") }; - } - if ( - (skillName.toLowerCase() === "email" || skillName.toLowerCase() === "configure") && - !owner - ) { - return { - action: "reply", - replyText: `\`!skill ${skillName}\` is owner-only. Contact ${ctx.account.owner ? `@${ctx.account.owner}` : "the bot owner"}.`, - }; - } - if (skillName.toLowerCase() === "email") { - return { action: "reply", replyText: [EMAIL_HEADING, EMAIL_USAGE].join("\n") }; - } - if (skillName.toLowerCase() === "configure") { - return { action: "reply", replyText: [CONFIGURE_HEADING, CONFIGURE_USAGE].join("\n") }; - } - return { action: "openclaw-command", command: `/skill${argStr ? " " + argStr : ""}` }; - } - case "skills": - return { action: "reply", replyText: runSkills(isOwner(ctx)) }; case "cron": return { action: "reply", replyText: await runCronCommand(ctx, argStr) }; - case "email": - return { action: "reply", replyText: await runEmailCommand(ctx, argStr) }; - case "configure": - return { action: "reply", replyText: runConfigureCommand() }; case "think": return { action: "openclaw-command", command: `/think${argStr ? " " + argStr : ""}` }; case "abort": @@ -310,14 +267,7 @@ function buildHelpText(showAll: boolean): string { ["verbose on/off", "debug details"], ], ], - [ - "Tools & Skills", - [ - ["tools", "list agent tools"], - ["skills", "installed skills"], - ["skill ", "run a skill"], - ], - ], + ["Cron", [["cron ", "one-shot reminder; run `!cron` for full usage"]]], ]; const visibleGroups = groups @@ -477,69 +427,6 @@ function runBots(): string { return ["**Bot accounts**", ...lines].join("\n"); } -function parseSkillFrontmatter(content: string): { name?: string; description?: string } { - const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); - if (!fmMatch) return {}; - const fm = fmMatch[1]!; - const result: { name?: string; description?: string } = {}; - const nameLine = fm.match(/^name:\s*(.+)$/m); - if (nameLine) result.name = nameLine[1]!.trim().replace(/^["']|["']$/g, ""); - const descLine = fm.match(/^description:\s*(.+)$/m); - if (descLine) result.description = descLine[1]!.trim().replace(/^["']|["']$/g, ""); - return result; -} - -function runSkills(showOwnerOnly: boolean): string { - const skillsDir = join(resolveOpenClawDir(), "workspace", "skills"); - if (!existsSync(skillsDir)) { - return "No skills installed (expected at ~/.openclaw/workspace/skills)."; - } - const entries = readdirSync(skillsDir).filter((name) => { - const full = resolve(skillsDir, name); - try { - return statSync(full).isDirectory() || statSync(full).isSymbolicLink(); - } catch { - return false; - } - }); - const skills: Array<{ name: string; description: string }> = []; - for (const name of entries) { - const skillMd = resolve(skillsDir, name, "SKILL.md"); - if (!existsSync(skillMd)) continue; - let content = ""; - try { - content = readFileSync(skillMd, "utf8"); - } catch { - continue; - } - const fm = parseSkillFrontmatter(content); - if (!fm.name) continue; - skills.push({ name: fm.name, description: fm.description ?? "" }); - } - if (skills.length === 0) { - return "No skills installed (expected at ~/.openclaw/workspace/skills)."; - } - const cap = (s: string, n = 80): string => (s.length > n ? s.slice(0, n).trimEnd() + "…" : s); - const lines = ["**Skills**"]; - const has = (name: string): boolean => skills.some((s) => s.name === name); - lines.push("", CRON_HEADING, CRON_USAGE); - if (showOwnerOnly) { - if (has("email") || has("agentmail")) { - lines.push("", EMAIL_HEADING, EMAIL_USAGE); - } - lines.push("", CONFIGURE_HEADING, CONFIGURE_USAGE); - } - for (const s of skills) { - if (s.name === "cron" || s.name === "email" || s.name === "agentmail") continue; - if (!showOwnerOnly && s.name === "configure") continue; - const title = s.name.charAt(0).toUpperCase() + s.name.slice(1); - lines.push("", `**${title}**`); - lines.push(`• ${s.description ? cap(s.description) : "No description available."}`); - lines.push(`• Run with: \`!skill ${s.name}\``); - } - return lines.join("\n"); -} - async function runGroups(ctx: CommandContext): Promise { try { const subs = await ctx.client.listSubscriptions(null); diff --git a/src/service/skill-commands.ts b/src/service/skill-commands.ts index 43275e7..4f8f6db 100644 --- a/src/service/skill-commands.ts +++ b/src/service/skill-commands.ts @@ -26,8 +26,8 @@ export const EMAIL_USAGE = [ "• Fetch via email skill: `!email fetch [account]` (max 20)", "• Summarize via email skill + agent: `!email summarize [account]` (max 10)", "", - "ℹ️ `!email send` is for quick, simple emails. For professional emails, use inbound chat.", - "⚠️ Don't use ` : ` (space-colon-space) in the subject — it's the separator for the format.", + "`!email send` is for quick, simple emails. For professional emails, use inbound chat.", + "Don't use ` : ` (space-colon-space) in the subject it's the separator for the format.", ].join("\n"); export const CONFIGURE_HEADING = "**Configure**"; From c11e012aba522a7705acbaf0cc86f45eefcb2b4d Mon Sep 17 00:00:00 2001 From: dodaa08 Date: Wed, 9 Sep 2026 17:29:48 +0530 Subject: [PATCH 3/4] Added skill listing back --- docs/COMMANDS.md | 8 ++--- src/service/channel.ts | 67 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 6f44891..9ae8ab7 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -92,10 +92,10 @@ Control how the agent responds. ## Tools & Skills -| Command | Description | -| --------------- | -------------------------------------- | -| `!tools` | List tools available to the agent | -| `!skill ` | Run a skill (handled by OpenClaw core) | +| Command | Description | +| --------- | -------------------------------------------- | +| `!tools` | List tools available to the agent | +| `!skills` | List installed skills (use via inbound chat) | ## Cron Jobs diff --git a/src/service/channel.ts b/src/service/channel.ts index 15c1789..b79bce0 100644 --- a/src/service/channel.ts +++ b/src/service/channel.ts @@ -1,8 +1,10 @@ import type { InboundEvent } from "../types.js"; import type { ChannelRuleOptions } from "../types.js"; -import { DM_SCOPE, CommandParser } from "../utils.js"; +import { DM_SCOPE, CommandParser, resolveOpenClawDir } from "../utils.js"; import { RocketChatClient } from "../client/rest.js"; import type { RCLoginResult } from "../types.js"; +import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; +import { resolve, join } from "node:path"; import { readConfig, readDefaultModel, @@ -203,6 +205,8 @@ async function runCommand( return await runModel(argStr); case "tools": return { action: "openclaw-command", command: `/tools${argStr ? " " + argStr : ""}` }; + case "skills": + return { action: "reply", replyText: runSkills() }; case "cron": return { action: "reply", replyText: await runCronCommand(ctx, argStr) }; case "think": @@ -267,7 +271,13 @@ function buildHelpText(showAll: boolean): string { ["verbose on/off", "debug details"], ], ], - ["Cron", [["cron ", "one-shot reminder; run `!cron` for full usage"]]], + [ + "Cron & Skills", + [ + ["cron ", "one-shot reminder; run `!cron` for full usage"], + ["skills", "list installed skills (use via inbound chat)"], + ], + ], ]; const visibleGroups = groups @@ -427,6 +437,59 @@ function runBots(): string { return ["**Bot accounts**", ...lines].join("\n"); } +function runSkills(): string { + const skillsDir = join(resolveOpenClawDir(), "workspace", "skills"); + if (!existsSync(skillsDir)) { + return "No skills installed (expected at ~/.openclaw/workspace/skills)."; + } + const entries = readdirSync(skillsDir).filter((name) => { + const full = resolve(skillsDir, name); + try { + return statSync(full).isDirectory() || statSync(full).isSymbolicLink(); + } catch { + return false; + } + }); + const skills: Array<{ name: string; description: string }> = []; + for (const name of entries) { + const skillMd = resolve(skillsDir, name, "SKILL.md"); + if (!existsSync(skillMd)) continue; + let content = ""; + try { + content = readFileSync(skillMd, "utf8"); + } catch { + continue; + } + const fm = parseSkillFrontmatter(content); + if (!fm.name) continue; + skills.push({ name: fm.name, description: fm.description ?? "" }); + } + if (skills.length === 0) { + return "No skills installed (expected at ~/.openclaw/workspace/skills)."; + } + const cap = (s: string, n = 200): string => (s.length > n ? s.slice(0, n).trimEnd() + "…" : s); + const lines = ["**Installed skills**", ""]; + lines.push("Use a skill via inbound chat with the agent."); + for (const s of skills) { + const title = s.name.charAt(0).toUpperCase() + s.name.slice(1); + lines.push("", `**${title}**`); + lines.push(`• ${s.description ? cap(s.description) : "No description available."}`); + } + return lines.join("\n"); +} + +function parseSkillFrontmatter(content: string): { name?: string; description?: string } { + const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (!fmMatch) return {}; + const fm = fmMatch[1]!; + const result: { name?: string; description?: string } = {}; + const nameLine = fm.match(/^name:\s*(.+)$/m); + if (nameLine) result.name = nameLine[1]!.trim().replace(/^["']|["']$/g, ""); + const descLine = fm.match(/^description:\s*(.+)$/m); + if (descLine) result.description = descLine[1]!.trim().replace(/^["']|["']$/g, ""); + return result; +} + async function runGroups(ctx: CommandContext): Promise { try { const subs = await ctx.client.listSubscriptions(null); From 7bae6239761ac8042e85150de0cc26acff0c320b Mon Sep 17 00:00:00 2001 From: dodaa08 Date: Wed, 9 Sep 2026 23:33:27 +0530 Subject: [PATCH 4/4] Removed dead code and imports for email skill runner --- docs/SETUP.md | 1 - docs/SKILLS/Email.md | 4 +- package.json | 3 +- pnpm-lock.yaml | 13 +- src/service/channel.ts | 22 +- src/service/skill-commands.ts | 423 +--------------------------------- 6 files changed, 25 insertions(+), 441 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index d2f6a2c..37e271d 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -78,7 +78,6 @@ rocketchat/ ├── access.db # Access grants (who can use which bot/room) ├── .db # Seen messages + failures (per bot) ├── rate-limit.json # Bot creation cooldown state -└── skills-status.json # Email skill setup status ``` ### Credentials: What's Safe to Delete diff --git a/docs/SKILLS/Email.md b/docs/SKILLS/Email.md index f8ebac4..4f59199 100644 --- a/docs/SKILLS/Email.md +++ b/docs/SKILLS/Email.md @@ -10,9 +10,7 @@ metadata: # Email -## Send (use s-nail, NOT himalaya) - -himalaya is blocked for sending. Use s-nail instead. +## Send (use s-nail) Sending requires SMTP or Agentmail credentials configured on the gateway (via env vars, `~/.netrc`, or the OpenClaw core skill config). diff --git a/package.json b/package.json index 433336c..2bd7e6f 100644 --- a/package.json +++ b/package.json @@ -68,11 +68,12 @@ "typescript": "^6.0.3" }, "dependencies": { - "@rocket.chat/ddp-client": "^1.1.1", "@clack/prompts": "^1.7.0", + "@rocket.chat/ddp-client": "^1.1.1", "commander": "^15.0.0", "json5": "^2.2.3", "picocolors": "^1.1.1", + "yaml": "^2.9.0", "zod": "^4.4.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f32f518..21ee08a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: picocolors: specifier: ^1.1.1 version: 1.1.1 + yaml: + specifier: ^2.9.0 + version: 2.9.0 zod: specifier: ^4.4.3 version: 4.5.4 @@ -1957,7 +1960,7 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@google/genai@2.18.0(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))': + '@google/genai@2.18.0(@modelcontextprotocol/sdk@1.30.0(zod@4.5.4))': dependencies: google-auth-library: 10.9.1 p-retry: 4.6.2 @@ -2105,10 +2108,10 @@ snapshots: '@mozilla/readability@0.6.0': {} - '@openclaw/ai@2026.9.2(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3)': + '@openclaw/ai@2026.9.2(@modelcontextprotocol/sdk@1.30.0(zod@4.5.4))(ws@8.21.3)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.120.0(zod@4.4.3) - '@google/genai': 2.18.0(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)) + '@google/genai': 2.18.0(@modelcontextprotocol/sdk@1.30.0(zod@4.5.4)) '@mistralai/mistralai': 2.6.4 openai: 7.5.0(ws@8.21.3)(zod@4.4.3) partial-json: 0.1.7 @@ -3052,7 +3055,7 @@ snapshots: '@clack/core': 1.4.3 '@clack/prompts': 1.7.0 '@earendil-works/pi-tui': 0.84.3 - '@google/genai': 2.18.0(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)) + '@google/genai': 2.18.0(@modelcontextprotocol/sdk@1.30.0(zod@4.5.4)) '@grammyjs/runner': 2.0.3(grammy@1.46.0) '@grammyjs/transformer-throttler': 1.2.1(grammy@1.46.0) '@homebridge/ciao': 1.3.12 @@ -3060,7 +3063,7 @@ snapshots: '@mistralai/mistralai': 2.6.4 '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) '@mozilla/readability': 0.6.0 - '@openclaw/ai': 2026.9.2(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3) + '@openclaw/ai': 2026.9.2(@modelcontextprotocol/sdk@1.30.0(zod@4.5.4))(ws@8.21.3)(zod@4.4.3) '@openclaw/fs-safe': 0.8.1 '@openclaw/proxyline': 0.3.7(undici@8.10.2) '@silvia-odwyer/photon-node': 0.3.4 diff --git a/src/service/channel.ts b/src/service/channel.ts index b79bce0..8023199 100644 --- a/src/service/channel.ts +++ b/src/service/channel.ts @@ -5,6 +5,7 @@ import { RocketChatClient } from "../client/rest.js"; import type { RCLoginResult } from "../types.js"; import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; import { resolve, join } from "node:path"; +import { parse as parseYaml } from "yaml"; import { readConfig, readDefaultModel, @@ -479,15 +480,18 @@ function runSkills(): string { } function parseSkillFrontmatter(content: string): { name?: string; description?: string } { - const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); - if (!fmMatch) return {}; - const fm = fmMatch[1]!; - const result: { name?: string; description?: string } = {}; - const nameLine = fm.match(/^name:\s*(.+)$/m); - if (nameLine) result.name = nameLine[1]!.trim().replace(/^["']|["']$/g, ""); - const descLine = fm.match(/^description:\s*(.+)$/m); - if (descLine) result.description = descLine[1]!.trim().replace(/^["']|["']$/g, ""); - return result; + const lines = content.split("\n"); + const openIdx = lines[0]?.trim() === "---" ? 0 : -1; + if (openIdx === -1) return {}; + const closeIdx = lines.findIndex((l, i) => i > openIdx && l.trim() === "---"); + if (closeIdx === -1) return {}; + const data = parseYaml(lines.slice(openIdx + 1, closeIdx).join("\n")) as + { name?: unknown; description?: unknown } | null | undefined; + if (typeof data !== "object" || data === null) return {}; + return { + ...(typeof data.name === "string" ? { name: data.name } : {}), + ...(typeof data.description === "string" ? { description: data.description } : {}), + }; } async function runGroups(ctx: CommandContext): Promise { diff --git a/src/service/skill-commands.ts b/src/service/skill-commands.ts index 4f8f6db..fd6f197 100644 --- a/src/service/skill-commands.ts +++ b/src/service/skill-commands.ts @@ -1,16 +1,9 @@ -import { execFile, spawn } from "node:child_process"; -import { readdirSync, existsSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { homedir } from "node:os"; +import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { resolveOpenClawDir } from "../utils.js"; import type { CommandContext } from "./channel.js"; const execFileAsync = promisify(execFile); -const EMAIL_DOCS = - "https://github.com/RocketChat/Openclaw/blob/main/docs/SETUP.md#email-setup-for-command-menu-email-skill"; - export const CRON_HEADING = "**Cron jobs**"; export const CRON_USAGE = [ "• `!cron ` one-shot reminder (30s | 5m | 2h | 1d)", @@ -19,125 +12,6 @@ export const CRON_USAGE = [ "• `!cron stop ` stop a repeating job", "• Examples: `!cron 30m stretch` · `!cron --every 1h check disk space`", ].join("\n"); - -export const EMAIL_HEADING = "**Email**"; -export const EMAIL_USAGE = [ - "• Send quick emails: `!email send : : `", - "• Fetch via email skill: `!email fetch [account]` (max 20)", - "• Summarize via email skill + agent: `!email summarize [account]` (max 10)", - "", - "`!email send` is for quick, simple emails. For professional emails, use inbound chat.", - "Don't use ` : ` (space-colon-space) in the subject it's the separator for the format.", -].join("\n"); - -export const CONFIGURE_HEADING = "**Configure**"; -export const CONFIGURE_USAGE = ["• `!configure` check skill setup and get setup steps"].join("\n"); - -type AuthStatus = { ok: boolean; hint: string }; - -function skillStatusFile(): string { - return resolve(resolveOpenClawDir(), "rocketchat", "skills-status.json"); -} - -function persistStatus(send: boolean, fetch: boolean): void { - const data = { - sendConfigured: send, - fetchConfigured: fetch, - checkedAt: new Date().toISOString(), - }; - try { - writeFileSync(skillStatusFile(), JSON.stringify(data, null, 2), "utf8"); - } catch { - /* best-effort; non-fatal */ - } -} - -function readStatus(): { send: boolean; fetch: boolean } | undefined { - try { - const parsed = JSON.parse(readFileSync(skillStatusFile(), "utf8")) as Partial<{ - sendConfigured?: boolean; - fetchConfigured?: boolean; - }>; - return { send: parsed.sendConfigured === true, fetch: parsed.fetchConfigured === true }; - } catch { - return undefined; - } -} - -export function fetchAuthStatus(): AuthStatus { - const password = process.env.GMAIL_APP_PASSWORD?.trim(); - if (password) return { ok: true, hint: "Gmail app password (env `GMAIL_APP_PASSWORD`)" }; - const gmailDir = resolve(homedir(), ".config", "gmail"); - const files = existsSync(gmailDir) - ? readdirSync(gmailDir).filter((f) => f.startsWith("app_password")) - : []; - if (files.length > 0) return { ok: true, hint: "Gmail app password file(s)" }; - return { - ok: false, - hint: "fetch needs a Gmail app password. Set `GMAIL_APP_PASSWORD` env, or add one under `~/.config/gmail/`.", - }; -} - -export function sendAuthStatus(): AuthStatus { - if (process.env.AGENTMAIL_API_KEY?.trim()) { - return { ok: true, hint: "Agentmail API key (env `AGENTMAIL_API_KEY`)" }; - } - if (process.env.EMAIL_SMTP_USER?.trim() && process.env.EMAIL_SMTP_PASS?.trim()) { - return { ok: true, hint: "SMTP credentials (`EMAIL_SMTP_USER` + `EMAIL_SMTP_PASS`)" }; - } - const mailrcPath = resolve(homedir(), ".mailrc"); - if (existsSync(mailrcPath)) { - try { - const content = readFileSync(mailrcPath, "utf8"); - const hasMta = /mta\s*=\s*smtps?:\/\/[^\s]*@[^\s]+/.test(content); - const usesNetrc = /netrc-lookup/.test(content); - const credsReady = usesNetrc - ? existsSync(resolve(homedir(), ".netrc")) - : /mta\s*=\s*smtps?:\/\/[^\s]*:[^\s]*@[^\s]+/.test(content); - if (hasMta && credsReady) { - return { ok: true, hint: "s-nail config (`~/.mailrc` SMTP `mta`)" }; - } - } catch { - /* fall through to "not configured" */ - } - } - return { - ok: false, - hint: "send needs Agentmail or SMTP creds. Set `AGENTMAIL_API_KEY`, or `EMAIL_SMTP_USER` + `EMAIL_SMTP_PASS`, or configure s-nail in `~/.mailrc`.", - }; -} - -export function runConfigureCommand(): string { - const send = sendAuthStatus(); - const fetch = fetchAuthStatus(); - persistStatus(send.ok, fetch.ok); - const emailOk = send.ok || fetch.ok; - - const lines = ["**Setup status**"]; - lines.push(`- Email: ${emailOk ? "**configured**" : "**not configured**"}`); - lines.push(`- Send: ${send.ok ? "**configured**" : "**not configured**"} ${send.hint}`); - lines.push(`- Fetch: ${fetch.ok ? "**configured**" : "**not configured**"} ${fetch.hint}`); - lines.push("- Cron: **always available** (uses OpenClaw account config)"); - - if (!emailOk) { - lines.push("", "### To set up email"); - lines.push( - "• Send: set the `AGENTMAIL_API_KEY` env var, or `EMAIL_SMTP_USER` + `EMAIL_SMTP_PASS`, on the gateway or configure s-nail in `~/.mailrc`.", - ); - lines.push( - "• Fetch: set the `GMAIL_APP_PASSWORD` env var (a Gmail app password), or add a file under `~/.config/gmail/`.", - ); - lines.push("• After adding creds, run `!configure` again to re-check."); - } - - lines.push("", `Docs: ${EMAIL_DOCS}`); - return lines.join("\n"); -} - -function resolveFetchAccount(): string { - return process.env.GMAIL_ACCOUNT?.trim() ?? ""; -} - const INTERVAL_RE = /^(\d+(?:\.\d+)?)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i; @@ -425,298 +299,3 @@ export async function runCronCommand(ctx: CommandContext, argStr: string): Promi "Reminder set. It will appear in this chat.", ].join("\n"); } - -function resolveFetchEmailsBin(): string { - return process.env.FETCH_EMAILS_BIN ?? "fetch-emails"; -} - -function resolveSNailBin(): string { - return process.env.SNAIL_BIN ?? "s-nail"; -} - -function formatEmailHelp(): string { - return [EMAIL_HEADING, EMAIL_USAGE].join("\n"); -} - -async function fetchEmailRaw(count: number, account: string): Promise { - try { - const res = await execFileAsync(resolveFetchEmailsBin(), [`${count}`, account], { - timeout: 60000, - maxBuffer: 2 * 1024 * 1024, - }); - return (res.stdout || "").trim(); - } catch (e) { - const error = e as { stdout?: string; stderr?: string; message?: string }; - throw new Error( - String(error?.stderr ?? error?.stdout ?? error?.message ?? "unknown error").trim(), - ); - } -} - -async function fetchEmail(countInput: string, accountInput: string | undefined): Promise { - const count = parseInt(countInput, 10); - if (!Number.isInteger(count) || count < 1 || count > 20) { - return [ - "Invalid count for fetching emails.", - "Correct format:", - "• `!email fetch [account]`", - "• Example: `!email fetch 5`", - ].join("\n"); - } - const status = readStatus(); - if (!status?.fetch) { - return [ - "Email isn't set up yet.", - "Run `!configure` to check and set up email creds, then try again.", - `Docs: ${EMAIL_DOCS}`, - ].join("\n"); - } - const account = accountInput?.trim() || resolveFetchAccount(); - if (!account) { - return [ - "No Gmail account specified.", - "Pass one: `!email fetch `, or set the `GMAIL_ACCOUNT` env var on the gateway.", - `Docs: ${EMAIL_DOCS}`, - ].join("\n"); - } - try { - const out = await fetchEmailRaw(count, account); - if (!out) return "Fetched, but the inbox returned no content."; - return out; - } catch (e) { - const error = e instanceof Error ? e.message : String(e); - return ["Failed to fetch email. Details:", "```", error, "```"].join("\n"); - } -} - -const EMAIL_SUMMARY_MAX = 10; - -async function summarizeEmail( - ctx: CommandContext, - countInput: string, - accountInput: string | undefined, -): Promise { - const count = parseInt(countInput, 10); - if (!Number.isInteger(count) || count < 1 || count > EMAIL_SUMMARY_MAX) { - return [ - `Invalid count for summarizing emails (max ${EMAIL_SUMMARY_MAX}).`, - "Correct format:", - `• \`!email summarize [account]\` (max ${EMAIL_SUMMARY_MAX})`, - "• Example: `!email summarize 5`", - ].join("\n"); - } - const status = readStatus(); - if (!status?.fetch) { - return [ - "Email isn't set up yet.", - "Run `!configure` to check and set up email creds, then try again.", - `Docs: ${EMAIL_DOCS}`, - ].join("\n"); - } - const account = accountInput?.trim() || resolveFetchAccount(); - if (!account) { - return [ - "No Gmail account specified.", - "Pass one: `!email summarize `, or set the `GMAIL_ACCOUNT` env var on the gateway.", - `Docs: ${EMAIL_DOCS}`, - ].join("\n"); - } - - let emails: string; - try { - emails = await fetchEmailRaw(count, account); - } catch (e) { - const error = e instanceof Error ? e.message : String(e); - return ["Failed to fetch email. Details:", "```", error, "```"].join("\n"); - } - if (!emails) { - return "Fetched, but the inbox returned no content to summarize."; - } - - const agentId = `rc-${ctx.accountId}`; - const prompt = [ - "Summarize the following email inbox in a concise, natural way.", - "Group similar items (newsletters, alerts, personal), note senders and subjects,", - "and flag anything that needs attention. Keep it short and skimmable.", - "", - "--- INBOX START ---", - emails, - "--- INBOX END ---", - ].join("\n"); - - const argv = ["agent", "--agent", agentId, "--message", prompt, "--json", "--timeout", "120"]; - - const extractSummary = (raw: string): string | undefined => { - try { - const parsed = JSON.parse(raw) as { - text?: unknown; - result?: unknown; - response?: unknown; - error?: unknown; - }; - for (const v of [parsed.text, parsed.response]) { - if (typeof v === "string" && v.trim()) return v.trim(); - } - const result = parsed.result; - if (result && typeof result === "object") { - const payloads = (result as { payloads?: Array<{ text?: unknown }> }).payloads; - for (const p of payloads ?? []) { - if (typeof p.text === "string" && p.text.trim()) return p.text.trim(); - } - } - if (typeof parsed.error === "string") return undefined; - return undefined; - } catch { - return undefined; - } - }; - - const summarizeErr = (raw: string): string => - ["Failed to summarize email. Details:", "```", raw || "unknown error", "```"].join("\n"); - - try { - const res = await execFileAsync(resolveOpenClawBin(), argv, { - timeout: 125000, - maxBuffer: 4 * 1024 * 1024, - }); - const summary = extractSummary(res.stdout ?? ""); - if (summary !== undefined) return summary; - return "The agent produced no summary."; - } catch (e) { - const error = e as { stdout?: string; stderr?: string; message?: string }; - const raw = String(error?.stderr ?? error?.stdout ?? error?.message ?? "").trim(); - const summary = extractSummary(raw); - if (summary !== undefined) return summary; - const msg = ((err: unknown) => { - try { - const parsed = JSON.parse(String(err)) as { error?: string }; - return typeof parsed.error === "string" ? parsed.error : undefined; - } catch { - return undefined; - } - })(raw); - if (msg) return `Summarization failed: ${msg}`; - return summarizeErr(raw); - } -} - -function sendEmail(to: string, subject: string, body: string): Promise { - // Input validation - prevent injection attacks - if (!to || !subject || !body) { - return Promise.resolve("Email parameters cannot be empty."); - } - - // Basic email validation - if (!to.includes("@") || to.includes("\n") || to.includes("\0")) { - return Promise.resolve(`Invalid email address: ${to}`); - } - - // Sanitize subject and body to prevent shell injection - if (subject.includes("\n") || subject.includes("\0")) { - return Promise.resolve("Subject contains invalid characters."); - } - - const from = process.env.EMAIL_FROM?.trim(); - const argv = from ? ["-s", subject, "-S", `from=${from}`, to] : ["-s", subject, to]; - - return new Promise((resolvePromise) => { - try { - const child = spawn(resolveSNailBin(), argv, { - stdio: ["pipe", "pipe", "pipe"], - timeout: 30000, - }); - - const out: string[] = []; - const err: string[] = []; - - child.stdout?.on("data", (d) => out.push(String(d))); - child.stderr?.on("data", (d) => err.push(String(d))); - - child.on("error", (e) => - resolvePromise(["Failed to send email.", "```", String(e.message || e), "```"].join("\n")), - ); - - child.on("close", (code) => { - if (code === 0) { - resolvePromise(`Email sent to ${to}.`); - } else { - resolvePromise( - [ - `s-nail exited with code ${code}.`, - "```", - (err.join("") || out.join("") || "no output").trim(), - "```", - ].join("\n"), - ); - } - }); - - child.stdin?.write(body); - child.stdin?.end(); - } catch (e) { - resolvePromise(["Failed to send email.", "```", String(e), "```"].join("\n")); - } - }); -} -export async function runEmailCommand(ctx: CommandContext, argStr: string): Promise { - const trimmed = argStr.trim(); - if (!trimmed || trimmed === "help") { - return formatEmailHelp(); - } - - const firstSpace = trimmed.search(/\s/); - const sub = (firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase(); - const rest = firstSpace === -1 ? "" : trimmed.slice(firstSpace).trim(); - - if (sub === "fetch") { - return fetchEmail( - rest.split(/\s+/)[0] ?? "", - rest.split(/\s+/).slice(1).join(" ") || undefined, - ); - } - - if (sub === "summarize") { - return summarizeEmail( - ctx, - rest.split(/\s+/)[0] ?? "", - rest.split(/\s+/).slice(1).join(" ") || undefined, - ); - } - - if (sub === "send") { - const sepIdx = rest.indexOf(" : "); - if (sepIdx === -1) { - return [ - "Invalid send format.", - "Correct format:", - "• `!email send : : `", - "• Example: `!email send friend@example.com : Hello : Check this out`", - ].join("\n"); - } - const afterFirst = rest.slice(sepIdx + 3); - const secondSepIdx = afterFirst.indexOf(" : "); - const to = rest.slice(0, sepIdx).trim(); - const subject = - secondSepIdx === -1 ? afterFirst.trim() : afterFirst.slice(0, secondSepIdx).trim(); - const body = secondSepIdx === -1 ? "" : afterFirst.slice(secondSepIdx + 3).trim(); - if (!to || !subject || !body) { - return [ - "Invalid send format.", - "Correct format:", - "• `!email send : : `", - "• Example: `!email send friend@example.com : Hello : Check this out`", - ].join("\n"); - } - const status = readStatus(); - if (!status?.send) { - return [ - "Email isn't set up yet (send).", - "Run `!configure` to check and set up email creds, then try again.", - `Docs: ${EMAIL_DOCS}`, - ].join("\n"); - } - return sendEmail(to, subject, body); - } - - return ["Unknown email action. Usage:", formatEmailHelp()].join("\n"); -}