Initial commit - LettaBot multi-channel AI assistant
Co-authored-by: Cameron Pfiffer <cameron@pfiffer.org> Co-authored-by: Caren Thomas <carenthomas@gmail.com> Co-authored-by: Charles Packer <packercharles@gmail.com> Co-authored-by: Sarah Wooders <sarahwooders@gmail.com>
This commit is contained in:
125
.env.example
Normal file
125
.env.example
Normal file
@@ -0,0 +1,125 @@
|
||||
# ============================================
|
||||
# LettaBot Configuration
|
||||
# ============================================
|
||||
|
||||
# Letta API Key (from app.letta.com)
|
||||
LETTA_API_KEY=your_letta_api_key
|
||||
|
||||
# Working directory for agent workspace
|
||||
# WORKING_DIR=/tmp/lettabot
|
||||
|
||||
# Custom system prompt (optional)
|
||||
# SYSTEM_PROMPT=You are a helpful assistant...
|
||||
|
||||
# Allowed tools (comma-separated)
|
||||
# ALLOWED_TOOLS=Read,Glob,Grep,Task,web_search,conversation_search
|
||||
|
||||
# ============================================
|
||||
# Telegram (required: at least one channel)
|
||||
# ============================================
|
||||
# Get token from @BotFather on Telegram
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
|
||||
# DM access policy: pairing (default), allowlist, open
|
||||
# - pairing: Unknown senders get a code, must be approved via CLI
|
||||
# - allowlist: Only users in TELEGRAM_ALLOWED_USERS can message
|
||||
# - open: Anyone can message
|
||||
# TELEGRAM_DM_POLICY=pairing
|
||||
|
||||
# Restrict to specific Telegram user IDs (comma-separated)
|
||||
# Used when dmPolicy is 'allowlist', or as pre-approved users for 'pairing'
|
||||
# TELEGRAM_ALLOWED_USERS=123456789,987654321
|
||||
|
||||
# ============================================
|
||||
# Slack (optional)
|
||||
# ============================================
|
||||
# Get tokens from api.slack.com/apps
|
||||
# Bot Token: xoxb-... (OAuth & Permissions)
|
||||
# App Token: xapp-... (Socket Mode, enable in app settings)
|
||||
|
||||
# SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
# SLACK_APP_TOKEN=xapp-your-app-token
|
||||
|
||||
# Restrict to specific Slack user IDs (e.g., U01234567)
|
||||
# SLACK_ALLOWED_USERS=U01234567,U98765432
|
||||
|
||||
# ============================================
|
||||
# WhatsApp (optional)
|
||||
# ============================================
|
||||
# Enable WhatsApp (will show QR code on first run)
|
||||
# WHATSAPP_ENABLED=true
|
||||
|
||||
# ============================================
|
||||
# Signal (optional)
|
||||
# ============================================
|
||||
# Requires signal-cli: brew install signal-cli
|
||||
# Link with: signal-cli link -n "LettaBot" (scan QR in Signal app)
|
||||
|
||||
# Your Signal phone number (E.164 format)
|
||||
# SIGNAL_PHONE_NUMBER=+15551234567
|
||||
|
||||
# Path to signal-cli binary (default: signal-cli)
|
||||
# SIGNAL_CLI_PATH=signal-cli
|
||||
|
||||
# HTTP daemon settings (default: 127.0.0.1:8090)
|
||||
# SIGNAL_HTTP_HOST=127.0.0.1
|
||||
# SIGNAL_HTTP_PORT=8090
|
||||
|
||||
# DM access policy: pairing (default), allowlist, open
|
||||
# - pairing: Unknown senders get a code, must be approved via CLI
|
||||
# - allowlist: Only users in SIGNAL_ALLOWED_USERS can message
|
||||
# - open: Anyone can message
|
||||
# SIGNAL_DM_POLICY=pairing
|
||||
|
||||
# Restrict to specific phone numbers (used with allowlist, or pre-approved for pairing)
|
||||
# SIGNAL_ALLOWED_USERS=+15559876543,+15551112222
|
||||
|
||||
# Enable/disable Note to Self messages (default: true)
|
||||
# SIGNAL_SELF_CHAT_MODE=true
|
||||
|
||||
# ============================================
|
||||
# Polling (system-level background checks)
|
||||
# ============================================
|
||||
# Polls every minute by default for new emails, etc.
|
||||
# POLLING_INTERVAL_MS=60000
|
||||
|
||||
# Gmail - requires gog CLI
|
||||
# Install: brew install steipete/tap/gogcli
|
||||
# Setup: gog auth add you@gmail.com --services gmail
|
||||
# GMAIL_ACCOUNT=you@gmail.com
|
||||
|
||||
# Session storage path
|
||||
# WHATSAPP_SESSION_PATH=./data/whatsapp-session
|
||||
|
||||
# Restrict to specific phone numbers (with country code)
|
||||
# WHATSAPP_ALLOWED_USERS=+15551234567,+15559876543
|
||||
|
||||
# ============================================
|
||||
# Cron Jobs (optional)
|
||||
# ============================================
|
||||
# Enable scheduled tasks
|
||||
# CRON_ENABLED=true
|
||||
|
||||
# ============================================
|
||||
# Heartbeat (optional)
|
||||
# ============================================
|
||||
# Heartbeat interval in minutes (set to enable heartbeat)
|
||||
# Agent checks HEARTBEAT.md for tasks. Responds HEARTBEAT_OK if nothing to do.
|
||||
# HEARTBEAT_INTERVAL_MIN=30
|
||||
|
||||
# Delivery target (format: channel:chatId). Defaults to last messaged chat.
|
||||
# HEARTBEAT_TARGET=telegram:123456789
|
||||
|
||||
# Custom heartbeat prompt (optional)
|
||||
# HEARTBEAT_PROMPT=Read HEARTBEAT.md if it exists. If nothing needs attention, reply HEARTBEAT_OK.
|
||||
|
||||
# ============================================
|
||||
# Gmail Integration (optional)
|
||||
# ============================================
|
||||
# GMAIL_ENABLED=true
|
||||
# GMAIL_WEBHOOK_PORT=8788
|
||||
# GMAIL_WEBHOOK_TOKEN=your_webhook_secret
|
||||
# GMAIL_CLIENT_ID=your_client_id.apps.googleusercontent.com
|
||||
# GMAIL_CLIENT_SECRET=your_client_secret
|
||||
# GMAIL_REFRESH_TOKEN=your_refresh_token
|
||||
# GMAIL_TELEGRAM_USER=123456789
|
||||
13
.github/dependabot.yml
vendored
Normal file
13
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
allow:
|
||||
- dependency-name: "@letta-ai/letta-code-sdk"
|
||||
- dependency-name: "@letta-ai/letta-client"
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "chore(deps):"
|
||||
34
.github/workflows/letta.yml
vendored
Normal file
34
.github/workflows/letta.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Letta Code
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request:
|
||||
types: [opened, labeled]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
letta:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: letta-ai/letta-code-action@v0
|
||||
with:
|
||||
letta_api_key: ${{ secrets.LETTA_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Use my persistent agent ID for consistent memory
|
||||
agent_id: agent-a7d61fda-62c3-44ae-90a0-c8359fae6e3d
|
||||
# Default to opus model for best quality
|
||||
model: opus
|
||||
# Trigger phrase
|
||||
trigger_phrase: "@letta-code"
|
||||
# Label trigger
|
||||
label_trigger: "letta-code"
|
||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Environment files (contain secrets)
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Letta local data
|
||||
.letta/
|
||||
|
||||
# User data
|
||||
user-agents.json
|
||||
|
||||
# Reference repos (cloned for research)
|
||||
*-reference/
|
||||
|
||||
# Test files
|
||||
test-*.mjs
|
||||
test-*.js
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Runtime files
|
||||
cron-log.jsonl
|
||||
cron-jobs.json
|
||||
lettabot-agent.json
|
||||
PERSONA.md
|
||||
|
||||
# Related repos
|
||||
moltbot/
|
||||
letta-code-sdk/
|
||||
|
||||
# WhatsApp session (contains credentials)
|
||||
data/whatsapp-session/
|
||||
53
.skills/1password/SKILL.md
Normal file
53
.skills/1password/SKILL.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: 1password
|
||||
description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in (single or multi-account), or reading/injecting/running secrets via op.
|
||||
homepage: https://developer.1password.com/docs/cli/get-started/
|
||||
metadata: {"clawdbot":{"emoji":"🔐","requires":{"bins":["op"]},"install":[{"id":"brew","kind":"brew","formula":"1password-cli","bins":["op"],"label":"Install 1Password CLI (brew)"}]}}
|
||||
---
|
||||
|
||||
# 1Password CLI
|
||||
|
||||
Follow the official CLI get-started steps. Don't guess install commands.
|
||||
|
||||
## References
|
||||
|
||||
- `references/get-started.md` (install + app integration + sign-in flow)
|
||||
- `references/cli-examples.md` (real `op` examples)
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Check OS + shell.
|
||||
2. Verify CLI present: `op --version`.
|
||||
3. Confirm desktop app integration is enabled (per get-started) and the app is unlocked.
|
||||
4. REQUIRED: create a fresh tmux session for all `op` commands (no direct `op` calls outside tmux).
|
||||
5. Sign in / authorize inside tmux: `op signin` (expect app prompt).
|
||||
6. Verify access inside tmux: `op whoami` (must succeed before any secret read).
|
||||
7. If multiple accounts: use `--account` or `OP_ACCOUNT`.
|
||||
|
||||
## REQUIRED tmux session (T-Max)
|
||||
|
||||
The shell tool uses a fresh TTY per command. To avoid re-prompts and failures, always run `op` inside a dedicated tmux session with a fresh socket/session name.
|
||||
|
||||
Example (see `tmux` skill for socket conventions, do not reuse old session names):
|
||||
|
||||
```bash
|
||||
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
|
||||
mkdir -p "$SOCKET_DIR"
|
||||
SOCKET="$SOCKET_DIR/clawdbot-op.sock"
|
||||
SESSION="op-auth-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op signin --account my.1password.com" Enter
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op whoami" Enter
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op vault list" Enter
|
||||
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
|
||||
tmux -S "$SOCKET" kill-session -t "$SESSION"
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never paste secrets into logs, chat, or code.
|
||||
- Prefer `op run` / `op inject` over writing secrets to disk.
|
||||
- If sign-in without app integration is needed, use `op account add`.
|
||||
- If a command returns "account is not signed in", re-run `op signin` inside tmux and authorize in the app.
|
||||
- Do not run `op` outside tmux; stop and ask if tmux is unavailable.
|
||||
29
.skills/1password/references/cli-examples.md
Normal file
29
.skills/1password/references/cli-examples.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# op CLI examples (from op help)
|
||||
|
||||
## Sign in
|
||||
|
||||
- `op signin`
|
||||
- `op signin --account <shorthand|signin-address|account-id|user-id>`
|
||||
|
||||
## Read
|
||||
|
||||
- `op read op://app-prod/db/password`
|
||||
- `op read "op://app-prod/db/one-time password?attribute=otp"`
|
||||
- `op read "op://app-prod/ssh key/private key?ssh-format=openssh"`
|
||||
- `op read --out-file ./key.pem op://app-prod/server/ssh/key.pem`
|
||||
|
||||
## Run
|
||||
|
||||
- `export DB_PASSWORD="op://app-prod/db/password"`
|
||||
- `op run --no-masking -- printenv DB_PASSWORD`
|
||||
- `op run --env-file="./.env" -- printenv DB_PASSWORD`
|
||||
|
||||
## Inject
|
||||
|
||||
- `echo "db_password: {{ op://app-prod/db/password }}" | op inject`
|
||||
- `op inject -i config.yml.tpl -o config.yml`
|
||||
|
||||
## Whoami / accounts
|
||||
|
||||
- `op whoami`
|
||||
- `op account list`
|
||||
17
.skills/1password/references/get-started.md
Normal file
17
.skills/1password/references/get-started.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 1Password CLI get-started (summary)
|
||||
|
||||
- Works on macOS, Windows, and Linux.
|
||||
- macOS/Linux shells: bash, zsh, sh, fish.
|
||||
- Windows shell: PowerShell.
|
||||
- Requires a 1Password subscription and the desktop app to use app integration.
|
||||
- macOS requirement: Big Sur 11.0.0 or later.
|
||||
- Linux app integration requires PolKit + an auth agent.
|
||||
- Install the CLI per the official doc for your OS.
|
||||
- Enable desktop app integration in the 1Password app:
|
||||
- Open and unlock the app, then select your account/collection.
|
||||
- macOS: Settings > Developer > Integrate with 1Password CLI (Touch ID optional).
|
||||
- Windows: turn on Windows Hello, then Settings > Developer > Integrate.
|
||||
- Linux: Settings > Security > Unlock using system authentication, then Settings > Developer > Integrate.
|
||||
- After integration, run any command to sign in (example in docs: `op vault list`).
|
||||
- If multiple accounts: use `op signin` to pick one, or `--account` / `OP_ACCOUNT`.
|
||||
- For non-integration auth, use `op account add`.
|
||||
50
.skills/apple-notes/SKILL.md
Normal file
50
.skills/apple-notes/SKILL.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: apple-notes
|
||||
description: Manage Apple Notes via the `memo` CLI on macOS (create, view, edit, delete, search, move, and export notes). Use when a user asks Clawdbot to add a note, list notes, search notes, or manage note folders.
|
||||
homepage: https://github.com/antoniorodr/memo
|
||||
metadata: {"clawdbot":{"emoji":"📝","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}}
|
||||
---
|
||||
|
||||
# Apple Notes CLI
|
||||
|
||||
Use `memo notes` to manage Apple Notes directly from the terminal. Create, view, edit, delete, search, move notes between folders, and export to HTML/Markdown.
|
||||
|
||||
Setup
|
||||
- Install (Homebrew): `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo`
|
||||
- Manual (pip): `pip install .` (after cloning the repo)
|
||||
- macOS-only; if prompted, grant Automation access to Notes.app.
|
||||
|
||||
View Notes
|
||||
- List all notes: `memo notes`
|
||||
- Filter by folder: `memo notes -f "Folder Name"`
|
||||
- Search notes (fuzzy): `memo notes -s "query"`
|
||||
|
||||
Create Notes
|
||||
- Add a new note: `memo notes -a`
|
||||
- Opens an interactive editor to compose the note.
|
||||
- Quick add with title: `memo notes -a "Note Title"`
|
||||
|
||||
Edit Notes
|
||||
- Edit existing note: `memo notes -e`
|
||||
- Interactive selection of note to edit.
|
||||
|
||||
Delete Notes
|
||||
- Delete a note: `memo notes -d`
|
||||
- Interactive selection of note to delete.
|
||||
|
||||
Move Notes
|
||||
- Move note to folder: `memo notes -m`
|
||||
- Interactive selection of note and destination folder.
|
||||
|
||||
Export Notes
|
||||
- Export to HTML/Markdown: `memo notes -ex`
|
||||
- Exports selected note; uses Mistune for markdown processing.
|
||||
|
||||
Limitations
|
||||
- Cannot edit notes containing images or attachments.
|
||||
- Interactive prompts may require terminal access.
|
||||
|
||||
Notes
|
||||
- macOS-only.
|
||||
- Requires Apple Notes.app to be accessible.
|
||||
- For automation, grant permissions in System Settings > Privacy & Security > Automation.
|
||||
67
.skills/apple-reminders/SKILL.md
Normal file
67
.skills/apple-reminders/SKILL.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: apple-reminders
|
||||
description: Manage Apple Reminders via the `remindctl` CLI on macOS (list, add, edit, complete, delete). Supports lists, date filters, and JSON/plain output.
|
||||
homepage: https://github.com/steipete/remindctl
|
||||
metadata: {"clawdbot":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["remindctl"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/remindctl","bins":["remindctl"],"label":"Install remindctl via Homebrew"}]}}
|
||||
---
|
||||
|
||||
# Apple Reminders CLI (remindctl)
|
||||
|
||||
Use `remindctl` to manage Apple Reminders directly from the terminal. It supports list filtering, date-based views, and scripting output.
|
||||
|
||||
Setup
|
||||
- Install (Homebrew): `brew install steipete/tap/remindctl`
|
||||
- From source: `pnpm install && pnpm build` (binary at `./bin/remindctl`)
|
||||
- macOS-only; grant Reminders permission when prompted.
|
||||
|
||||
Permissions
|
||||
- Check status: `remindctl status`
|
||||
- Request access: `remindctl authorize`
|
||||
|
||||
View Reminders
|
||||
- Default (today): `remindctl`
|
||||
- Today: `remindctl today`
|
||||
- Tomorrow: `remindctl tomorrow`
|
||||
- Week: `remindctl week`
|
||||
- Overdue: `remindctl overdue`
|
||||
- Upcoming: `remindctl upcoming`
|
||||
- Completed: `remindctl completed`
|
||||
- All: `remindctl all`
|
||||
- Specific date: `remindctl 2026-01-04`
|
||||
|
||||
Manage Lists
|
||||
- List all lists: `remindctl list`
|
||||
- Show list: `remindctl list Work`
|
||||
- Create list: `remindctl list Projects --create`
|
||||
- Rename list: `remindctl list Work --rename Office`
|
||||
- Delete list: `remindctl list Work --delete`
|
||||
|
||||
Create Reminders
|
||||
- Quick add: `remindctl add "Buy milk"`
|
||||
- With list + due: `remindctl add --title "Call mom" --list Personal --due tomorrow`
|
||||
|
||||
Edit Reminders
|
||||
- Edit title/due: `remindctl edit 1 --title "New title" --due 2026-01-04`
|
||||
|
||||
Complete Reminders
|
||||
- Complete by id: `remindctl complete 1 2 3`
|
||||
|
||||
Delete Reminders
|
||||
- Delete by id: `remindctl delete 4A83 --force`
|
||||
|
||||
Output Formats
|
||||
- JSON (scripting): `remindctl today --json`
|
||||
- Plain TSV: `remindctl today --plain`
|
||||
- Counts only: `remindctl today --quiet`
|
||||
|
||||
Date Formats
|
||||
Accepted by `--due` and date filters:
|
||||
- `today`, `tomorrow`, `yesterday`
|
||||
- `YYYY-MM-DD`
|
||||
- `YYYY-MM-DD HH:mm`
|
||||
- ISO 8601 (`2026-01-04T12:34:56Z`)
|
||||
|
||||
Notes
|
||||
- macOS-only.
|
||||
- If access is denied, enable Terminal/remindctl in System Settings → Privacy & Security → Reminders.
|
||||
- If running over SSH, grant access on the Mac that runs the command.
|
||||
79
.skills/bear-notes/SKILL.md
Normal file
79
.skills/bear-notes/SKILL.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: bear-notes
|
||||
description: Create, search, and manage Bear notes via grizzly CLI.
|
||||
homepage: https://bear.app
|
||||
metadata: {"clawdbot":{"emoji":"🐻","os":["darwin"],"requires":{"bins":["grizzly"]},"install":[{"id":"go","kind":"go","module":"github.com/tylerwince/grizzly/cmd/grizzly@latest","bins":["grizzly"],"label":"Install grizzly (go)"}]}}
|
||||
---
|
||||
|
||||
# Bear Notes
|
||||
|
||||
Use `grizzly` to create, read, and manage notes in Bear on macOS.
|
||||
|
||||
Requirements
|
||||
- Bear app installed and running
|
||||
- For some operations (add-text, tags, open-note --selected), a Bear app token (stored in `~/.config/grizzly/token`)
|
||||
|
||||
## Getting a Bear Token
|
||||
|
||||
For operations that require a token (add-text, tags, open-note --selected), you need an authentication token:
|
||||
1. Open Bear → Help → API Token → Copy Token
|
||||
2. Save it: `echo "YOUR_TOKEN" > ~/.config/grizzly/token`
|
||||
|
||||
## Common Commands
|
||||
|
||||
Create a note
|
||||
```bash
|
||||
echo "Note content here" | grizzly create --title "My Note" --tag work
|
||||
grizzly create --title "Quick Note" --tag inbox < /dev/null
|
||||
```
|
||||
|
||||
Open/read a note by ID
|
||||
```bash
|
||||
grizzly open-note --id "NOTE_ID" --enable-callback --json
|
||||
```
|
||||
|
||||
Append text to a note
|
||||
```bash
|
||||
echo "Additional content" | grizzly add-text --id "NOTE_ID" --mode append --token-file ~/.config/grizzly/token
|
||||
```
|
||||
|
||||
List all tags
|
||||
```bash
|
||||
grizzly tags --enable-callback --json --token-file ~/.config/grizzly/token
|
||||
```
|
||||
|
||||
Search notes (via open-tag)
|
||||
```bash
|
||||
grizzly open-tag --name "work" --enable-callback --json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
Common flags:
|
||||
- `--dry-run` — Preview the URL without executing
|
||||
- `--print-url` — Show the x-callback-url
|
||||
- `--enable-callback` — Wait for Bear's response (needed for reading data)
|
||||
- `--json` — Output as JSON (when using callbacks)
|
||||
- `--token-file PATH` — Path to Bear API token file
|
||||
|
||||
## Configuration
|
||||
|
||||
Grizzly reads config from (in priority order):
|
||||
1. CLI flags
|
||||
2. Environment variables (`GRIZZLY_TOKEN_FILE`, `GRIZZLY_CALLBACK_URL`, `GRIZZLY_TIMEOUT`)
|
||||
3. `.grizzly.toml` in current directory
|
||||
4. `~/.config/grizzly/config.toml`
|
||||
|
||||
Example `~/.config/grizzly/config.toml`:
|
||||
```toml
|
||||
token_file = "~/.config/grizzly/token"
|
||||
callback_url = "http://127.0.0.1:42123/success"
|
||||
timeout = "5s"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Bear must be running for commands to work
|
||||
- Note IDs are Bear's internal identifiers (visible in note info or via callbacks)
|
||||
- Use `--enable-callback` when you need to read data back from Bear
|
||||
- Some operations require a valid token (add-text, tags, open-note --selected)
|
||||
197
.skills/bird/SKILL.md
Normal file
197
.skills/bird/SKILL.md
Normal file
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: bird
|
||||
description: X/Twitter CLI for reading, searching, posting, and engagement via cookies.
|
||||
homepage: https://bird.fast
|
||||
metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)","os":["darwin"]},{"id":"npm","kind":"node","package":"@steipete/bird","bins":["bird"],"label":"Install bird (npm)"}]}}
|
||||
---
|
||||
|
||||
# bird 🐦
|
||||
|
||||
Fast X/Twitter CLI using GraphQL + cookie auth.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# npm/pnpm/bun
|
||||
npm install -g @steipete/bird
|
||||
|
||||
# Homebrew (macOS, prebuilt binary)
|
||||
brew install steipete/tap/bird
|
||||
|
||||
# One-shot (no install)
|
||||
bunx @steipete/bird whoami
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
`bird` uses cookie-based auth.
|
||||
|
||||
Use `--auth-token` / `--ct0` to pass cookies directly, or `--cookie-source` for browser cookies.
|
||||
|
||||
Run `bird check` to see which source is active. For Arc/Brave, use `--chrome-profile-dir <path>`.
|
||||
|
||||
## Commands
|
||||
|
||||
### Account & Auth
|
||||
|
||||
```bash
|
||||
bird whoami # Show logged-in account
|
||||
bird check # Show credential sources
|
||||
bird query-ids --fresh # Refresh GraphQL query ID cache
|
||||
```
|
||||
|
||||
### Reading Tweets
|
||||
|
||||
```bash
|
||||
bird read <url-or-id> # Read a single tweet
|
||||
bird <url-or-id> # Shorthand for read
|
||||
bird thread <url-or-id> # Full conversation thread
|
||||
bird replies <url-or-id> # List replies to a tweet
|
||||
```
|
||||
|
||||
### Timelines
|
||||
|
||||
```bash
|
||||
bird home # Home timeline (For You)
|
||||
bird home --following # Following timeline
|
||||
bird user-tweets @handle -n 20 # User's profile timeline
|
||||
bird mentions # Tweets mentioning you
|
||||
bird mentions --user @handle # Mentions of another user
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
```bash
|
||||
bird search "query" -n 10
|
||||
bird search "from:steipete" --all --max-pages 3
|
||||
```
|
||||
|
||||
### News & Trending
|
||||
|
||||
```bash
|
||||
bird news -n 10 # AI-curated from Explore tabs
|
||||
bird news --ai-only # Filter to AI-curated only
|
||||
bird news --sports # Sports tab
|
||||
bird news --with-tweets # Include related tweets
|
||||
bird trending # Alias for news
|
||||
```
|
||||
|
||||
### Lists
|
||||
|
||||
```bash
|
||||
bird lists # Your lists
|
||||
bird lists --member-of # Lists you're a member of
|
||||
bird list-timeline <id> -n 20 # Tweets from a list
|
||||
```
|
||||
|
||||
### Bookmarks & Likes
|
||||
|
||||
```bash
|
||||
bird bookmarks -n 10
|
||||
bird bookmarks --folder-id <id> # Specific folder
|
||||
bird bookmarks --include-parent # Include parent tweet
|
||||
bird bookmarks --author-chain # Author's self-reply chain
|
||||
bird bookmarks --full-chain-only # Full reply chain
|
||||
bird unbookmark <url-or-id>
|
||||
bird likes -n 10
|
||||
```
|
||||
|
||||
### Social Graph
|
||||
|
||||
```bash
|
||||
bird following -n 20 # Users you follow
|
||||
bird followers -n 20 # Users following you
|
||||
bird following --user <id> # Another user's following
|
||||
bird about @handle # Account origin/location info
|
||||
```
|
||||
|
||||
### Engagement Actions
|
||||
|
||||
```bash
|
||||
bird follow @handle # Follow a user
|
||||
bird unfollow @handle # Unfollow a user
|
||||
```
|
||||
|
||||
### Posting
|
||||
|
||||
```bash
|
||||
bird tweet "hello world"
|
||||
bird reply <url-or-id> "nice thread!"
|
||||
bird tweet "check this out" --media image.png --alt "description"
|
||||
```
|
||||
|
||||
**⚠️ Posting risks**: Posting is more likely to be rate limited; if blocked, use the browser tool instead.
|
||||
|
||||
## Media Uploads
|
||||
|
||||
```bash
|
||||
bird tweet "hi" --media img.png --alt "description"
|
||||
bird tweet "pics" --media a.jpg --media b.jpg # Up to 4 images
|
||||
bird tweet "video" --media clip.mp4 # Or 1 video
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
Commands supporting pagination: `replies`, `thread`, `search`, `bookmarks`, `likes`, `list-timeline`, `following`, `followers`, `user-tweets`
|
||||
|
||||
```bash
|
||||
bird bookmarks --all # Fetch all pages
|
||||
bird bookmarks --max-pages 3 # Limit pages
|
||||
bird bookmarks --cursor <cursor> # Resume from cursor
|
||||
bird replies <id> --all --delay 1000 # Delay between pages (ms)
|
||||
```
|
||||
|
||||
## Output Options
|
||||
|
||||
```bash
|
||||
--json # JSON output
|
||||
--json-full # JSON with raw API response
|
||||
--plain # No emoji, no color (script-friendly)
|
||||
--no-emoji # Disable emoji
|
||||
--no-color # Disable ANSI colors (or set NO_COLOR=1)
|
||||
--quote-depth n # Max quoted tweet depth in JSON (default: 1)
|
||||
```
|
||||
|
||||
## Global Options
|
||||
|
||||
```bash
|
||||
--auth-token <token> # Set auth_token cookie
|
||||
--ct0 <token> # Set ct0 cookie
|
||||
--cookie-source <source> # Cookie source for browser cookies (repeatable)
|
||||
--chrome-profile <name> # Chrome profile name
|
||||
--chrome-profile-dir <path> # Chrome/Chromium profile dir or cookie DB path
|
||||
--firefox-profile <name> # Firefox profile
|
||||
--timeout <ms> # Request timeout
|
||||
--cookie-timeout <ms> # Cookie extraction timeout
|
||||
```
|
||||
|
||||
## Config File
|
||||
|
||||
`~/.config/bird/config.json5` (global) or `./.birdrc.json5` (project):
|
||||
|
||||
```json5
|
||||
{
|
||||
cookieSource: ["chrome"],
|
||||
chromeProfileDir: "/path/to/Arc/Profile",
|
||||
timeoutMs: 20000,
|
||||
quoteDepth: 1
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables: `BIRD_TIMEOUT_MS`, `BIRD_COOKIE_TIMEOUT_MS`, `BIRD_QUOTE_DEPTH`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Query IDs stale (404 errors)
|
||||
```bash
|
||||
bird query-ids --fresh
|
||||
```
|
||||
|
||||
### Cookie extraction fails
|
||||
- Check browser is logged into X
|
||||
- Try different `--cookie-source`
|
||||
- For Arc/Brave: use `--chrome-profile-dir`
|
||||
|
||||
---
|
||||
|
||||
**TL;DR**: Read/search/engage with CLI. Post carefully or use browser. 🐦
|
||||
46
.skills/blogwatcher/SKILL.md
Normal file
46
.skills/blogwatcher/SKILL.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: blogwatcher
|
||||
description: Monitor blogs and RSS/Atom feeds for updates using the blogwatcher CLI.
|
||||
homepage: https://github.com/Hyaxia/blogwatcher
|
||||
metadata: {"clawdbot":{"emoji":"📰","requires":{"bins":["blogwatcher"]},"install":[{"id":"go","kind":"go","module":"github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest","bins":["blogwatcher"],"label":"Install blogwatcher (go)"}]}}
|
||||
---
|
||||
|
||||
# blogwatcher
|
||||
|
||||
Track blog and RSS/Atom feed updates with the `blogwatcher` CLI.
|
||||
|
||||
Install
|
||||
- Go: `go install github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest`
|
||||
|
||||
Quick start
|
||||
- `blogwatcher --help`
|
||||
|
||||
Common commands
|
||||
- Add a blog: `blogwatcher add "My Blog" https://example.com`
|
||||
- List blogs: `blogwatcher blogs`
|
||||
- Scan for updates: `blogwatcher scan`
|
||||
- List articles: `blogwatcher articles`
|
||||
- Mark an article read: `blogwatcher read 1`
|
||||
- Mark all articles read: `blogwatcher read-all`
|
||||
- Remove a blog: `blogwatcher remove "My Blog"`
|
||||
|
||||
Example output
|
||||
```
|
||||
$ blogwatcher blogs
|
||||
Tracked blogs (1):
|
||||
|
||||
xkcd
|
||||
URL: https://xkcd.com
|
||||
```
|
||||
```
|
||||
$ blogwatcher scan
|
||||
Scanning 1 blog(s)...
|
||||
|
||||
xkcd
|
||||
Source: RSS | Found: 4 | New: 4
|
||||
|
||||
Found 4 new article(s) total!
|
||||
```
|
||||
|
||||
Notes
|
||||
- Use `blogwatcher <command> --help` to discover flags and options.
|
||||
27
.skills/blucli/SKILL.md
Normal file
27
.skills/blucli/SKILL.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: blucli
|
||||
description: BluOS CLI (blu) for discovery, playback, grouping, and volume.
|
||||
homepage: https://blucli.sh
|
||||
metadata: {"clawdbot":{"emoji":"🫐","requires":{"bins":["blu"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/blucli/cmd/blu@latest","bins":["blu"],"label":"Install blucli (go)"}]}}
|
||||
---
|
||||
|
||||
# blucli (blu)
|
||||
|
||||
Use `blu` to control Bluesound/NAD players.
|
||||
|
||||
Quick start
|
||||
- `blu devices` (pick target)
|
||||
- `blu --device <id> status`
|
||||
- `blu play|pause|stop`
|
||||
- `blu volume set 15`
|
||||
|
||||
Target selection (in priority order)
|
||||
- `--device <id|name|alias>`
|
||||
- `BLU_DEVICE`
|
||||
- config default (if set)
|
||||
|
||||
Common tasks
|
||||
- Grouping: `blu group status|add|remove`
|
||||
- TuneIn search/play: `blu tunein search "query"`, `blu tunein play "query"`
|
||||
|
||||
Prefer `--json` for scripts. Confirm the target device before changing playback.
|
||||
25
.skills/camsnap/SKILL.md
Normal file
25
.skills/camsnap/SKILL.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: camsnap
|
||||
description: Capture frames or clips from RTSP/ONVIF cameras.
|
||||
homepage: https://camsnap.ai
|
||||
metadata: {"clawdbot":{"emoji":"📸","requires":{"bins":["camsnap"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/camsnap","bins":["camsnap"],"label":"Install camsnap (brew)"}]}}
|
||||
---
|
||||
|
||||
# camsnap
|
||||
|
||||
Use `camsnap` to grab snapshots, clips, or motion events from configured cameras.
|
||||
|
||||
Setup
|
||||
- Config file: `~/.config/camsnap/config.yaml`
|
||||
- Add camera: `camsnap add --name kitchen --host 192.168.0.10 --user user --pass pass`
|
||||
|
||||
Common commands
|
||||
- Discover: `camsnap discover --info`
|
||||
- Snapshot: `camsnap snap kitchen --out shot.jpg`
|
||||
- Clip: `camsnap clip kitchen --dur 5s --out clip.mp4`
|
||||
- Motion watch: `camsnap watch kitchen --threshold 0.2 --action '...'`
|
||||
- Doctor: `camsnap doctor --probe`
|
||||
|
||||
Notes
|
||||
- Requires `ffmpeg` on PATH.
|
||||
- Prefer a short test capture before longer clips.
|
||||
95
.skills/cron/SKILL.md
Normal file
95
.skills/cron/SKILL.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: cron
|
||||
description: Create and manage scheduled tasks (cron jobs) that send you messages at specified times.
|
||||
---
|
||||
|
||||
# Cron Jobs
|
||||
|
||||
Schedule tasks that send you messages at specified times. Jobs are scheduled immediately when created.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
lettabot-cron list # List all jobs
|
||||
lettabot-cron create [options] # Create job
|
||||
lettabot-cron delete ID # Delete job
|
||||
lettabot-cron enable ID # Enable job
|
||||
lettabot-cron disable ID # Disable job
|
||||
```
|
||||
|
||||
## Create a Job
|
||||
|
||||
```bash
|
||||
lettabot-cron create \
|
||||
--name "Morning Briefing" \
|
||||
--schedule "0 8 * * *" \
|
||||
--message "Good morning! Review tasks for today." \
|
||||
--deliver telegram:123456789
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `-n, --name` - Job name (required)
|
||||
- `-s, --schedule` - Cron expression (required)
|
||||
- `-m, --message` - Message sent to you when job runs (required)
|
||||
- `-d, --deliver` - Where to send response (format: `channel:chatId`). **Defaults to last messaged chat.**
|
||||
- `--disabled` - Create disabled
|
||||
|
||||
## Message Format
|
||||
|
||||
When a cron job runs, you receive a message like:
|
||||
|
||||
```
|
||||
[cron:cron-123abc Morning Briefing] Good morning! Review tasks for today.
|
||||
Current time: 1/27/2026, 8:00:00 AM (America/Los_Angeles)
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- The message came from a cron job (not a user)
|
||||
- The job ID and name
|
||||
- The current time
|
||||
|
||||
## Cron Schedule Syntax
|
||||
|
||||
```
|
||||
┌───────── minute (0-59)
|
||||
│ ┌─────── hour (0-23)
|
||||
│ │ ┌───── day of month (1-31)
|
||||
│ │ │ ┌─── month (1-12)
|
||||
│ │ │ │ ┌─ day of week (0-6, Sun=0)
|
||||
* * * * *
|
||||
```
|
||||
|
||||
| Pattern | When |
|
||||
|---------|------|
|
||||
| `0 8 * * *` | Daily at 8:00 AM |
|
||||
| `0 9 * * 1-5` | Weekdays at 9:00 AM |
|
||||
| `0 */2 * * *` | Every 2 hours |
|
||||
| `30 17 * * 5` | Fridays at 5:30 PM |
|
||||
| `0 0 1 * *` | First of month at midnight |
|
||||
|
||||
## Examples
|
||||
|
||||
**Daily morning check-in (delivered to Telegram):**
|
||||
```bash
|
||||
lettabot-cron create \
|
||||
-n "Morning" \
|
||||
-s "0 8 * * *" \
|
||||
-m "Good morning! What's on today's agenda?" \
|
||||
-d telegram:123456789
|
||||
```
|
||||
|
||||
**Weekly review (delivered to Slack):**
|
||||
```bash
|
||||
lettabot-cron create \
|
||||
-n "Weekly Review" \
|
||||
-s "0 17 * * 5" \
|
||||
-m "Friday wrap-up: What did we accomplish?" \
|
||||
-d slack:C1234567890
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Jobs schedule immediately when created (no restart needed)
|
||||
- Use `lettabot-cron list` to see next run times and last run status
|
||||
- Jobs persist in `cron-jobs.json`
|
||||
- Logs written to `cron-log.jsonl`
|
||||
29
.skills/eightctl/SKILL.md
Normal file
29
.skills/eightctl/SKILL.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: eightctl
|
||||
description: Control Eight Sleep pods (status, temperature, alarms, schedules).
|
||||
homepage: https://eightctl.sh
|
||||
metadata: {"clawdbot":{"emoji":"🎛️","requires":{"bins":["eightctl"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/eightctl/cmd/eightctl@latest","bins":["eightctl"],"label":"Install eightctl (go)"}]}}
|
||||
---
|
||||
|
||||
# eightctl
|
||||
|
||||
Use `eightctl` for Eight Sleep pod control. Requires auth.
|
||||
|
||||
Auth
|
||||
- Config: `~/.config/eightctl/config.yaml`
|
||||
- Env: `EIGHTCTL_EMAIL`, `EIGHTCTL_PASSWORD`
|
||||
|
||||
Quick start
|
||||
- `eightctl status`
|
||||
- `eightctl on|off`
|
||||
- `eightctl temp 20`
|
||||
|
||||
Common tasks
|
||||
- Alarms: `eightctl alarm list|create|dismiss`
|
||||
- Schedules: `eightctl schedule list|create|update`
|
||||
- Audio: `eightctl audio state|play|pause`
|
||||
- Base: `eightctl base info|angle`
|
||||
|
||||
Notes
|
||||
- API is unofficial and rate-limited; avoid repeated logins.
|
||||
- Confirm before changing temperature or alarms.
|
||||
41
.skills/food-order/SKILL.md
Normal file
41
.skills/food-order/SKILL.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: food-order
|
||||
description: "Reorder Foodora orders + track ETA/status with ordercli. Never confirm without explicit user approval. Triggers: order food, reorder, track ETA."
|
||||
homepage: https://ordercli.sh
|
||||
metadata: {"clawdbot":{"emoji":"🥡","requires":{"bins":["ordercli"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/ordercli/cmd/ordercli@latest","bins":["ordercli"],"label":"Install ordercli (go)"}]}}
|
||||
---
|
||||
|
||||
# Food order (Foodora via ordercli)
|
||||
|
||||
Goal: reorder a previous Foodora order safely (preview first; confirm only on explicit user “yes/confirm/place the order”).
|
||||
|
||||
Hard safety rules
|
||||
- Never run `ordercli foodora reorder ... --confirm` unless user explicitly confirms placing the order.
|
||||
- Prefer preview-only steps first; show what will happen; ask for confirmation.
|
||||
- If user is unsure: stop at preview and ask questions.
|
||||
|
||||
Setup (once)
|
||||
- Country: `ordercli foodora countries` → `ordercli foodora config set --country AT`
|
||||
- Login (password): `ordercli foodora login --email you@example.com --password-stdin`
|
||||
- Login (no password, preferred): `ordercli foodora session chrome --url https://www.foodora.at/ --profile "Default"`
|
||||
|
||||
Find what to reorder
|
||||
- Recent list: `ordercli foodora history --limit 10`
|
||||
- Details: `ordercli foodora history show <orderCode>`
|
||||
- If needed (machine-readable): `ordercli foodora history show <orderCode> --json`
|
||||
|
||||
Preview reorder (no cart changes)
|
||||
- `ordercli foodora reorder <orderCode>`
|
||||
|
||||
Place reorder (cart change; explicit confirmation required)
|
||||
- Confirm first, then run: `ordercli foodora reorder <orderCode> --confirm`
|
||||
- Multiple addresses? Ask user for the right `--address-id` (take from their Foodora account / prior order data) and run:
|
||||
- `ordercli foodora reorder <orderCode> --confirm --address-id <id>`
|
||||
|
||||
Track the order
|
||||
- ETA/status (active list): `ordercli foodora orders`
|
||||
- Live updates: `ordercli foodora orders --watch`
|
||||
- Single order detail: `ordercli foodora order <orderCode>`
|
||||
|
||||
Debug / safe testing
|
||||
- Use a throwaway config: `ordercli --config /tmp/ordercli.json ...`
|
||||
23
.skills/gemini/SKILL.md
Normal file
23
.skills/gemini/SKILL.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: gemini
|
||||
description: Gemini CLI for one-shot Q&A, summaries, and generation.
|
||||
homepage: https://ai.google.dev/
|
||||
metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install":[{"id":"brew","kind":"brew","formula":"gemini-cli","bins":["gemini"],"label":"Install Gemini CLI (brew)"}]}}
|
||||
---
|
||||
|
||||
# Gemini CLI
|
||||
|
||||
Use Gemini in one-shot mode with a positional prompt (avoid interactive mode).
|
||||
|
||||
Quick start
|
||||
- `gemini "Answer this question..."`
|
||||
- `gemini --model <name> "Prompt..."`
|
||||
- `gemini --output-format json "Return JSON"`
|
||||
|
||||
Extensions
|
||||
- List: `gemini --list-extensions`
|
||||
- Manage: `gemini extensions <command>`
|
||||
|
||||
Notes
|
||||
- If auth is required, run `gemini` once interactively and follow the login flow.
|
||||
- Avoid `--yolo` for safety.
|
||||
47
.skills/gifgrep/SKILL.md
Normal file
47
.skills/gifgrep/SKILL.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: gifgrep
|
||||
description: Search GIF providers with CLI/TUI, download results, and extract stills/sheets.
|
||||
homepage: https://gifgrep.com
|
||||
metadata: {"clawdbot":{"emoji":"🧲","requires":{"bins":["gifgrep"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gifgrep","bins":["gifgrep"],"label":"Install gifgrep (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/gifgrep/cmd/gifgrep@latest","bins":["gifgrep"],"label":"Install gifgrep (go)"}]}}
|
||||
---
|
||||
|
||||
# gifgrep
|
||||
|
||||
Use `gifgrep` to search GIF providers (Tenor/Giphy), browse in a TUI, download results, and extract stills or sheets.
|
||||
|
||||
GIF-Grab (gifgrep workflow)
|
||||
- Search → preview → download → extract (still/sheet) for fast review and sharing.
|
||||
|
||||
Quick start
|
||||
- `gifgrep cats --max 5`
|
||||
- `gifgrep cats --format url | head -n 5`
|
||||
- `gifgrep search --json cats | jq '.[0].url'`
|
||||
- `gifgrep tui "office handshake"`
|
||||
- `gifgrep cats --download --max 1 --format url`
|
||||
|
||||
TUI + previews
|
||||
- TUI: `gifgrep tui "query"`
|
||||
- CLI still previews: `--thumbs` (Kitty/Ghostty only; still frame)
|
||||
|
||||
Download + reveal
|
||||
- `--download` saves to `~/Downloads`
|
||||
- `--reveal` shows the last download in Finder
|
||||
|
||||
Stills + sheets
|
||||
- `gifgrep still ./clip.gif --at 1.5s -o still.png`
|
||||
- `gifgrep sheet ./clip.gif --frames 9 --cols 3 -o sheet.png`
|
||||
- Sheets = single PNG grid of sampled frames (great for quick review, docs, PRs, chat).
|
||||
- Tune: `--frames` (count), `--cols` (grid width), `--padding` (spacing).
|
||||
|
||||
Providers
|
||||
- `--source auto|tenor|giphy`
|
||||
- `GIPHY_API_KEY` required for `--source giphy`
|
||||
- `TENOR_API_KEY` optional (Tenor demo key used if unset)
|
||||
|
||||
Output
|
||||
- `--json` prints an array of results (`id`, `title`, `url`, `preview_url`, `tags`, `width`, `height`)
|
||||
- `--format` for pipe-friendly fields (e.g., `url`)
|
||||
|
||||
Environment tweaks
|
||||
- `GIFGREP_SOFTWARE_ANIM=1` to force software animation
|
||||
- `GIFGREP_CELL_ASPECT=0.5` to tweak preview geometry
|
||||
48
.skills/github/SKILL.md
Normal file
48
.skills/github/SKILL.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: github
|
||||
description: "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries."
|
||||
metadata: {"clawdbot":{"emoji":"🐙","requires":{"bins":["gh"]},"install":[{"id":"brew","kind":"brew","formula":"gh","bins":["gh"],"label":"Install GitHub CLI (brew)"},{"id":"apt","kind":"apt","package":"gh","bins":["gh"],"label":"Install GitHub CLI (apt)"}]}}
|
||||
---
|
||||
|
||||
# GitHub Skill
|
||||
|
||||
Use the `gh` CLI to interact with GitHub. Always specify `--repo owner/repo` when not in a git directory, or use URLs directly.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
Check CI status on a PR:
|
||||
```bash
|
||||
gh pr checks 55 --repo owner/repo
|
||||
```
|
||||
|
||||
List recent workflow runs:
|
||||
```bash
|
||||
gh run list --repo owner/repo --limit 10
|
||||
```
|
||||
|
||||
View a run and see which steps failed:
|
||||
```bash
|
||||
gh run view <run-id> --repo owner/repo
|
||||
```
|
||||
|
||||
View logs for failed steps only:
|
||||
```bash
|
||||
gh run view <run-id> --repo owner/repo --log-failed
|
||||
```
|
||||
|
||||
## API for Advanced Queries
|
||||
|
||||
The `gh api` command is useful for accessing data not available through other subcommands.
|
||||
|
||||
Get PR with specific fields:
|
||||
```bash
|
||||
gh api repos/owner/repo/pulls/55 --jq '.title, .state, .user.login'
|
||||
```
|
||||
|
||||
## JSON Output
|
||||
|
||||
Most commands support `--json` for structured output. You can use `--jq` to filter:
|
||||
|
||||
```bash
|
||||
gh issue list --repo owner/repo --json number,title --jq '.[] | "\(.number): \(.title)"'
|
||||
```
|
||||
92
.skills/gog/SKILL.md
Normal file
92
.skills/gog/SKILL.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: gog
|
||||
description: Google Workspace CLI for Gmail, Calendar, Drive, Contacts, Sheets, and Docs.
|
||||
homepage: https://gogcli.sh
|
||||
metadata: {"clawdbot":{"emoji":"🎮","requires":{"bins":["gog"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gogcli","bins":["gog"],"label":"Install gog (brew)"}]}}
|
||||
---
|
||||
|
||||
# gog
|
||||
|
||||
Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
|
||||
|
||||
Setup (once)
|
||||
- `gog auth credentials /path/to/client_secret.json`
|
||||
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets`
|
||||
- `gog auth list`
|
||||
|
||||
Common commands
|
||||
- Gmail search: `gog gmail search 'newer_than:7d' --max 10`
|
||||
- Gmail messages search (per email, ignores threading): `gog gmail messages search "in:inbox from:ryanair.com" --max 20 --account you@example.com`
|
||||
- Gmail send (plain): `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
|
||||
- Gmail send (multi-line): `gog gmail send --to a@b.com --subject "Hi" --body-file ./message.txt`
|
||||
- Gmail send (stdin): `gog gmail send --to a@b.com --subject "Hi" --body-file -`
|
||||
- Gmail send (HTML): `gog gmail send --to a@b.com --subject "Hi" --body-html "<p>Hello</p>"`
|
||||
- Gmail draft: `gog gmail drafts create --to a@b.com --subject "Hi" --body-file ./message.txt`
|
||||
- Gmail send draft: `gog gmail drafts send <draftId>`
|
||||
- Gmail reply: `gog gmail send --to a@b.com --subject "Re: Hi" --body "Reply" --reply-to-message-id <msgId>`
|
||||
- Calendar list events: `gog calendar events <calendarId> --from <iso> --to <iso>`
|
||||
- Calendar create event: `gog calendar create <calendarId> --summary "Title" --from <iso> --to <iso>`
|
||||
- Calendar create with color: `gog calendar create <calendarId> --summary "Title" --from <iso> --to <iso> --event-color 7`
|
||||
- Calendar update event: `gog calendar update <calendarId> <eventId> --summary "New Title" --event-color 4`
|
||||
- Calendar show colors: `gog calendar colors`
|
||||
- Drive search: `gog drive search "query" --max 10`
|
||||
- Contacts: `gog contacts list --max 20`
|
||||
- Sheets get: `gog sheets get <sheetId> "Tab!A1:D10" --json`
|
||||
- Sheets update: `gog sheets update <sheetId> "Tab!A1:B2" --values-json '[["A","B"],["1","2"]]' --input USER_ENTERED`
|
||||
- Sheets append: `gog sheets append <sheetId> "Tab!A:C" --values-json '[["x","y","z"]]' --insert INSERT_ROWS`
|
||||
- Sheets clear: `gog sheets clear <sheetId> "Tab!A2:Z"`
|
||||
- Sheets metadata: `gog sheets metadata <sheetId> --json`
|
||||
- Docs export: `gog docs export <docId> --format txt --out /tmp/doc.txt`
|
||||
- Docs cat: `gog docs cat <docId>`
|
||||
|
||||
Calendar Colors
|
||||
- Use `gog calendar colors` to see all available event colors (IDs 1-11)
|
||||
- Add colors to events with `--event-color <id>` flag
|
||||
- Event color IDs (from `gog calendar colors` output):
|
||||
- 1: #a4bdfc
|
||||
- 2: #7ae7bf
|
||||
- 3: #dbadff
|
||||
- 4: #ff887c
|
||||
- 5: #fbd75b
|
||||
- 6: #ffb878
|
||||
- 7: #46d6db
|
||||
- 8: #e1e1e1
|
||||
- 9: #5484ed
|
||||
- 10: #51b749
|
||||
- 11: #dc2127
|
||||
|
||||
Email Formatting
|
||||
- Prefer plain text. Use `--body-file` for multi-paragraph messages (or `--body-file -` for stdin).
|
||||
- Same `--body-file` pattern works for drafts and replies.
|
||||
- `--body` does not unescape `\n`. If you need inline newlines, use a heredoc or `$'Line 1\n\nLine 2'`.
|
||||
- Use `--body-html` only when you need rich formatting.
|
||||
- HTML tags: `<p>` for paragraphs, `<br>` for line breaks, `<strong>` for bold, `<em>` for italic, `<a href="url">` for links, `<ul>`/`<li>` for lists.
|
||||
- Example (plain text via stdin):
|
||||
```bash
|
||||
gog gmail send --to recipient@example.com \
|
||||
--subject "Meeting Follow-up" \
|
||||
--body-file - <<'EOF'
|
||||
Hi Name,
|
||||
|
||||
Thanks for meeting today. Next steps:
|
||||
- Item one
|
||||
- Item two
|
||||
|
||||
Best regards,
|
||||
Your Name
|
||||
EOF
|
||||
```
|
||||
- Example (HTML list):
|
||||
```bash
|
||||
gog gmail send --to recipient@example.com \
|
||||
--subject "Meeting Follow-up" \
|
||||
--body-html "<p>Hi Name,</p><p>Thanks for meeting today. Here are the next steps:</p><ul><li>Item one</li><li>Item two</li></ul><p>Best regards,<br>Your Name</p>"
|
||||
```
|
||||
|
||||
Notes
|
||||
- Set `GOG_ACCOUNT=you@gmail.com` to avoid repeating `--account`.
|
||||
- For scripting, prefer `--json` plus `--no-input`.
|
||||
- Sheets values can be passed via `--values-json` (recommended) or as inline rows.
|
||||
- Docs supports export/cat/copy. In-place edits require a Docs API client (not in gog).
|
||||
- Confirm before sending mail or creating events.
|
||||
- `gog gmail search` returns one row per thread; use `gog gmail messages search` when you need every individual email returned separately.
|
||||
128
.skills/google/SKILL.md
Normal file
128
.skills/google/SKILL.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
name: google
|
||||
description: Google Workspace CLI (gog) for Gmail, Calendar, Drive, Contacts, Sheets, and Docs.
|
||||
---
|
||||
|
||||
# Google Workspace (gog)
|
||||
|
||||
Use `gog` CLI to interact with Google Workspace services.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
brew install steipete/tap/gogcli
|
||||
gog auth credentials /path/to/credentials.json
|
||||
gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets
|
||||
gog auth list
|
||||
```
|
||||
|
||||
## Gmail
|
||||
|
||||
```bash
|
||||
# Search emails
|
||||
gog gmail search 'newer_than:1h is:unread' --account EMAIL --max 10
|
||||
gog gmail search 'from:someone@example.com' --account EMAIL --max 10
|
||||
|
||||
# Read email
|
||||
gog gmail get MESSAGE_ID --account EMAIL
|
||||
|
||||
# Send email
|
||||
gog gmail send --to recipient@example.com --subject "Subject" --body "Message" --account EMAIL
|
||||
|
||||
# Reply to thread
|
||||
gog gmail send --to recipient@example.com --subject "Re: Original" --body "Reply" --reply-to-message-id MSG_ID --account EMAIL
|
||||
|
||||
# Create/send draft
|
||||
gog gmail drafts create --to recipient@example.com --subject "Subject" --body "Draft" --account EMAIL
|
||||
gog gmail drafts send DRAFT_ID --account EMAIL
|
||||
|
||||
# Manage labels
|
||||
gog gmail labels --account EMAIL
|
||||
gog gmail modify MESSAGE_ID --add-labels LABEL --account EMAIL
|
||||
gog gmail modify MESSAGE_ID --remove-labels UNREAD --account EMAIL
|
||||
```
|
||||
|
||||
## Calendar
|
||||
|
||||
```bash
|
||||
# List events
|
||||
gog calendar events CALENDAR_ID --from 2026-01-27T00:00:00Z --to 2026-01-28T00:00:00Z --account EMAIL
|
||||
|
||||
# Create event
|
||||
gog calendar create CALENDAR_ID --summary "Meeting" --from 2026-01-27T10:00:00Z --to 2026-01-27T11:00:00Z --account EMAIL
|
||||
|
||||
# Create with color (1-11)
|
||||
gog calendar create CALENDAR_ID --summary "Meeting" --from ISO --to ISO --event-color 7 --account EMAIL
|
||||
|
||||
# Update event
|
||||
gog calendar update CALENDAR_ID EVENT_ID --summary "New Title" --account EMAIL
|
||||
|
||||
# Show available colors
|
||||
gog calendar colors
|
||||
```
|
||||
|
||||
## Drive
|
||||
|
||||
```bash
|
||||
# Search files
|
||||
gog drive search "query" --max 10 --account EMAIL
|
||||
|
||||
# List files in folder
|
||||
gog drive list FOLDER_ID --account EMAIL
|
||||
|
||||
# Download file
|
||||
gog drive download FILE_ID --out /path/to/file --account EMAIL
|
||||
|
||||
# Upload file
|
||||
gog drive upload /path/to/file --parent FOLDER_ID --account EMAIL
|
||||
```
|
||||
|
||||
## Contacts
|
||||
|
||||
```bash
|
||||
# List contacts
|
||||
gog contacts list --max 20 --account EMAIL
|
||||
|
||||
# Search contacts
|
||||
gog contacts search "name" --account EMAIL
|
||||
```
|
||||
|
||||
## Sheets
|
||||
|
||||
```bash
|
||||
# Read range
|
||||
gog sheets get SHEET_ID "Sheet1!A1:D10" --json --account EMAIL
|
||||
|
||||
# Update cells
|
||||
gog sheets update SHEET_ID "Sheet1!A1:B2" --values-json '[["A","B"],["1","2"]]' --input USER_ENTERED --account EMAIL
|
||||
|
||||
# Append rows
|
||||
gog sheets append SHEET_ID "Sheet1!A:C" --values-json '[["x","y","z"]]' --insert INSERT_ROWS --account EMAIL
|
||||
|
||||
# Clear range
|
||||
gog sheets clear SHEET_ID "Sheet1!A2:Z" --account EMAIL
|
||||
|
||||
# Get metadata
|
||||
gog sheets metadata SHEET_ID --json --account EMAIL
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
```bash
|
||||
# Read document
|
||||
gog docs cat DOC_ID --account EMAIL
|
||||
|
||||
# Export to file
|
||||
gog docs export DOC_ID --format txt --out /tmp/doc.txt --account EMAIL
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Set default account in `.env`:
|
||||
```bash
|
||||
GMAIL_ACCOUNT=you@gmail.com
|
||||
```
|
||||
|
||||
## Email Polling
|
||||
|
||||
Emails are polled every 1 minute via cron. Use `ignore()` if nothing important.
|
||||
30
.skills/goplaces/SKILL.md
Normal file
30
.skills/goplaces/SKILL.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: goplaces
|
||||
description: Query Google Places API (New) via the goplaces CLI for text search, place details, resolve, and reviews. Use for human-friendly place lookup or JSON output for scripts.
|
||||
homepage: https://github.com/steipete/goplaces
|
||||
metadata: {"clawdbot":{"emoji":"📍","requires":{"bins":["goplaces"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY","install":[{"id":"brew","kind":"brew","formula":"steipete/tap/goplaces","bins":["goplaces"],"label":"Install goplaces (brew)"}]}}
|
||||
---
|
||||
|
||||
# goplaces
|
||||
|
||||
Modern Google Places API (New) CLI. Human output by default, `--json` for scripts.
|
||||
|
||||
Install
|
||||
- Homebrew: `brew install steipete/tap/goplaces`
|
||||
|
||||
Config
|
||||
- `GOOGLE_PLACES_API_KEY` required.
|
||||
- Optional: `GOOGLE_PLACES_BASE_URL` for testing/proxying.
|
||||
|
||||
Common commands
|
||||
- Search: `goplaces search "coffee" --open-now --min-rating 4 --limit 5`
|
||||
- Bias: `goplaces search "pizza" --lat 40.8 --lng -73.9 --radius-m 3000`
|
||||
- Pagination: `goplaces search "pizza" --page-token "NEXT_PAGE_TOKEN"`
|
||||
- Resolve: `goplaces resolve "Soho, London" --limit 5`
|
||||
- Details: `goplaces details <place_id> --reviews`
|
||||
- JSON: `goplaces search "sushi" --json`
|
||||
|
||||
Notes
|
||||
- `--no-color` or `NO_COLOR` disables ANSI color.
|
||||
- Price levels: 0..4 (free → very expensive).
|
||||
- Type filter sends only the first `--type` value (API accepts one).
|
||||
217
.skills/himalaya/SKILL.md
Normal file
217
.skills/himalaya/SKILL.md
Normal file
@@ -0,0 +1,217 @@
|
||||
---
|
||||
name: himalaya
|
||||
description: "CLI to manage emails via IMAP/SMTP. Use `himalaya` to list, read, write, reply, forward, search, and organize emails from the terminal. Supports multiple accounts and message composition with MML (MIME Meta Language)."
|
||||
homepage: https://github.com/pimalaya/himalaya
|
||||
metadata: {"clawdbot":{"emoji":"📧","requires":{"bins":["himalaya"]},"install":[{"id":"brew","kind":"brew","formula":"himalaya","bins":["himalaya"],"label":"Install Himalaya (brew)"}]}}
|
||||
---
|
||||
|
||||
# Himalaya Email CLI
|
||||
|
||||
Himalaya is a CLI email client that lets you manage emails from the terminal using IMAP, SMTP, Notmuch, or Sendmail backends.
|
||||
|
||||
## References
|
||||
|
||||
- `references/configuration.md` (config file setup + IMAP/SMTP authentication)
|
||||
- `references/message-composition.md` (MML syntax for composing emails)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Himalaya CLI installed (`himalaya --version` to verify)
|
||||
2. A configuration file at `~/.config/himalaya/config.toml`
|
||||
3. IMAP/SMTP credentials configured (password stored securely)
|
||||
|
||||
## Configuration Setup
|
||||
|
||||
Run the interactive wizard to set up an account:
|
||||
```bash
|
||||
himalaya account configure
|
||||
```
|
||||
|
||||
Or create `~/.config/himalaya/config.toml` manually:
|
||||
```toml
|
||||
[accounts.personal]
|
||||
email = "you@example.com"
|
||||
display-name = "Your Name"
|
||||
default = true
|
||||
|
||||
backend.type = "imap"
|
||||
backend.host = "imap.example.com"
|
||||
backend.port = 993
|
||||
backend.encryption.type = "tls"
|
||||
backend.login = "you@example.com"
|
||||
backend.auth.type = "password"
|
||||
backend.auth.cmd = "pass show email/imap" # or use keyring
|
||||
|
||||
message.send.backend.type = "smtp"
|
||||
message.send.backend.host = "smtp.example.com"
|
||||
message.send.backend.port = 587
|
||||
message.send.backend.encryption.type = "start-tls"
|
||||
message.send.backend.login = "you@example.com"
|
||||
message.send.backend.auth.type = "password"
|
||||
message.send.backend.auth.cmd = "pass show email/smtp"
|
||||
```
|
||||
|
||||
## Common Operations
|
||||
|
||||
### List Folders
|
||||
|
||||
```bash
|
||||
himalaya folder list
|
||||
```
|
||||
|
||||
### List Emails
|
||||
|
||||
List emails in INBOX (default):
|
||||
```bash
|
||||
himalaya envelope list
|
||||
```
|
||||
|
||||
List emails in a specific folder:
|
||||
```bash
|
||||
himalaya envelope list --folder "Sent"
|
||||
```
|
||||
|
||||
List with pagination:
|
||||
```bash
|
||||
himalaya envelope list --page 1 --page-size 20
|
||||
```
|
||||
|
||||
### Search Emails
|
||||
|
||||
```bash
|
||||
himalaya envelope list from john@example.com subject meeting
|
||||
```
|
||||
|
||||
### Read an Email
|
||||
|
||||
Read email by ID (shows plain text):
|
||||
```bash
|
||||
himalaya message read 42
|
||||
```
|
||||
|
||||
Export raw MIME:
|
||||
```bash
|
||||
himalaya message export 42 --full
|
||||
```
|
||||
|
||||
### Reply to an Email
|
||||
|
||||
Interactive reply (opens $EDITOR):
|
||||
```bash
|
||||
himalaya message reply 42
|
||||
```
|
||||
|
||||
Reply-all:
|
||||
```bash
|
||||
himalaya message reply 42 --all
|
||||
```
|
||||
|
||||
### Forward an Email
|
||||
|
||||
```bash
|
||||
himalaya message forward 42
|
||||
```
|
||||
|
||||
### Write a New Email
|
||||
|
||||
Interactive compose (opens $EDITOR):
|
||||
```bash
|
||||
himalaya message write
|
||||
```
|
||||
|
||||
Send directly using template:
|
||||
```bash
|
||||
cat << 'EOF' | himalaya template send
|
||||
From: you@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test Message
|
||||
|
||||
Hello from Himalaya!
|
||||
EOF
|
||||
```
|
||||
|
||||
Or with headers flag:
|
||||
```bash
|
||||
himalaya message write -H "To:recipient@example.com" -H "Subject:Test" "Message body here"
|
||||
```
|
||||
|
||||
### Move/Copy Emails
|
||||
|
||||
Move to folder:
|
||||
```bash
|
||||
himalaya message move 42 "Archive"
|
||||
```
|
||||
|
||||
Copy to folder:
|
||||
```bash
|
||||
himalaya message copy 42 "Important"
|
||||
```
|
||||
|
||||
### Delete an Email
|
||||
|
||||
```bash
|
||||
himalaya message delete 42
|
||||
```
|
||||
|
||||
### Manage Flags
|
||||
|
||||
Add flag:
|
||||
```bash
|
||||
himalaya flag add 42 --flag seen
|
||||
```
|
||||
|
||||
Remove flag:
|
||||
```bash
|
||||
himalaya flag remove 42 --flag seen
|
||||
```
|
||||
|
||||
## Multiple Accounts
|
||||
|
||||
List accounts:
|
||||
```bash
|
||||
himalaya account list
|
||||
```
|
||||
|
||||
Use a specific account:
|
||||
```bash
|
||||
himalaya --account work envelope list
|
||||
```
|
||||
|
||||
## Attachments
|
||||
|
||||
Save attachments from a message:
|
||||
```bash
|
||||
himalaya attachment download 42
|
||||
```
|
||||
|
||||
Save to specific directory:
|
||||
```bash
|
||||
himalaya attachment download 42 --dir ~/Downloads
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
Most commands support `--output` for structured output:
|
||||
```bash
|
||||
himalaya envelope list --output json
|
||||
himalaya envelope list --output plain
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
Enable debug logging:
|
||||
```bash
|
||||
RUST_LOG=debug himalaya envelope list
|
||||
```
|
||||
|
||||
Full trace with backtrace:
|
||||
```bash
|
||||
RUST_LOG=trace RUST_BACKTRACE=1 himalaya envelope list
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `himalaya --help` or `himalaya <command> --help` for detailed usage.
|
||||
- Message IDs are relative to the current folder; re-list after folder changes.
|
||||
- For composing rich emails with attachments, use MML syntax (see `references/message-composition.md`).
|
||||
- Store passwords securely using `pass`, system keyring, or a command that outputs the password.
|
||||
174
.skills/himalaya/references/configuration.md
Normal file
174
.skills/himalaya/references/configuration.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Himalaya Configuration Reference
|
||||
|
||||
Configuration file location: `~/.config/himalaya/config.toml`
|
||||
|
||||
## Minimal IMAP + SMTP Setup
|
||||
|
||||
```toml
|
||||
[accounts.default]
|
||||
email = "user@example.com"
|
||||
display-name = "Your Name"
|
||||
default = true
|
||||
|
||||
# IMAP backend for reading emails
|
||||
backend.type = "imap"
|
||||
backend.host = "imap.example.com"
|
||||
backend.port = 993
|
||||
backend.encryption.type = "tls"
|
||||
backend.login = "user@example.com"
|
||||
backend.auth.type = "password"
|
||||
backend.auth.raw = "your-password"
|
||||
|
||||
# SMTP backend for sending emails
|
||||
message.send.backend.type = "smtp"
|
||||
message.send.backend.host = "smtp.example.com"
|
||||
message.send.backend.port = 587
|
||||
message.send.backend.encryption.type = "start-tls"
|
||||
message.send.backend.login = "user@example.com"
|
||||
message.send.backend.auth.type = "password"
|
||||
message.send.backend.auth.raw = "your-password"
|
||||
```
|
||||
|
||||
## Password Options
|
||||
|
||||
### Raw password (testing only, not recommended)
|
||||
```toml
|
||||
backend.auth.raw = "your-password"
|
||||
```
|
||||
|
||||
### Password from command (recommended)
|
||||
```toml
|
||||
backend.auth.cmd = "pass show email/imap"
|
||||
# backend.auth.cmd = "security find-generic-password -a user@example.com -s imap -w"
|
||||
```
|
||||
|
||||
### System keyring (requires keyring feature)
|
||||
```toml
|
||||
backend.auth.keyring = "imap-example"
|
||||
```
|
||||
Then run `himalaya account configure <account>` to store the password.
|
||||
|
||||
## Gmail Configuration
|
||||
|
||||
```toml
|
||||
[accounts.gmail]
|
||||
email = "you@gmail.com"
|
||||
display-name = "Your Name"
|
||||
default = true
|
||||
|
||||
backend.type = "imap"
|
||||
backend.host = "imap.gmail.com"
|
||||
backend.port = 993
|
||||
backend.encryption.type = "tls"
|
||||
backend.login = "you@gmail.com"
|
||||
backend.auth.type = "password"
|
||||
backend.auth.cmd = "pass show google/app-password"
|
||||
|
||||
message.send.backend.type = "smtp"
|
||||
message.send.backend.host = "smtp.gmail.com"
|
||||
message.send.backend.port = 587
|
||||
message.send.backend.encryption.type = "start-tls"
|
||||
message.send.backend.login = "you@gmail.com"
|
||||
message.send.backend.auth.type = "password"
|
||||
message.send.backend.auth.cmd = "pass show google/app-password"
|
||||
```
|
||||
|
||||
**Note:** Gmail requires an App Password if 2FA is enabled.
|
||||
|
||||
## iCloud Configuration
|
||||
|
||||
```toml
|
||||
[accounts.icloud]
|
||||
email = "you@icloud.com"
|
||||
display-name = "Your Name"
|
||||
|
||||
backend.type = "imap"
|
||||
backend.host = "imap.mail.me.com"
|
||||
backend.port = 993
|
||||
backend.encryption.type = "tls"
|
||||
backend.login = "you@icloud.com"
|
||||
backend.auth.type = "password"
|
||||
backend.auth.cmd = "pass show icloud/app-password"
|
||||
|
||||
message.send.backend.type = "smtp"
|
||||
message.send.backend.host = "smtp.mail.me.com"
|
||||
message.send.backend.port = 587
|
||||
message.send.backend.encryption.type = "start-tls"
|
||||
message.send.backend.login = "you@icloud.com"
|
||||
message.send.backend.auth.type = "password"
|
||||
message.send.backend.auth.cmd = "pass show icloud/app-password"
|
||||
```
|
||||
|
||||
**Note:** Generate an app-specific password at appleid.apple.com
|
||||
|
||||
## Folder Aliases
|
||||
|
||||
Map custom folder names:
|
||||
```toml
|
||||
[accounts.default.folder.alias]
|
||||
inbox = "INBOX"
|
||||
sent = "Sent"
|
||||
drafts = "Drafts"
|
||||
trash = "Trash"
|
||||
```
|
||||
|
||||
## Multiple Accounts
|
||||
|
||||
```toml
|
||||
[accounts.personal]
|
||||
email = "personal@example.com"
|
||||
default = true
|
||||
# ... backend config ...
|
||||
|
||||
[accounts.work]
|
||||
email = "work@company.com"
|
||||
# ... backend config ...
|
||||
```
|
||||
|
||||
Switch accounts with `--account`:
|
||||
```bash
|
||||
himalaya --account work envelope list
|
||||
```
|
||||
|
||||
## Notmuch Backend (local mail)
|
||||
|
||||
```toml
|
||||
[accounts.local]
|
||||
email = "user@example.com"
|
||||
|
||||
backend.type = "notmuch"
|
||||
backend.db-path = "~/.mail/.notmuch"
|
||||
```
|
||||
|
||||
## OAuth2 Authentication (for providers that support it)
|
||||
|
||||
```toml
|
||||
backend.auth.type = "oauth2"
|
||||
backend.auth.client-id = "your-client-id"
|
||||
backend.auth.client-secret.cmd = "pass show oauth/client-secret"
|
||||
backend.auth.access-token.cmd = "pass show oauth/access-token"
|
||||
backend.auth.refresh-token.cmd = "pass show oauth/refresh-token"
|
||||
backend.auth.auth-url = "https://provider.com/oauth/authorize"
|
||||
backend.auth.token-url = "https://provider.com/oauth/token"
|
||||
```
|
||||
|
||||
## Additional Options
|
||||
|
||||
### Signature
|
||||
```toml
|
||||
[accounts.default]
|
||||
signature = "Best regards,\nYour Name"
|
||||
signature-delim = "-- \n"
|
||||
```
|
||||
|
||||
### Downloads directory
|
||||
```toml
|
||||
[accounts.default]
|
||||
downloads-dir = "~/Downloads/himalaya"
|
||||
```
|
||||
|
||||
### Editor for composing
|
||||
Set via environment variable:
|
||||
```bash
|
||||
export EDITOR="vim"
|
||||
```
|
||||
182
.skills/himalaya/references/message-composition.md
Normal file
182
.skills/himalaya/references/message-composition.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Message Composition with MML (MIME Meta Language)
|
||||
|
||||
Himalaya uses MML for composing emails. MML is a simple XML-based syntax that compiles to MIME messages.
|
||||
|
||||
## Basic Message Structure
|
||||
|
||||
An email message is a list of **headers** followed by a **body**, separated by a blank line:
|
||||
|
||||
```
|
||||
From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Hello World
|
||||
|
||||
This is the message body.
|
||||
```
|
||||
|
||||
## Headers
|
||||
|
||||
Common headers:
|
||||
- `From`: Sender address
|
||||
- `To`: Primary recipient(s)
|
||||
- `Cc`: Carbon copy recipients
|
||||
- `Bcc`: Blind carbon copy recipients
|
||||
- `Subject`: Message subject
|
||||
- `Reply-To`: Address for replies (if different from From)
|
||||
- `In-Reply-To`: Message ID being replied to
|
||||
|
||||
### Address Formats
|
||||
|
||||
```
|
||||
To: user@example.com
|
||||
To: John Doe <john@example.com>
|
||||
To: "John Doe" <john@example.com>
|
||||
To: user1@example.com, user2@example.com, "Jane" <jane@example.com>
|
||||
```
|
||||
|
||||
## Plain Text Body
|
||||
|
||||
Simple plain text email:
|
||||
```
|
||||
From: alice@localhost
|
||||
To: bob@localhost
|
||||
Subject: Plain Text Example
|
||||
|
||||
Hello, this is a plain text email.
|
||||
No special formatting needed.
|
||||
|
||||
Best,
|
||||
Alice
|
||||
```
|
||||
|
||||
## MML for Rich Emails
|
||||
|
||||
### Multipart Messages
|
||||
|
||||
Alternative text/html parts:
|
||||
```
|
||||
From: alice@localhost
|
||||
To: bob@localhost
|
||||
Subject: Multipart Example
|
||||
|
||||
<#multipart type=alternative>
|
||||
This is the plain text version.
|
||||
<#part type=text/html>
|
||||
<html><body><h1>This is the HTML version</h1></body></html>
|
||||
<#/multipart>
|
||||
```
|
||||
|
||||
### Attachments
|
||||
|
||||
Attach a file:
|
||||
```
|
||||
From: alice@localhost
|
||||
To: bob@localhost
|
||||
Subject: With Attachment
|
||||
|
||||
Here is the document you requested.
|
||||
|
||||
<#part filename=/path/to/document.pdf><#/part>
|
||||
```
|
||||
|
||||
Attachment with custom name:
|
||||
```
|
||||
<#part filename=/path/to/file.pdf name=report.pdf><#/part>
|
||||
```
|
||||
|
||||
Multiple attachments:
|
||||
```
|
||||
<#part filename=/path/to/doc1.pdf><#/part>
|
||||
<#part filename=/path/to/doc2.pdf><#/part>
|
||||
```
|
||||
|
||||
### Inline Images
|
||||
|
||||
Embed an image inline:
|
||||
```
|
||||
From: alice@localhost
|
||||
To: bob@localhost
|
||||
Subject: Inline Image
|
||||
|
||||
<#multipart type=related>
|
||||
<#part type=text/html>
|
||||
<html><body>
|
||||
<p>Check out this image:</p>
|
||||
<img src="cid:image1">
|
||||
</body></html>
|
||||
<#part disposition=inline id=image1 filename=/path/to/image.png><#/part>
|
||||
<#/multipart>
|
||||
```
|
||||
|
||||
### Mixed Content (Text + Attachments)
|
||||
|
||||
```
|
||||
From: alice@localhost
|
||||
To: bob@localhost
|
||||
Subject: Mixed Content
|
||||
|
||||
<#multipart type=mixed>
|
||||
<#part type=text/plain>
|
||||
Please find the attached files.
|
||||
|
||||
Best,
|
||||
Alice
|
||||
<#part filename=/path/to/file1.pdf><#/part>
|
||||
<#part filename=/path/to/file2.zip><#/part>
|
||||
<#/multipart>
|
||||
```
|
||||
|
||||
## MML Tag Reference
|
||||
|
||||
### `<#multipart>`
|
||||
Groups multiple parts together.
|
||||
- `type=alternative`: Different representations of same content
|
||||
- `type=mixed`: Independent parts (text + attachments)
|
||||
- `type=related`: Parts that reference each other (HTML + images)
|
||||
|
||||
### `<#part>`
|
||||
Defines a message part.
|
||||
- `type=<mime-type>`: Content type (e.g., `text/html`, `application/pdf`)
|
||||
- `filename=<path>`: File to attach
|
||||
- `name=<name>`: Display name for attachment
|
||||
- `disposition=inline`: Display inline instead of as attachment
|
||||
- `id=<cid>`: Content ID for referencing in HTML
|
||||
|
||||
## Composing from CLI
|
||||
|
||||
### Interactive compose
|
||||
Opens your `$EDITOR`:
|
||||
```bash
|
||||
himalaya message write
|
||||
```
|
||||
|
||||
### Reply (opens editor with quoted message)
|
||||
```bash
|
||||
himalaya message reply 42
|
||||
himalaya message reply 42 --all # reply-all
|
||||
```
|
||||
|
||||
### Forward
|
||||
```bash
|
||||
himalaya message forward 42
|
||||
```
|
||||
|
||||
### Send from stdin
|
||||
```bash
|
||||
cat message.txt | himalaya template send
|
||||
```
|
||||
|
||||
### Prefill headers from CLI
|
||||
```bash
|
||||
himalaya message write \
|
||||
-H "To:recipient@example.com" \
|
||||
-H "Subject:Quick Message" \
|
||||
"Message body here"
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- The editor opens with a template; fill in headers and body.
|
||||
- Save and exit the editor to send; exit without saving to cancel.
|
||||
- MML parts are compiled to proper MIME when sending.
|
||||
- Use `himalaya message export --full` to inspect the raw MIME structure of received emails.
|
||||
25
.skills/imsg/SKILL.md
Normal file
25
.skills/imsg/SKILL.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: imsg
|
||||
description: iMessage/SMS CLI for listing chats, history, watch, and sending.
|
||||
homepage: https://imsg.to
|
||||
metadata: {"clawdbot":{"emoji":"📨","os":["darwin"],"requires":{"bins":["imsg"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/imsg","bins":["imsg"],"label":"Install imsg (brew)"}]}}
|
||||
---
|
||||
|
||||
# imsg
|
||||
|
||||
Use `imsg` to read and send Messages.app iMessage/SMS on macOS.
|
||||
|
||||
Requirements
|
||||
- Messages.app signed in
|
||||
- Full Disk Access for your terminal
|
||||
- Automation permission to control Messages.app (for sending)
|
||||
|
||||
Common commands
|
||||
- List chats: `imsg chats --limit 10 --json`
|
||||
- History: `imsg history --chat-id 1 --limit 20 --attachments --json`
|
||||
- Watch: `imsg watch --chat-id 1 --attachments`
|
||||
- Send: `imsg send --to "+14155551212" --text "hi" --file /path/pic.jpg`
|
||||
|
||||
Notes
|
||||
- `--service imessage|sms|auto` controls delivery.
|
||||
- Confirm recipient + message before sending.
|
||||
101
.skills/local-places/SERVER_README.md
Normal file
101
.skills/local-places/SERVER_README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Local Places
|
||||
|
||||
This repo is a fusion of two pieces:
|
||||
|
||||
- A FastAPI server that exposes endpoints for searching and resolving places via the Google Maps Places API.
|
||||
- A companion agent skill that explains how to use the API and can call it to find places efficiently.
|
||||
|
||||
Together, the skill and server let an agent turn natural-language place queries into structured results quickly.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
# copy skill definition into the relevant folder (where the agent looks for it)
|
||||
# then run the server
|
||||
|
||||
uv venv
|
||||
uv pip install -e ".[dev]"
|
||||
uv run --env-file .env uvicorn local_places.main:app --host 0.0.0.0 --reload
|
||||
```
|
||||
|
||||
Open the API docs at http://127.0.0.1:8000/docs.
|
||||
|
||||
## Places API
|
||||
|
||||
Set the Google Places API key before running:
|
||||
|
||||
```bash
|
||||
export GOOGLE_PLACES_API_KEY="your-key"
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `POST /places/search` (free-text query + filters)
|
||||
- `GET /places/{place_id}` (place details)
|
||||
- `POST /locations/resolve` (resolve a user-provided location string)
|
||||
|
||||
Example search request:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "italian restaurant",
|
||||
"filters": {
|
||||
"types": ["restaurant"],
|
||||
"open_now": true,
|
||||
"min_rating": 4.0,
|
||||
"price_levels": [1, 2]
|
||||
},
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `filters.types` supports a single type (mapped to Google `includedType`).
|
||||
|
||||
Example search request (curl):
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/places/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "italian restaurant",
|
||||
"location_bias": {
|
||||
"lat": 40.8065,
|
||||
"lng": -73.9719,
|
||||
"radius_m": 3000
|
||||
},
|
||||
"filters": {
|
||||
"types": ["restaurant"],
|
||||
"open_now": true,
|
||||
"min_rating": 4.0,
|
||||
"price_levels": [1, 2, 3]
|
||||
},
|
||||
"limit": 10
|
||||
}'
|
||||
```
|
||||
|
||||
Example resolve request (curl):
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/locations/resolve \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"location_text": "Riverside Park, New York",
|
||||
"limit": 5
|
||||
}'
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## OpenAPI
|
||||
|
||||
Generate the OpenAPI schema:
|
||||
|
||||
```bash
|
||||
uv run python scripts/generate_openapi.py
|
||||
```
|
||||
91
.skills/local-places/SKILL.md
Normal file
91
.skills/local-places/SKILL.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: local-places
|
||||
description: Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
|
||||
homepage: https://github.com/Hyaxia/local_places
|
||||
metadata: {"clawdbot":{"emoji":"📍","requires":{"bins":["uv"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY"}}
|
||||
---
|
||||
|
||||
# 📍 Local Places
|
||||
|
||||
*Find places, Go fast*
|
||||
|
||||
Search for nearby places using a local Google Places API proxy. Two-step flow: resolve location first, then search.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd {baseDir}
|
||||
echo "GOOGLE_PLACES_API_KEY=your-key" > .env
|
||||
uv venv && uv pip install -e ".[dev]"
|
||||
uv run --env-file .env uvicorn local_places.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Requires `GOOGLE_PLACES_API_KEY` in `.env` or environment.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Check server:** `curl http://127.0.0.1:8000/ping`
|
||||
|
||||
2. **Resolve location:**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/locations/resolve \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"location_text": "Soho, London", "limit": 5}'
|
||||
```
|
||||
|
||||
3. **Search places:**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/places/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "coffee shop",
|
||||
"location_bias": {"lat": 51.5137, "lng": -0.1366, "radius_m": 1000},
|
||||
"filters": {"open_now": true, "min_rating": 4.0},
|
||||
"limit": 10
|
||||
}'
|
||||
```
|
||||
|
||||
4. **Get details:**
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/places/{place_id}
|
||||
```
|
||||
|
||||
## Conversation Flow
|
||||
|
||||
1. If user says "near me" or gives vague location → resolve it first
|
||||
2. If multiple results → show numbered list, ask user to pick
|
||||
3. Ask for preferences: type, open now, rating, price level
|
||||
4. Search with `location_bias` from chosen location
|
||||
5. Present results with name, rating, address, open status
|
||||
6. Offer to fetch details or refine search
|
||||
|
||||
## Filter Constraints
|
||||
|
||||
- `filters.types`: exactly ONE type (e.g., "restaurant", "cafe", "gym")
|
||||
- `filters.price_levels`: integers 0-4 (0=free, 4=very expensive)
|
||||
- `filters.min_rating`: 0-5 in 0.5 increments
|
||||
- `filters.open_now`: boolean
|
||||
- `limit`: 1-20 for search, 1-10 for resolve
|
||||
- `location_bias.radius_m`: must be > 0
|
||||
|
||||
## Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"place_id": "ChIJ...",
|
||||
"name": "Coffee Shop",
|
||||
"address": "123 Main St",
|
||||
"location": {"lat": 51.5, "lng": -0.1},
|
||||
"rating": 4.6,
|
||||
"price_level": 2,
|
||||
"types": ["cafe", "food"],
|
||||
"open_now": true
|
||||
}
|
||||
],
|
||||
"next_page_token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Use `next_page_token` as `page_token` in next request for more results.
|
||||
27
.skills/local-places/pyproject.toml
Normal file
27
.skills/local-places/pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[project]
|
||||
name = "my-api"
|
||||
version = "0.1.0"
|
||||
description = "FastAPI server"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.110.0",
|
||||
"httpx>=0.27.0",
|
||||
"uvicorn[standard]>=0.29.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/local_places"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-q"
|
||||
testpaths = ["tests"]
|
||||
2
.skills/local-places/src/local_places/__init__.py
Normal file
2
.skills/local-places/src/local_places/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.0"
|
||||
314
.skills/local-places/src/local_places/google_places.py
Normal file
314
.skills/local-places/src/local_places/google_places.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from local_places.schemas import (
|
||||
LatLng,
|
||||
LocationResolveRequest,
|
||||
LocationResolveResponse,
|
||||
PlaceDetails,
|
||||
PlaceSummary,
|
||||
ResolvedLocation,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
)
|
||||
|
||||
GOOGLE_PLACES_BASE_URL = os.getenv(
|
||||
"GOOGLE_PLACES_BASE_URL", "https://places.googleapis.com/v1"
|
||||
)
|
||||
logger = logging.getLogger("local_places.google_places")
|
||||
|
||||
_PRICE_LEVEL_TO_ENUM = {
|
||||
0: "PRICE_LEVEL_FREE",
|
||||
1: "PRICE_LEVEL_INEXPENSIVE",
|
||||
2: "PRICE_LEVEL_MODERATE",
|
||||
3: "PRICE_LEVEL_EXPENSIVE",
|
||||
4: "PRICE_LEVEL_VERY_EXPENSIVE",
|
||||
}
|
||||
_ENUM_TO_PRICE_LEVEL = {value: key for key, value in _PRICE_LEVEL_TO_ENUM.items()}
|
||||
|
||||
_SEARCH_FIELD_MASK = (
|
||||
"places.id,"
|
||||
"places.displayName,"
|
||||
"places.formattedAddress,"
|
||||
"places.location,"
|
||||
"places.rating,"
|
||||
"places.priceLevel,"
|
||||
"places.types,"
|
||||
"places.currentOpeningHours,"
|
||||
"nextPageToken"
|
||||
)
|
||||
|
||||
_DETAILS_FIELD_MASK = (
|
||||
"id,"
|
||||
"displayName,"
|
||||
"formattedAddress,"
|
||||
"location,"
|
||||
"rating,"
|
||||
"priceLevel,"
|
||||
"types,"
|
||||
"regularOpeningHours,"
|
||||
"currentOpeningHours,"
|
||||
"nationalPhoneNumber,"
|
||||
"websiteUri"
|
||||
)
|
||||
|
||||
_RESOLVE_FIELD_MASK = (
|
||||
"places.id,"
|
||||
"places.displayName,"
|
||||
"places.formattedAddress,"
|
||||
"places.location,"
|
||||
"places.types"
|
||||
)
|
||||
|
||||
|
||||
class _GoogleResponse:
|
||||
def __init__(self, response: httpx.Response):
|
||||
self.status_code = response.status_code
|
||||
self._response = response
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._response.json()
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self._response.text
|
||||
|
||||
|
||||
def _api_headers(field_mask: str) -> dict[str, str]:
|
||||
api_key = os.getenv("GOOGLE_PLACES_API_KEY")
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="GOOGLE_PLACES_API_KEY is not set.",
|
||||
)
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": api_key,
|
||||
"X-Goog-FieldMask": field_mask,
|
||||
}
|
||||
|
||||
|
||||
def _request(
|
||||
method: str, url: str, payload: dict[str, Any] | None, field_mask: str
|
||||
) -> _GoogleResponse:
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
response = client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=_api_headers(field_mask),
|
||||
json=payload,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=502, detail="Google Places API unavailable.") from exc
|
||||
|
||||
return _GoogleResponse(response)
|
||||
|
||||
|
||||
def _build_text_query(request: SearchRequest) -> str:
|
||||
keyword = request.filters.keyword if request.filters else None
|
||||
if keyword:
|
||||
return f"{request.query} {keyword}".strip()
|
||||
return request.query
|
||||
|
||||
|
||||
def _build_search_body(request: SearchRequest) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"textQuery": _build_text_query(request),
|
||||
"pageSize": request.limit,
|
||||
}
|
||||
|
||||
if request.page_token:
|
||||
body["pageToken"] = request.page_token
|
||||
|
||||
if request.location_bias:
|
||||
body["locationBias"] = {
|
||||
"circle": {
|
||||
"center": {
|
||||
"latitude": request.location_bias.lat,
|
||||
"longitude": request.location_bias.lng,
|
||||
},
|
||||
"radius": request.location_bias.radius_m,
|
||||
}
|
||||
}
|
||||
|
||||
if request.filters:
|
||||
filters = request.filters
|
||||
if filters.types:
|
||||
body["includedType"] = filters.types[0]
|
||||
if filters.open_now is not None:
|
||||
body["openNow"] = filters.open_now
|
||||
if filters.min_rating is not None:
|
||||
body["minRating"] = filters.min_rating
|
||||
if filters.price_levels:
|
||||
body["priceLevels"] = [
|
||||
_PRICE_LEVEL_TO_ENUM[level] for level in filters.price_levels
|
||||
]
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def _parse_lat_lng(raw: dict[str, Any] | None) -> LatLng | None:
|
||||
if not raw:
|
||||
return None
|
||||
latitude = raw.get("latitude")
|
||||
longitude = raw.get("longitude")
|
||||
if latitude is None or longitude is None:
|
||||
return None
|
||||
return LatLng(lat=latitude, lng=longitude)
|
||||
|
||||
|
||||
def _parse_display_name(raw: dict[str, Any] | None) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
return raw.get("text")
|
||||
|
||||
|
||||
def _parse_open_now(raw: dict[str, Any] | None) -> bool | None:
|
||||
if not raw:
|
||||
return None
|
||||
return raw.get("openNow")
|
||||
|
||||
|
||||
def _parse_hours(raw: dict[str, Any] | None) -> list[str] | None:
|
||||
if not raw:
|
||||
return None
|
||||
return raw.get("weekdayDescriptions")
|
||||
|
||||
|
||||
def _parse_price_level(raw: str | None) -> int | None:
|
||||
if not raw:
|
||||
return None
|
||||
return _ENUM_TO_PRICE_LEVEL.get(raw)
|
||||
|
||||
|
||||
def search_places(request: SearchRequest) -> SearchResponse:
|
||||
url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText"
|
||||
response = _request("POST", url, _build_search_body(request), _SEARCH_FIELD_MASK)
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"Google Places API error %s. response=%s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Google Places API error ({response.status_code}).",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Google Places API returned invalid JSON. response=%s",
|
||||
response.text,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
|
||||
|
||||
places = payload.get("places", [])
|
||||
results = []
|
||||
for place in places:
|
||||
results.append(
|
||||
PlaceSummary(
|
||||
place_id=place.get("id", ""),
|
||||
name=_parse_display_name(place.get("displayName")),
|
||||
address=place.get("formattedAddress"),
|
||||
location=_parse_lat_lng(place.get("location")),
|
||||
rating=place.get("rating"),
|
||||
price_level=_parse_price_level(place.get("priceLevel")),
|
||||
types=place.get("types"),
|
||||
open_now=_parse_open_now(place.get("currentOpeningHours")),
|
||||
)
|
||||
)
|
||||
|
||||
return SearchResponse(
|
||||
results=results,
|
||||
next_page_token=payload.get("nextPageToken"),
|
||||
)
|
||||
|
||||
|
||||
def get_place_details(place_id: str) -> PlaceDetails:
|
||||
url = f"{GOOGLE_PLACES_BASE_URL}/places/{place_id}"
|
||||
response = _request("GET", url, None, _DETAILS_FIELD_MASK)
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"Google Places API error %s. response=%s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Google Places API error ({response.status_code}).",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Google Places API returned invalid JSON. response=%s",
|
||||
response.text,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
|
||||
|
||||
return PlaceDetails(
|
||||
place_id=payload.get("id", place_id),
|
||||
name=_parse_display_name(payload.get("displayName")),
|
||||
address=payload.get("formattedAddress"),
|
||||
location=_parse_lat_lng(payload.get("location")),
|
||||
rating=payload.get("rating"),
|
||||
price_level=_parse_price_level(payload.get("priceLevel")),
|
||||
types=payload.get("types"),
|
||||
phone=payload.get("nationalPhoneNumber"),
|
||||
website=payload.get("websiteUri"),
|
||||
hours=_parse_hours(payload.get("regularOpeningHours")),
|
||||
open_now=_parse_open_now(payload.get("currentOpeningHours")),
|
||||
)
|
||||
|
||||
|
||||
def resolve_locations(request: LocationResolveRequest) -> LocationResolveResponse:
|
||||
url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText"
|
||||
body = {"textQuery": request.location_text, "pageSize": request.limit}
|
||||
response = _request("POST", url, body, _RESOLVE_FIELD_MASK)
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"Google Places API error %s. response=%s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Google Places API error ({response.status_code}).",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Google Places API returned invalid JSON. response=%s",
|
||||
response.text,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
|
||||
|
||||
places = payload.get("places", [])
|
||||
results = []
|
||||
for place in places:
|
||||
results.append(
|
||||
ResolvedLocation(
|
||||
place_id=place.get("id", ""),
|
||||
name=_parse_display_name(place.get("displayName")),
|
||||
address=place.get("formattedAddress"),
|
||||
location=_parse_lat_lng(place.get("location")),
|
||||
types=place.get("types"),
|
||||
)
|
||||
)
|
||||
|
||||
return LocationResolveResponse(results=results)
|
||||
65
.skills/local-places/src/local_places/main.py
Normal file
65
.skills/local-places/src/local_places/main.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from local_places.google_places import get_place_details, resolve_locations, search_places
|
||||
from local_places.schemas import (
|
||||
LocationResolveRequest,
|
||||
LocationResolveResponse,
|
||||
PlaceDetails,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title="My API",
|
||||
servers=[{"url": os.getenv("OPENAPI_SERVER_URL", "http://maxims-macbook-air:8000")}],
|
||||
)
|
||||
logger = logging.getLogger("local_places.validation")
|
||||
|
||||
|
||||
@app.get("/ping")
|
||||
def ping() -> dict[str, str]:
|
||||
return {"message": "pong"}
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
logger.error(
|
||||
"Validation error on %s %s. body=%s errors=%s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
exc.body,
|
||||
exc.errors(),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=jsonable_encoder({"detail": exc.errors()}),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/places/search", response_model=SearchResponse)
|
||||
def places_search(request: SearchRequest) -> SearchResponse:
|
||||
return search_places(request)
|
||||
|
||||
|
||||
@app.get("/places/{place_id}", response_model=PlaceDetails)
|
||||
def places_details(place_id: str) -> PlaceDetails:
|
||||
return get_place_details(place_id)
|
||||
|
||||
|
||||
@app.post("/locations/resolve", response_model=LocationResolveResponse)
|
||||
def locations_resolve(request: LocationResolveRequest) -> LocationResolveResponse:
|
||||
return resolve_locations(request)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("local_places.main:app", host="0.0.0.0", port=8000)
|
||||
107
.skills/local-places/src/local_places/schemas.py
Normal file
107
.skills/local-places/src/local_places/schemas.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class LatLng(BaseModel):
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
lng: float = Field(ge=-180, le=180)
|
||||
|
||||
|
||||
class LocationBias(BaseModel):
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
lng: float = Field(ge=-180, le=180)
|
||||
radius_m: float = Field(gt=0)
|
||||
|
||||
|
||||
class Filters(BaseModel):
|
||||
types: list[str] | None = None
|
||||
open_now: bool | None = None
|
||||
min_rating: float | None = Field(default=None, ge=0, le=5)
|
||||
price_levels: list[int] | None = None
|
||||
keyword: str | None = Field(default=None, min_length=1)
|
||||
|
||||
@field_validator("types")
|
||||
@classmethod
|
||||
def validate_types(cls, value: list[str] | None) -> list[str] | None:
|
||||
if value is None:
|
||||
return value
|
||||
if len(value) > 1:
|
||||
raise ValueError(
|
||||
"Only one type is supported. Use query/keyword for additional filtering."
|
||||
)
|
||||
return value
|
||||
|
||||
@field_validator("price_levels")
|
||||
@classmethod
|
||||
def validate_price_levels(cls, value: list[int] | None) -> list[int] | None:
|
||||
if value is None:
|
||||
return value
|
||||
invalid = [level for level in value if level not in range(0, 5)]
|
||||
if invalid:
|
||||
raise ValueError("price_levels must be integers between 0 and 4.")
|
||||
return value
|
||||
|
||||
@field_validator("min_rating")
|
||||
@classmethod
|
||||
def validate_min_rating(cls, value: float | None) -> float | None:
|
||||
if value is None:
|
||||
return value
|
||||
if (value * 2) % 1 != 0:
|
||||
raise ValueError("min_rating must be in 0.5 increments.")
|
||||
return value
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
query: str = Field(min_length=1)
|
||||
location_bias: LocationBias | None = None
|
||||
filters: Filters | None = None
|
||||
limit: int = Field(default=10, ge=1, le=20)
|
||||
page_token: str | None = None
|
||||
|
||||
|
||||
class PlaceSummary(BaseModel):
|
||||
place_id: str
|
||||
name: str | None = None
|
||||
address: str | None = None
|
||||
location: LatLng | None = None
|
||||
rating: float | None = None
|
||||
price_level: int | None = None
|
||||
types: list[str] | None = None
|
||||
open_now: bool | None = None
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
results: list[PlaceSummary]
|
||||
next_page_token: str | None = None
|
||||
|
||||
|
||||
class LocationResolveRequest(BaseModel):
|
||||
location_text: str = Field(min_length=1)
|
||||
limit: int = Field(default=5, ge=1, le=10)
|
||||
|
||||
|
||||
class ResolvedLocation(BaseModel):
|
||||
place_id: str
|
||||
name: str | None = None
|
||||
address: str | None = None
|
||||
location: LatLng | None = None
|
||||
types: list[str] | None = None
|
||||
|
||||
|
||||
class LocationResolveResponse(BaseModel):
|
||||
results: list[ResolvedLocation]
|
||||
|
||||
|
||||
class PlaceDetails(BaseModel):
|
||||
place_id: str
|
||||
name: str | None = None
|
||||
address: str | None = None
|
||||
location: LatLng | None = None
|
||||
rating: float | None = None
|
||||
price_level: int | None = None
|
||||
types: list[str] | None = None
|
||||
phone: str | None = None
|
||||
website: str | None = None
|
||||
hours: list[str] | None = None
|
||||
open_now: bool | None = None
|
||||
38
.skills/mcporter/SKILL.md
Normal file
38
.skills/mcporter/SKILL.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: mcporter
|
||||
description: Use the mcporter CLI to list, configure, auth, and call MCP servers/tools directly (HTTP or stdio), including ad-hoc servers, config edits, and CLI/type generation.
|
||||
homepage: http://mcporter.dev
|
||||
metadata: {"clawdbot":{"emoji":"📦","requires":{"bins":["mcporter"]},"install":[{"id":"node","kind":"node","package":"mcporter","bins":["mcporter"],"label":"Install mcporter (node)"}]}}
|
||||
---
|
||||
|
||||
# mcporter
|
||||
|
||||
Use `mcporter` to work with MCP servers directly.
|
||||
|
||||
Quick start
|
||||
- `mcporter list`
|
||||
- `mcporter list <server> --schema`
|
||||
- `mcporter call <server.tool> key=value`
|
||||
|
||||
Call tools
|
||||
- Selector: `mcporter call linear.list_issues team=ENG limit:5`
|
||||
- Function syntax: `mcporter call "linear.create_issue(title: \"Bug\")"`
|
||||
- Full URL: `mcporter call https://api.example.com/mcp.fetch url:https://example.com`
|
||||
- Stdio: `mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com`
|
||||
- JSON payload: `mcporter call <server.tool> --args '{"limit":5}'`
|
||||
|
||||
Auth + config
|
||||
- OAuth: `mcporter auth <server | url> [--reset]`
|
||||
- Config: `mcporter config list|get|add|remove|import|login|logout`
|
||||
|
||||
Daemon
|
||||
- `mcporter daemon start|status|stop|restart`
|
||||
|
||||
Codegen
|
||||
- CLI: `mcporter generate-cli --server <name>` or `--command <url>`
|
||||
- Inspect: `mcporter inspect-cli <path> [--json]`
|
||||
- TS: `mcporter emit-ts <server> --mode client|types`
|
||||
|
||||
Notes
|
||||
- Config default: `./config/mcporter.json` (override with `--config`).
|
||||
- Prefer `--output json` for machine-readable results.
|
||||
35
.skills/nano-banana-pro/SKILL.md
Normal file
35
.skills/nano-banana-pro/SKILL.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: nano-banana-pro
|
||||
description: Generate or edit images via Gemini 3 Pro Image (Nano Banana Pro).
|
||||
homepage: https://ai.google.dev/
|
||||
metadata: {"clawdbot":{"emoji":"🍌","requires":{"bins":["uv"],"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY","install":[{"id":"uv-brew","kind":"brew","formula":"uv","bins":["uv"],"label":"Install uv (brew)"}]}}
|
||||
---
|
||||
|
||||
# Nano Banana Pro (Gemini 3 Pro Image)
|
||||
|
||||
Use the bundled script to generate or edit images.
|
||||
|
||||
Generate
|
||||
```bash
|
||||
uv run {baseDir}/scripts/generate_image.py --prompt "your image description" --filename "output.png" --resolution 1K
|
||||
```
|
||||
|
||||
Edit (single image)
|
||||
```bash
|
||||
uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" -i "/path/in.png" --resolution 2K
|
||||
```
|
||||
|
||||
Multi-image composition (up to 14 images)
|
||||
```bash
|
||||
uv run {baseDir}/scripts/generate_image.py --prompt "combine these into one scene" --filename "output.png" -i img1.png -i img2.png -i img3.png
|
||||
```
|
||||
|
||||
API key
|
||||
- `GEMINI_API_KEY` env var
|
||||
- Or set `skills."nano-banana-pro".apiKey` / `skills."nano-banana-pro".env.GEMINI_API_KEY` in `~/.clawdbot/clawdbot.json`
|
||||
|
||||
Notes
|
||||
- Resolutions: `1K` (default), `2K`, `4K`.
|
||||
- Use timestamps in filenames: `yyyy-mm-dd-hh-mm-ss-name.png`.
|
||||
- The script prints a `MEDIA:` line for Clawdbot to auto-attach on supported chat providers.
|
||||
- Do not read the image back; report the saved path only.
|
||||
184
.skills/nano-banana-pro/scripts/generate_image.py
Executable file
184
.skills/nano-banana-pro/scripts/generate_image.py
Executable file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "google-genai>=1.0.0",
|
||||
# "pillow>=10.0.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API.
|
||||
|
||||
Usage:
|
||||
uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY]
|
||||
|
||||
Multi-image editing (up to 14 images):
|
||||
uv run generate_image.py --prompt "combine these images" --filename "output.png" -i img1.png -i img2.png -i img3.png
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_api_key(provided_key: str | None) -> str | None:
|
||||
"""Get API key from argument first, then environment."""
|
||||
if provided_key:
|
||||
return provided_key
|
||||
return os.environ.get("GEMINI_API_KEY")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prompt", "-p",
|
||||
required=True,
|
||||
help="Image description/prompt"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--filename", "-f",
|
||||
required=True,
|
||||
help="Output filename (e.g., sunset-mountains.png)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-image", "-i",
|
||||
action="append",
|
||||
dest="input_images",
|
||||
metavar="IMAGE",
|
||||
help="Input image path(s) for editing/composition. Can be specified multiple times (up to 14 images)."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resolution", "-r",
|
||||
choices=["1K", "2K", "4K"],
|
||||
default="1K",
|
||||
help="Output resolution: 1K (default), 2K, or 4K"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-key", "-k",
|
||||
help="Gemini API key (overrides GEMINI_API_KEY env var)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get API key
|
||||
api_key = get_api_key(args.api_key)
|
||||
if not api_key:
|
||||
print("Error: No API key provided.", file=sys.stderr)
|
||||
print("Please either:", file=sys.stderr)
|
||||
print(" 1. Provide --api-key argument", file=sys.stderr)
|
||||
print(" 2. Set GEMINI_API_KEY environment variable", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Import here after checking API key to avoid slow import on error
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image as PILImage
|
||||
|
||||
# Initialise client
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
# Set up output path
|
||||
output_path = Path(args.filename)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load input images if provided (up to 14 supported by Nano Banana Pro)
|
||||
input_images = []
|
||||
output_resolution = args.resolution
|
||||
if args.input_images:
|
||||
if len(args.input_images) > 14:
|
||||
print(f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
max_input_dim = 0
|
||||
for img_path in args.input_images:
|
||||
try:
|
||||
img = PILImage.open(img_path)
|
||||
input_images.append(img)
|
||||
print(f"Loaded input image: {img_path}")
|
||||
|
||||
# Track largest dimension for auto-resolution
|
||||
width, height = img.size
|
||||
max_input_dim = max(max_input_dim, width, height)
|
||||
except Exception as e:
|
||||
print(f"Error loading input image '{img_path}': {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Auto-detect resolution from largest input if not explicitly set
|
||||
if args.resolution == "1K" and max_input_dim > 0: # Default value
|
||||
if max_input_dim >= 3000:
|
||||
output_resolution = "4K"
|
||||
elif max_input_dim >= 1500:
|
||||
output_resolution = "2K"
|
||||
else:
|
||||
output_resolution = "1K"
|
||||
print(f"Auto-detected resolution: {output_resolution} (from max input dimension {max_input_dim})")
|
||||
|
||||
# Build contents (images first if editing, prompt only if generating)
|
||||
if input_images:
|
||||
contents = [*input_images, args.prompt]
|
||||
img_count = len(input_images)
|
||||
print(f"Processing {img_count} image{'s' if img_count > 1 else ''} with resolution {output_resolution}...")
|
||||
else:
|
||||
contents = args.prompt
|
||||
print(f"Generating image with resolution {output_resolution}...")
|
||||
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model="gemini-3-pro-image-preview",
|
||||
contents=contents,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["TEXT", "IMAGE"],
|
||||
image_config=types.ImageConfig(
|
||||
image_size=output_resolution
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Process response and convert to PNG
|
||||
image_saved = False
|
||||
for part in response.parts:
|
||||
if part.text is not None:
|
||||
print(f"Model response: {part.text}")
|
||||
elif part.inline_data is not None:
|
||||
# Convert inline data to PIL Image and save as PNG
|
||||
from io import BytesIO
|
||||
|
||||
# inline_data.data is already bytes, not base64
|
||||
image_data = part.inline_data.data
|
||||
if isinstance(image_data, str):
|
||||
# If it's a string, it might be base64
|
||||
import base64
|
||||
image_data = base64.b64decode(image_data)
|
||||
|
||||
image = PILImage.open(BytesIO(image_data))
|
||||
|
||||
# Ensure RGB mode for PNG (convert RGBA to RGB with white background if needed)
|
||||
if image.mode == 'RGBA':
|
||||
rgb_image = PILImage.new('RGB', image.size, (255, 255, 255))
|
||||
rgb_image.paste(image, mask=image.split()[3])
|
||||
rgb_image.save(str(output_path), 'PNG')
|
||||
elif image.mode == 'RGB':
|
||||
image.save(str(output_path), 'PNG')
|
||||
else:
|
||||
image.convert('RGB').save(str(output_path), 'PNG')
|
||||
image_saved = True
|
||||
|
||||
if image_saved:
|
||||
full_path = output_path.resolve()
|
||||
print(f"\nImage saved: {full_path}")
|
||||
# Clawdbot parses MEDIA tokens and will attach the file on supported providers.
|
||||
print(f"MEDIA: {full_path}")
|
||||
else:
|
||||
print("Error: No image was generated in the response.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating image: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
20
.skills/nano-pdf/SKILL.md
Normal file
20
.skills/nano-pdf/SKILL.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: nano-pdf
|
||||
description: Edit PDFs with natural-language instructions using the nano-pdf CLI.
|
||||
homepage: https://pypi.org/project/nano-pdf/
|
||||
metadata: {"clawdbot":{"emoji":"📄","requires":{"bins":["nano-pdf"]},"install":[{"id":"uv","kind":"uv","package":"nano-pdf","bins":["nano-pdf"],"label":"Install nano-pdf (uv)"}]}}
|
||||
---
|
||||
|
||||
# nano-pdf
|
||||
|
||||
Use `nano-pdf` to apply edits to a specific page in a PDF using a natural-language instruction.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
nano-pdf edit deck.pdf 1 "Change the title to 'Q3 Results' and fix the typo in the subtitle"
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Page numbers are 0-based or 1-based depending on the tool’s version/config; if the result looks off by one, retry with the other.
|
||||
- Always sanity-check the output PDF before sending it out.
|
||||
156
.skills/notion/SKILL.md
Normal file
156
.skills/notion/SKILL.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: notion
|
||||
description: Notion API for creating and managing pages, databases, and blocks.
|
||||
homepage: https://developers.notion.com
|
||||
metadata: {"clawdbot":{"emoji":"📝","requires":{"env":["NOTION_API_KEY"]},"primaryEnv":"NOTION_API_KEY"}}
|
||||
---
|
||||
|
||||
# notion
|
||||
|
||||
Use the Notion API to create/read/update pages, data sources (databases), and blocks.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create an integration at https://notion.so/my-integrations
|
||||
2. Copy the API key (starts with `ntn_` or `secret_`)
|
||||
3. Store it:
|
||||
```bash
|
||||
mkdir -p ~/.config/notion
|
||||
echo "ntn_your_key_here" > ~/.config/notion/api_key
|
||||
```
|
||||
4. Share target pages/databases with your integration (click "..." → "Connect to" → your integration name)
|
||||
|
||||
## API Basics
|
||||
|
||||
All requests need:
|
||||
```bash
|
||||
NOTION_KEY=$(cat ~/.config/notion/api_key)
|
||||
curl -X GET "https://api.notion.com/v1/..." \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
> **Note:** The `Notion-Version` header is required. This skill uses `2025-09-03` (latest). In this version, databases are called "data sources" in the API.
|
||||
|
||||
## Common Operations
|
||||
|
||||
**Search for pages and data sources:**
|
||||
```bash
|
||||
curl -X POST "https://api.notion.com/v1/search" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "page title"}'
|
||||
```
|
||||
|
||||
**Get page:**
|
||||
```bash
|
||||
curl "https://api.notion.com/v1/pages/{page_id}" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03"
|
||||
```
|
||||
|
||||
**Get page content (blocks):**
|
||||
```bash
|
||||
curl "https://api.notion.com/v1/blocks/{page_id}/children" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03"
|
||||
```
|
||||
|
||||
**Create page in a data source:**
|
||||
```bash
|
||||
curl -X POST "https://api.notion.com/v1/pages" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"parent": {"database_id": "xxx"},
|
||||
"properties": {
|
||||
"Name": {"title": [{"text": {"content": "New Item"}}]},
|
||||
"Status": {"select": {"name": "Todo"}}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Query a data source (database):**
|
||||
```bash
|
||||
curl -X POST "https://api.notion.com/v1/data_sources/{data_source_id}/query" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"filter": {"property": "Status", "select": {"equals": "Active"}},
|
||||
"sorts": [{"property": "Date", "direction": "descending"}]
|
||||
}'
|
||||
```
|
||||
|
||||
**Create a data source (database):**
|
||||
```bash
|
||||
curl -X POST "https://api.notion.com/v1/data_sources" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"parent": {"page_id": "xxx"},
|
||||
"title": [{"text": {"content": "My Database"}}],
|
||||
"properties": {
|
||||
"Name": {"title": {}},
|
||||
"Status": {"select": {"options": [{"name": "Todo"}, {"name": "Done"}]}},
|
||||
"Date": {"date": {}}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Update page properties:**
|
||||
```bash
|
||||
curl -X PATCH "https://api.notion.com/v1/pages/{page_id}" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"properties": {"Status": {"select": {"name": "Done"}}}}'
|
||||
```
|
||||
|
||||
**Add blocks to page:**
|
||||
```bash
|
||||
curl -X PATCH "https://api.notion.com/v1/blocks/{page_id}/children" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"children": [
|
||||
{"object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"text": {"content": "Hello"}}]}}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
## Property Types
|
||||
|
||||
Common property formats for database items:
|
||||
- **Title:** `{"title": [{"text": {"content": "..."}}]}`
|
||||
- **Rich text:** `{"rich_text": [{"text": {"content": "..."}}]}`
|
||||
- **Select:** `{"select": {"name": "Option"}}`
|
||||
- **Multi-select:** `{"multi_select": [{"name": "A"}, {"name": "B"}]}`
|
||||
- **Date:** `{"date": {"start": "2024-01-15", "end": "2024-01-16"}}`
|
||||
- **Checkbox:** `{"checkbox": true}`
|
||||
- **Number:** `{"number": 42}`
|
||||
- **URL:** `{"url": "https://..."}`
|
||||
- **Email:** `{"email": "a@b.com"}`
|
||||
- **Relation:** `{"relation": [{"id": "page_id"}]}`
|
||||
|
||||
## Key Differences in 2025-09-03
|
||||
|
||||
- **Databases → Data Sources:** Use `/data_sources/` endpoints for queries and retrieval
|
||||
- **Two IDs:** Each database now has both a `database_id` and a `data_source_id`
|
||||
- Use `database_id` when creating pages (`parent: {"database_id": "..."}`)
|
||||
- Use `data_source_id` when querying (`POST /v1/data_sources/{id}/query`)
|
||||
- **Search results:** Databases return as `"object": "data_source"` with their `data_source_id`
|
||||
- **Parent in responses:** Pages show `parent.data_source_id` alongside `parent.database_id`
|
||||
- **Finding the data_source_id:** Search for the database, or call `GET /v1/data_sources/{data_source_id}`
|
||||
|
||||
## Notes
|
||||
|
||||
- Page/database IDs are UUIDs (with or without dashes)
|
||||
- The API cannot set database view filters — that's UI-only
|
||||
- Rate limit: ~3 requests/second average
|
||||
- Use `is_inline: true` when creating data sources to embed them in pages
|
||||
55
.skills/obsidian/SKILL.md
Normal file
55
.skills/obsidian/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: obsidian
|
||||
description: Work with Obsidian vaults (plain Markdown notes) and automate via obsidian-cli.
|
||||
homepage: https://help.obsidian.md
|
||||
metadata: {"clawdbot":{"emoji":"💎","requires":{"bins":["obsidian-cli"]},"install":[{"id":"brew","kind":"brew","formula":"yakitrak/yakitrak/obsidian-cli","bins":["obsidian-cli"],"label":"Install obsidian-cli (brew)"}]}}
|
||||
---
|
||||
|
||||
# Obsidian
|
||||
|
||||
Obsidian vault = a normal folder on disk.
|
||||
|
||||
Vault structure (typical)
|
||||
- Notes: `*.md` (plain text Markdown; edit with any editor)
|
||||
- Config: `.obsidian/` (workspace + plugin settings; usually don’t touch from scripts)
|
||||
- Canvases: `*.canvas` (JSON)
|
||||
- Attachments: whatever folder you chose in Obsidian settings (images/PDFs/etc.)
|
||||
|
||||
## Find the active vault(s)
|
||||
|
||||
Obsidian desktop tracks vaults here (source of truth):
|
||||
- `~/Library/Application Support/obsidian/obsidian.json`
|
||||
|
||||
`obsidian-cli` resolves vaults from that file; vault name is typically the **folder name** (path suffix).
|
||||
|
||||
Fast “what vault is active / where are the notes?”
|
||||
- If you’ve already set a default: `obsidian-cli print-default --path-only`
|
||||
- Otherwise, read `~/Library/Application Support/obsidian/obsidian.json` and use the vault entry with `"open": true`.
|
||||
|
||||
Notes
|
||||
- Multiple vaults common (iCloud vs `~/Documents`, work/personal, etc.). Don’t guess; read config.
|
||||
- Avoid writing hardcoded vault paths into scripts; prefer reading the config or using `print-default`.
|
||||
|
||||
## obsidian-cli quick start
|
||||
|
||||
Pick a default vault (once):
|
||||
- `obsidian-cli set-default "<vault-folder-name>"`
|
||||
- `obsidian-cli print-default` / `obsidian-cli print-default --path-only`
|
||||
|
||||
Search
|
||||
- `obsidian-cli search "query"` (note names)
|
||||
- `obsidian-cli search-content "query"` (inside notes; shows snippets + lines)
|
||||
|
||||
Create
|
||||
- `obsidian-cli create "Folder/New note" --content "..." --open`
|
||||
- Requires Obsidian URI handler (`obsidian://…`) working (Obsidian installed).
|
||||
- Avoid creating notes under “hidden” dot-folders (e.g. `.something/...`) via URI; Obsidian may refuse.
|
||||
|
||||
Move/rename (safe refactor)
|
||||
- `obsidian-cli move "old/path/note" "new/path/note"`
|
||||
- Updates `[[wikilinks]]` and common Markdown links across the vault (this is the main win vs `mv`).
|
||||
|
||||
Delete
|
||||
- `obsidian-cli delete "path/note"`
|
||||
|
||||
Prefer direct edits when appropriate: open the `.md` file and change it; Obsidian will pick it up.
|
||||
71
.skills/openai-image-gen/SKILL.md
Normal file
71
.skills/openai-image-gen/SKILL.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: openai-image-gen
|
||||
description: Batch-generate images via OpenAI Images API. Random prompt sampler + `index.html` gallery.
|
||||
homepage: https://platform.openai.com/docs/api-reference/images
|
||||
metadata: {"clawdbot":{"emoji":"🖼️","requires":{"bins":["python3"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY","install":[{"id":"python-brew","kind":"brew","formula":"python","bins":["python3"],"label":"Install Python (brew)"}]}}
|
||||
---
|
||||
|
||||
# OpenAI Image Gen
|
||||
|
||||
Generate a handful of “random but structured” prompts and render them via the OpenAI Images API.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python3 {baseDir}/scripts/gen.py
|
||||
open ~/Projects/tmp/openai-image-gen-*/index.html # if ~/Projects/tmp exists; else ./tmp/...
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
```bash
|
||||
# GPT image models with various options
|
||||
python3 {baseDir}/scripts/gen.py --count 16 --model gpt-image-1
|
||||
python3 {baseDir}/scripts/gen.py --prompt "ultra-detailed studio photo of a lobster astronaut" --count 4
|
||||
python3 {baseDir}/scripts/gen.py --size 1536x1024 --quality high --out-dir ./out/images
|
||||
python3 {baseDir}/scripts/gen.py --model gpt-image-1.5 --background transparent --output-format webp
|
||||
|
||||
# DALL-E 3 (note: count is automatically limited to 1)
|
||||
python3 {baseDir}/scripts/gen.py --model dall-e-3 --quality hd --size 1792x1024 --style vivid
|
||||
python3 {baseDir}/scripts/gen.py --model dall-e-3 --style natural --prompt "serene mountain landscape"
|
||||
|
||||
# DALL-E 2
|
||||
python3 {baseDir}/scripts/gen.py --model dall-e-2 --size 512x512 --count 4
|
||||
```
|
||||
|
||||
## Model-Specific Parameters
|
||||
|
||||
Different models support different parameter values. The script automatically selects appropriate defaults based on the model.
|
||||
|
||||
### Size
|
||||
|
||||
- **GPT image models** (`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`): `1024x1024`, `1536x1024` (landscape), `1024x1536` (portrait), or `auto`
|
||||
- Default: `1024x1024`
|
||||
- **dall-e-3**: `1024x1024`, `1792x1024`, or `1024x1792`
|
||||
- Default: `1024x1024`
|
||||
- **dall-e-2**: `256x256`, `512x512`, or `1024x1024`
|
||||
- Default: `1024x1024`
|
||||
|
||||
### Quality
|
||||
|
||||
- **GPT image models**: `auto`, `high`, `medium`, or `low`
|
||||
- Default: `high`
|
||||
- **dall-e-3**: `hd` or `standard`
|
||||
- Default: `standard`
|
||||
- **dall-e-2**: `standard` only
|
||||
- Default: `standard`
|
||||
|
||||
### Other Notable Differences
|
||||
|
||||
- **dall-e-3** only supports generating 1 image at a time (`n=1`). The script automatically limits count to 1 when using this model.
|
||||
- **GPT image models** support additional parameters:
|
||||
- `--background`: `transparent`, `opaque`, or `auto` (default)
|
||||
- `--output-format`: `png` (default), `jpeg`, or `webp`
|
||||
- Note: `stream` and `moderation` are available via API but not yet implemented in this script
|
||||
- **dall-e-3** has a `--style` parameter: `vivid` (hyper-real, dramatic) or `natural` (more natural looking)
|
||||
|
||||
## Output
|
||||
|
||||
- `*.png`, `*.jpeg`, or `*.webp` images (output format depends on model + `--output-format`)
|
||||
- `prompts.json` (prompt → file mapping)
|
||||
- `index.html` (thumbnail gallery)
|
||||
240
.skills/openai-image-gen/scripts/gen.py
Normal file
240
.skills/openai-image-gen/scripts/gen.py
Normal file
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import base64
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
text = text.lower().strip()
|
||||
text = re.sub(r"[^a-z0-9]+", "-", text)
|
||||
text = re.sub(r"-{2,}", "-", text).strip("-")
|
||||
return text or "image"
|
||||
|
||||
|
||||
def default_out_dir() -> Path:
|
||||
now = dt.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
||||
preferred = Path.home() / "Projects" / "tmp"
|
||||
base = preferred if preferred.is_dir() else Path("./tmp")
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base / f"openai-image-gen-{now}"
|
||||
|
||||
|
||||
def pick_prompts(count: int) -> list[str]:
|
||||
subjects = [
|
||||
"a lobster astronaut",
|
||||
"a brutalist lighthouse",
|
||||
"a cozy reading nook",
|
||||
"a cyberpunk noodle shop",
|
||||
"a Vienna street at dusk",
|
||||
"a minimalist product photo",
|
||||
"a surreal underwater library",
|
||||
]
|
||||
styles = [
|
||||
"ultra-detailed studio photo",
|
||||
"35mm film still",
|
||||
"isometric illustration",
|
||||
"editorial photography",
|
||||
"soft watercolor",
|
||||
"architectural render",
|
||||
"high-contrast monochrome",
|
||||
]
|
||||
lighting = [
|
||||
"golden hour",
|
||||
"overcast soft light",
|
||||
"neon lighting",
|
||||
"dramatic rim light",
|
||||
"candlelight",
|
||||
"foggy atmosphere",
|
||||
]
|
||||
prompts: list[str] = []
|
||||
for _ in range(count):
|
||||
prompts.append(
|
||||
f"{random.choice(styles)} of {random.choice(subjects)}, {random.choice(lighting)}"
|
||||
)
|
||||
return prompts
|
||||
|
||||
|
||||
def get_model_defaults(model: str) -> tuple[str, str]:
|
||||
"""Return (default_size, default_quality) for the given model."""
|
||||
if model == "dall-e-2":
|
||||
# quality will be ignored
|
||||
return ("1024x1024", "standard")
|
||||
elif model == "dall-e-3":
|
||||
return ("1024x1024", "standard")
|
||||
else:
|
||||
# GPT image or future models
|
||||
return ("1024x1024", "high")
|
||||
|
||||
|
||||
def request_images(
|
||||
api_key: str,
|
||||
prompt: str,
|
||||
model: str,
|
||||
size: str,
|
||||
quality: str,
|
||||
background: str = "",
|
||||
output_format: str = "",
|
||||
style: str = "",
|
||||
) -> dict:
|
||||
url = "https://api.openai.com/v1/images/generations"
|
||||
args = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"n": 1,
|
||||
}
|
||||
|
||||
# Quality parameter - dall-e-2 doesn't accept this parameter
|
||||
if model != "dall-e-2":
|
||||
args["quality"] = quality
|
||||
|
||||
# Note: response_format no longer supported by OpenAI Images API
|
||||
# dall-e models now return URLs by default
|
||||
|
||||
if model.startswith("gpt-image"):
|
||||
if background:
|
||||
args["background"] = background
|
||||
if output_format:
|
||||
args["output_format"] = output_format
|
||||
|
||||
if model == "dall-e-3" and style:
|
||||
args["style"] = style
|
||||
|
||||
body = json.dumps(args).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data=body,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
payload = e.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"OpenAI Images API failed ({e.code}): {payload}") from e
|
||||
|
||||
|
||||
def write_gallery(out_dir: Path, items: list[dict]) -> None:
|
||||
thumbs = "\n".join(
|
||||
[
|
||||
f"""
|
||||
<figure>
|
||||
<a href="{it["file"]}"><img src="{it["file"]}" loading="lazy" /></a>
|
||||
<figcaption>{it["prompt"]}</figcaption>
|
||||
</figure>
|
||||
""".strip()
|
||||
for it in items
|
||||
]
|
||||
)
|
||||
html = f"""<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>openai-image-gen</title>
|
||||
<style>
|
||||
:root {{ color-scheme: dark; }}
|
||||
body {{ margin: 24px; font: 14px/1.4 ui-sans-serif, system-ui; background: #0b0f14; color: #e8edf2; }}
|
||||
h1 {{ font-size: 18px; margin: 0 0 16px; }}
|
||||
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }}
|
||||
figure {{ margin: 0; padding: 12px; border: 1px solid #1e2a36; border-radius: 14px; background: #0f1620; }}
|
||||
img {{ width: 100%; height: auto; border-radius: 10px; display: block; }}
|
||||
figcaption {{ margin-top: 10px; color: #b7c2cc; }}
|
||||
code {{ color: #9cd1ff; }}
|
||||
</style>
|
||||
<h1>openai-image-gen</h1>
|
||||
<p>Output: <code>{out_dir.as_posix()}</code></p>
|
||||
<div class="grid">
|
||||
{thumbs}
|
||||
</div>
|
||||
"""
|
||||
(out_dir / "index.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Generate images via OpenAI Images API.")
|
||||
ap.add_argument("--prompt", help="Single prompt. If omitted, random prompts are generated.")
|
||||
ap.add_argument("--count", type=int, default=8, help="How many images to generate.")
|
||||
ap.add_argument("--model", default="gpt-image-1", help="Image model id.")
|
||||
ap.add_argument("--size", default="", help="Image size (e.g. 1024x1024, 1536x1024). Defaults based on model if not specified.")
|
||||
ap.add_argument("--quality", default="", help="Image quality (e.g. high, standard). Defaults based on model if not specified.")
|
||||
ap.add_argument("--background", default="", help="Background transparency (GPT models only): transparent, opaque, or auto.")
|
||||
ap.add_argument("--output-format", default="", help="Output format (GPT models only): png, jpeg, or webp.")
|
||||
ap.add_argument("--style", default="", help="Image style (dall-e-3 only): vivid or natural.")
|
||||
ap.add_argument("--out-dir", default="", help="Output directory (default: ./tmp/openai-image-gen-<ts>).")
|
||||
args = ap.parse_args()
|
||||
|
||||
api_key = (os.environ.get("OPENAI_API_KEY") or "").strip()
|
||||
if not api_key:
|
||||
print("Missing OPENAI_API_KEY", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Apply model-specific defaults if not specified
|
||||
default_size, default_quality = get_model_defaults(args.model)
|
||||
size = args.size or default_size
|
||||
quality = args.quality or default_quality
|
||||
|
||||
count = args.count
|
||||
if args.model == "dall-e-3" and count > 1:
|
||||
print(f"Warning: dall-e-3 only supports generating 1 image at a time. Reducing count from {count} to 1.", file=sys.stderr)
|
||||
count = 1
|
||||
|
||||
out_dir = Path(args.out_dir).expanduser() if args.out_dir else default_out_dir()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
prompts = [args.prompt] * count if args.prompt else pick_prompts(count)
|
||||
|
||||
# Determine file extension based on output format
|
||||
if args.model.startswith("gpt-image") and args.output_format:
|
||||
file_ext = args.output_format
|
||||
else:
|
||||
file_ext = "png"
|
||||
|
||||
items: list[dict] = []
|
||||
for idx, prompt in enumerate(prompts, start=1):
|
||||
print(f"[{idx}/{len(prompts)}] {prompt}")
|
||||
res = request_images(
|
||||
api_key,
|
||||
prompt,
|
||||
args.model,
|
||||
size,
|
||||
quality,
|
||||
args.background,
|
||||
args.output_format,
|
||||
args.style,
|
||||
)
|
||||
data = res.get("data", [{}])[0]
|
||||
image_b64 = data.get("b64_json")
|
||||
image_url = data.get("url")
|
||||
if not image_b64 and not image_url:
|
||||
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
|
||||
|
||||
filename = f"{idx:03d}-{slugify(prompt)[:40]}.{file_ext}"
|
||||
filepath = out_dir / filename
|
||||
if image_b64:
|
||||
filepath.write_bytes(base64.b64decode(image_b64))
|
||||
else:
|
||||
try:
|
||||
urllib.request.urlretrieve(image_url, filepath)
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"Failed to download image from {image_url}: {e}") from e
|
||||
|
||||
items.append({"prompt": prompt, "file": filename})
|
||||
|
||||
(out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
|
||||
write_gallery(out_dir, items)
|
||||
print(f"\nWrote: {(out_dir / 'index.html').as_posix()}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
43
.skills/openai-whisper-api/SKILL.md
Normal file
43
.skills/openai-whisper-api/SKILL.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: openai-whisper-api
|
||||
description: Transcribe audio via OpenAI Audio Transcriptions API (Whisper).
|
||||
homepage: https://platform.openai.com/docs/guides/speech-to-text
|
||||
metadata: {"clawdbot":{"emoji":"☁️","requires":{"bins":["curl"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY"}}
|
||||
---
|
||||
|
||||
# OpenAI Whisper API (curl)
|
||||
|
||||
Transcribe an audio file via OpenAI’s `/v1/audio/transcriptions` endpoint.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
{baseDir}/scripts/transcribe.sh /path/to/audio.m4a
|
||||
```
|
||||
|
||||
Defaults:
|
||||
- Model: `whisper-1`
|
||||
- Output: `<input>.txt`
|
||||
|
||||
## Useful flags
|
||||
|
||||
```bash
|
||||
{baseDir}/scripts/transcribe.sh /path/to/audio.ogg --model whisper-1 --out /tmp/transcript.txt
|
||||
{baseDir}/scripts/transcribe.sh /path/to/audio.m4a --language en
|
||||
{baseDir}/scripts/transcribe.sh /path/to/audio.m4a --prompt "Speaker names: Peter, Daniel"
|
||||
{baseDir}/scripts/transcribe.sh /path/to/audio.m4a --json --out /tmp/transcript.json
|
||||
```
|
||||
|
||||
## API key
|
||||
|
||||
Set `OPENAI_API_KEY`, or configure it in `~/.clawdbot/clawdbot.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
skills: {
|
||||
"openai-whisper-api": {
|
||||
apiKey: "OPENAI_KEY_HERE"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
85
.skills/openai-whisper-api/scripts/transcribe.sh
Normal file
85
.skills/openai-whisper-api/scripts/transcribe.sh
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage:
|
||||
transcribe.sh <audio-file> [--model whisper-1] [--out /path/to/out.txt] [--language en] [--prompt "hint"] [--json]
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
in="${1:-}"
|
||||
shift || true
|
||||
|
||||
model="whisper-1"
|
||||
out=""
|
||||
language=""
|
||||
prompt=""
|
||||
response_format="text"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--model)
|
||||
model="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--out)
|
||||
out="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--language)
|
||||
language="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--prompt)
|
||||
prompt="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
response_format="json"
|
||||
shift 1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arg: $1" >&2
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$in" ]]; then
|
||||
echo "File not found: $in" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${OPENAI_API_KEY:-}" == "" ]]; then
|
||||
echo "Missing OPENAI_API_KEY" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$out" == "" ]]; then
|
||||
base="${in%.*}"
|
||||
if [[ "$response_format" == "json" ]]; then
|
||||
out="${base}.json"
|
||||
else
|
||||
out="${base}.txt"
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$out")"
|
||||
|
||||
curl -sS https://api.openai.com/v1/audio/transcriptions \
|
||||
-H "Authorization: Bearer $OPENAI_API_KEY" \
|
||||
-H "Accept: application/json" \
|
||||
-F "file=@${in}" \
|
||||
-F "model=${model}" \
|
||||
-F "response_format=${response_format}" \
|
||||
${language:+-F "language=${language}"} \
|
||||
${prompt:+-F "prompt=${prompt}"} \
|
||||
>"$out"
|
||||
|
||||
echo "$out"
|
||||
19
.skills/openai-whisper/SKILL.md
Normal file
19
.skills/openai-whisper/SKILL.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: openai-whisper
|
||||
description: Local speech-to-text with the Whisper CLI (no API key).
|
||||
homepage: https://openai.com/research/whisper
|
||||
metadata: {"clawdbot":{"emoji":"🎙️","requires":{"bins":["whisper"]},"install":[{"id":"brew","kind":"brew","formula":"openai-whisper","bins":["whisper"],"label":"Install OpenAI Whisper (brew)"}]}}
|
||||
---
|
||||
|
||||
# Whisper (CLI)
|
||||
|
||||
Use `whisper` to transcribe audio locally.
|
||||
|
||||
Quick start
|
||||
- `whisper /path/audio.mp3 --model medium --output_format txt --output_dir .`
|
||||
- `whisper /path/audio.m4a --task translate --output_format srt`
|
||||
|
||||
Notes
|
||||
- Models download to `~/.cache/whisper` on first run.
|
||||
- `--model` defaults to `turbo` on this install.
|
||||
- Use smaller models for speed, larger for accuracy.
|
||||
30
.skills/openhue/SKILL.md
Normal file
30
.skills/openhue/SKILL.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: openhue
|
||||
description: Control Philips Hue lights/scenes via the OpenHue CLI.
|
||||
homepage: https://www.openhue.io/cli
|
||||
metadata: {"clawdbot":{"emoji":"💡","requires":{"bins":["openhue"]},"install":[{"id":"brew","kind":"brew","formula":"openhue/cli/openhue-cli","bins":["openhue"],"label":"Install OpenHue CLI (brew)"}]}}
|
||||
---
|
||||
|
||||
# OpenHue CLI
|
||||
|
||||
Use `openhue` to control Hue lights and scenes via a Hue Bridge.
|
||||
|
||||
Setup
|
||||
- Discover bridges: `openhue discover`
|
||||
- Guided setup: `openhue setup`
|
||||
|
||||
Read
|
||||
- `openhue get light --json`
|
||||
- `openhue get room --json`
|
||||
- `openhue get scene --json`
|
||||
|
||||
Write
|
||||
- Turn on: `openhue set light <id-or-name> --on`
|
||||
- Turn off: `openhue set light <id-or-name> --off`
|
||||
- Brightness: `openhue set light <id> --on --brightness 50`
|
||||
- Color: `openhue set light <id> --on --rgb #3399FF`
|
||||
- Scene: `openhue set scene <scene-id>`
|
||||
|
||||
Notes
|
||||
- You may need to press the Hue Bridge button during setup.
|
||||
- Use `--room "Room Name"` when light names are ambiguous.
|
||||
105
.skills/oracle/SKILL.md
Normal file
105
.skills/oracle/SKILL.md
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: oracle
|
||||
description: Best practices for using the oracle CLI (prompt + file bundling, engines, sessions, and file attachment patterns).
|
||||
homepage: https://askoracle.dev
|
||||
metadata: {"clawdbot":{"emoji":"🧿","requires":{"bins":["oracle"]},"install":[{"id":"node","kind":"node","package":"@steipete/oracle","bins":["oracle"],"label":"Install oracle (node)"}]}}
|
||||
---
|
||||
|
||||
# oracle — best use
|
||||
|
||||
Oracle bundles your prompt + selected files into one “one-shot” request so another model can answer with real repo context (API or browser automation). Treat output as advisory: verify against code + tests.
|
||||
|
||||
## Main use case (browser, GPT‑5.2 Pro)
|
||||
|
||||
Default workflow here: `--engine browser` with GPT‑5.2 Pro in ChatGPT. This is the common “long think” path: ~10 minutes to ~1 hour is normal; expect a stored session you can reattach to.
|
||||
|
||||
Recommended defaults:
|
||||
- Engine: browser (`--engine browser`)
|
||||
- Model: GPT‑5.2 Pro (`--model gpt-5.2-pro` or `--model "5.2 Pro"`)
|
||||
|
||||
## Golden path
|
||||
|
||||
1. Pick a tight file set (fewest files that still contain the truth).
|
||||
2. Preview payload + token spend (`--dry-run` + `--files-report`).
|
||||
3. Use browser mode for the usual GPT‑5.2 Pro workflow; use API only when you explicitly want it.
|
||||
4. If the run detaches/timeouts: reattach to the stored session (don’t re-run).
|
||||
|
||||
## Commands (preferred)
|
||||
|
||||
- Help:
|
||||
- `oracle --help`
|
||||
- If the binary isn’t installed: `npx -y @steipete/oracle --help` (avoid `pnpx` here; sqlite bindings).
|
||||
|
||||
- Preview (no tokens):
|
||||
- `oracle --dry-run summary -p "<task>" --file "src/**" --file "!**/*.test.*"`
|
||||
- `oracle --dry-run full -p "<task>" --file "src/**"`
|
||||
|
||||
- Token sanity:
|
||||
- `oracle --dry-run summary --files-report -p "<task>" --file "src/**"`
|
||||
|
||||
- Browser run (main path; long-running is normal):
|
||||
- `oracle --engine browser --model gpt-5.2-pro -p "<task>" --file "src/**"`
|
||||
|
||||
- Manual paste fallback:
|
||||
- `oracle --render --copy -p "<task>" --file "src/**"`
|
||||
- Note: `--copy` is a hidden alias for `--copy-markdown`.
|
||||
|
||||
## Attaching files (`--file`)
|
||||
|
||||
`--file` accepts files, directories, and globs. You can pass it multiple times; entries can be comma-separated.
|
||||
|
||||
- Include:
|
||||
- `--file "src/**"`
|
||||
- `--file src/index.ts`
|
||||
- `--file docs --file README.md`
|
||||
|
||||
- Exclude:
|
||||
- `--file "src/**" --file "!src/**/*.test.ts" --file "!**/*.snap"`
|
||||
|
||||
- Defaults (implementation behavior):
|
||||
- Default-ignored dirs: `node_modules`, `dist`, `coverage`, `.git`, `.turbo`, `.next`, `build`, `tmp` (skipped unless explicitly passed as literal dirs/files).
|
||||
- Honors `.gitignore` when expanding globs.
|
||||
- Does not follow symlinks.
|
||||
- Dotfiles filtered unless opted in via pattern (e.g. `--file ".github/**"`).
|
||||
- Files > 1 MB rejected.
|
||||
|
||||
## Engines (API vs browser)
|
||||
|
||||
- Auto-pick: `api` when `OPENAI_API_KEY` is set; otherwise `browser`.
|
||||
- Browser supports GPT + Gemini only; use `--engine api` for Claude/Grok/Codex or multi-model runs.
|
||||
- Browser attachments:
|
||||
- `--browser-attachments auto|never|always` (auto pastes inline up to ~60k chars then uploads).
|
||||
- Remote browser host:
|
||||
- Host: `oracle serve --host 0.0.0.0 --port 9473 --token <secret>`
|
||||
- Client: `oracle --engine browser --remote-host <host:port> --remote-token <secret> -p "<task>" --file "src/**"`
|
||||
|
||||
## Sessions + slugs
|
||||
|
||||
- Stored under `~/.oracle/sessions` (override with `ORACLE_HOME_DIR`).
|
||||
- Runs may detach or take a long time (browser + GPT‑5.2 Pro often does). If the CLI times out: don’t re-run; reattach.
|
||||
- List: `oracle status --hours 72`
|
||||
- Attach: `oracle session <id> --render`
|
||||
- Use `--slug "<3-5 words>"` to keep session IDs readable.
|
||||
- Duplicate prompt guard exists; use `--force` only when you truly want a fresh run.
|
||||
|
||||
## Prompt template (high signal)
|
||||
|
||||
Oracle starts with **zero** project knowledge. Assume the model cannot infer your stack, build tooling, conventions, or “obvious” paths. Include:
|
||||
- Project briefing (stack + build/test commands + platform constraints).
|
||||
- “Where things live” (key directories, entrypoints, config files, boundaries).
|
||||
- Exact question + what you tried + the error text (verbatim).
|
||||
- Constraints (“don’t change X”, “must keep public API”, etc).
|
||||
- Desired output (“return patch plan + tests”, “give 3 options with tradeoffs”).
|
||||
|
||||
## Safety
|
||||
|
||||
- Don’t attach secrets by default (`.env`, key files, auth tokens). Redact aggressively; share only what’s required.
|
||||
|
||||
## “Exhaustive prompt” restoration pattern
|
||||
|
||||
For long investigations, write a standalone prompt + file set so you can rerun days later:
|
||||
- 6–30 sentence project briefing + the goal.
|
||||
- Repro steps + exact errors + what you tried.
|
||||
- Attach all context files needed (entrypoints, configs, key modules, docs).
|
||||
|
||||
Oracle runs are one-shot; the model doesn’t remember prior runs. “Restoring context” means re-running with the same prompt + `--file …` set (or reattaching a still-running stored session).
|
||||
47
.skills/ordercli/SKILL.md
Normal file
47
.skills/ordercli/SKILL.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: ordercli
|
||||
description: Foodora-only CLI for checking past orders and active order status (Deliveroo WIP).
|
||||
homepage: https://ordercli.sh
|
||||
metadata: {"clawdbot":{"emoji":"🛵","requires":{"bins":["ordercli"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/ordercli","bins":["ordercli"],"label":"Install ordercli (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/ordercli/cmd/ordercli@latest","bins":["ordercli"],"label":"Install ordercli (go)"}]}}
|
||||
---
|
||||
|
||||
# ordercli
|
||||
|
||||
Use `ordercli` to check past orders and track active order status (Foodora only right now).
|
||||
|
||||
Quick start (Foodora)
|
||||
- `ordercli foodora countries`
|
||||
- `ordercli foodora config set --country AT`
|
||||
- `ordercli foodora login --email you@example.com --password-stdin`
|
||||
- `ordercli foodora orders`
|
||||
- `ordercli foodora history --limit 20`
|
||||
- `ordercli foodora history show <orderCode>`
|
||||
|
||||
Orders
|
||||
- Active list (arrival/status): `ordercli foodora orders`
|
||||
- Watch: `ordercli foodora orders --watch`
|
||||
- Active order detail: `ordercli foodora order <orderCode>`
|
||||
- History detail JSON: `ordercli foodora history show <orderCode> --json`
|
||||
|
||||
Reorder (adds to cart)
|
||||
- Preview: `ordercli foodora reorder <orderCode>`
|
||||
- Confirm: `ordercli foodora reorder <orderCode> --confirm`
|
||||
- Address: `ordercli foodora reorder <orderCode> --confirm --address-id <id>`
|
||||
|
||||
Cloudflare / bot protection
|
||||
- Browser login: `ordercli foodora login --email you@example.com --password-stdin --browser`
|
||||
- Reuse profile: `--browser-profile "$HOME/Library/Application Support/ordercli/browser-profile"`
|
||||
- Import Chrome cookies: `ordercli foodora cookies chrome --profile "Default"`
|
||||
|
||||
Session import (no password)
|
||||
- `ordercli foodora session chrome --url https://www.foodora.at/ --profile "Default"`
|
||||
- `ordercli foodora session refresh --client-id android`
|
||||
|
||||
Deliveroo (WIP, not working yet)
|
||||
- Requires `DELIVEROO_BEARER_TOKEN` (optional `DELIVEROO_COOKIE`).
|
||||
- `ordercli deliveroo config set --market uk`
|
||||
- `ordercli deliveroo history`
|
||||
|
||||
Notes
|
||||
- Use `--config /tmp/ordercli.json` for testing.
|
||||
- Confirm before any reorder or cart-changing action.
|
||||
153
.skills/peekaboo/SKILL.md
Normal file
153
.skills/peekaboo/SKILL.md
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
name: peekaboo
|
||||
description: Capture and automate macOS UI with the Peekaboo CLI.
|
||||
homepage: https://peekaboo.boo
|
||||
metadata: {"clawdbot":{"emoji":"👀","os":["darwin"],"requires":{"bins":["peekaboo"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/peekaboo","bins":["peekaboo"],"label":"Install Peekaboo (brew)"}]}}
|
||||
---
|
||||
|
||||
# Peekaboo
|
||||
|
||||
Peekaboo is a full macOS UI automation CLI: capture/inspect screens, target UI
|
||||
elements, drive input, and manage apps/windows/menus. Commands share a snapshot
|
||||
cache and support `--json`/`-j` for scripting. Run `peekaboo` or
|
||||
`peekaboo <cmd> --help` for flags; `peekaboo --version` prints build metadata.
|
||||
Tip: run via `polter peekaboo` to ensure fresh builds.
|
||||
|
||||
## Features (all CLI capabilities, excluding agent/MCP)
|
||||
|
||||
Core
|
||||
- `bridge`: inspect Peekaboo Bridge host connectivity
|
||||
- `capture`: live capture or video ingest + frame extraction
|
||||
- `clean`: prune snapshot cache and temp files
|
||||
- `config`: init/show/edit/validate, providers, models, credentials
|
||||
- `image`: capture screenshots (screen/window/menu bar regions)
|
||||
- `learn`: print the full agent guide + tool catalog
|
||||
- `list`: apps, windows, screens, menubar, permissions
|
||||
- `permissions`: check Screen Recording/Accessibility status
|
||||
- `run`: execute `.peekaboo.json` scripts
|
||||
- `sleep`: pause execution for a duration
|
||||
- `tools`: list available tools with filtering/display options
|
||||
|
||||
Interaction
|
||||
- `click`: target by ID/query/coords with smart waits
|
||||
- `drag`: drag & drop across elements/coords/Dock
|
||||
- `hotkey`: modifier combos like `cmd,shift,t`
|
||||
- `move`: cursor positioning with optional smoothing
|
||||
- `paste`: set clipboard -> paste -> restore
|
||||
- `press`: special-key sequences with repeats
|
||||
- `scroll`: directional scrolling (targeted + smooth)
|
||||
- `swipe`: gesture-style drags between targets
|
||||
- `type`: text + control keys (`--clear`, delays)
|
||||
|
||||
System
|
||||
- `app`: launch/quit/relaunch/hide/unhide/switch/list apps
|
||||
- `clipboard`: read/write clipboard (text/images/files)
|
||||
- `dialog`: click/input/file/dismiss/list system dialogs
|
||||
- `dock`: launch/right-click/hide/show/list Dock items
|
||||
- `menu`: click/list application menus + menu extras
|
||||
- `menubar`: list/click status bar items
|
||||
- `open`: enhanced `open` with app targeting + JSON payloads
|
||||
- `space`: list/switch/move-window (Spaces)
|
||||
- `visualizer`: exercise Peekaboo visual feedback animations
|
||||
- `window`: close/minimize/maximize/move/resize/focus/list
|
||||
|
||||
Vision
|
||||
- `see`: annotated UI maps, snapshot IDs, optional analysis
|
||||
|
||||
Global runtime flags
|
||||
- `--json`/`-j`, `--verbose`/`-v`, `--log-level <level>`
|
||||
- `--no-remote`, `--bridge-socket <path>`
|
||||
|
||||
## Quickstart (happy path)
|
||||
```bash
|
||||
peekaboo permissions
|
||||
peekaboo list apps --json
|
||||
peekaboo see --annotate --path /tmp/peekaboo-see.png
|
||||
peekaboo click --on B1
|
||||
peekaboo type "Hello" --return
|
||||
```
|
||||
|
||||
## Common targeting parameters (most interaction commands)
|
||||
- App/window: `--app`, `--pid`, `--window-title`, `--window-id`, `--window-index`
|
||||
- Snapshot targeting: `--snapshot` (ID from `see`; defaults to latest)
|
||||
- Element/coords: `--on`/`--id` (element ID), `--coords x,y`
|
||||
- Focus control: `--no-auto-focus`, `--space-switch`, `--bring-to-current-space`,
|
||||
`--focus-timeout-seconds`, `--focus-retry-count`
|
||||
|
||||
## Common capture parameters
|
||||
- Output: `--path`, `--format png|jpg`, `--retina`
|
||||
- Targeting: `--mode screen|window|frontmost`, `--screen-index`,
|
||||
`--window-title`, `--window-id`
|
||||
- Analysis: `--analyze "prompt"`, `--annotate`
|
||||
- Capture engine: `--capture-engine auto|classic|cg|modern|sckit`
|
||||
|
||||
## Common motion/typing parameters
|
||||
- Timing: `--duration` (drag/swipe), `--steps`, `--delay` (type/scroll/press)
|
||||
- Human-ish movement: `--profile human|linear`, `--wpm` (typing)
|
||||
- Scroll: `--direction up|down|left|right`, `--amount <ticks>`, `--smooth`
|
||||
|
||||
## Examples
|
||||
### See -> click -> type (most reliable flow)
|
||||
```bash
|
||||
peekaboo see --app Safari --window-title "Login" --annotate --path /tmp/see.png
|
||||
peekaboo click --on B3 --app Safari
|
||||
peekaboo type "user@example.com" --app Safari
|
||||
peekaboo press tab --count 1 --app Safari
|
||||
peekaboo type "supersecret" --app Safari --return
|
||||
```
|
||||
|
||||
### Target by window id
|
||||
```bash
|
||||
peekaboo list windows --app "Visual Studio Code" --json
|
||||
peekaboo click --window-id 12345 --coords 120,160
|
||||
peekaboo type "Hello from Peekaboo" --window-id 12345
|
||||
```
|
||||
|
||||
### Capture screenshots + analyze
|
||||
```bash
|
||||
peekaboo image --mode screen --screen-index 0 --retina --path /tmp/screen.png
|
||||
peekaboo image --app Safari --window-title "Dashboard" --analyze "Summarize KPIs"
|
||||
peekaboo see --mode screen --screen-index 0 --analyze "Summarize the dashboard"
|
||||
```
|
||||
|
||||
### Live capture (motion-aware)
|
||||
```bash
|
||||
peekaboo capture live --mode region --region 100,100,800,600 --duration 30 \
|
||||
--active-fps 8 --idle-fps 2 --highlight-changes --path /tmp/capture
|
||||
```
|
||||
|
||||
### App + window management
|
||||
```bash
|
||||
peekaboo app launch "Safari" --open https://example.com
|
||||
peekaboo window focus --app Safari --window-title "Example"
|
||||
peekaboo window set-bounds --app Safari --x 50 --y 50 --width 1200 --height 800
|
||||
peekaboo app quit --app Safari
|
||||
```
|
||||
|
||||
### Menus, menubar, dock
|
||||
```bash
|
||||
peekaboo menu click --app Safari --item "New Window"
|
||||
peekaboo menu click --app TextEdit --path "Format > Font > Show Fonts"
|
||||
peekaboo menu click-extra --title "WiFi"
|
||||
peekaboo dock launch Safari
|
||||
peekaboo menubar list --json
|
||||
```
|
||||
|
||||
### Mouse + gesture input
|
||||
```bash
|
||||
peekaboo move 500,300 --smooth
|
||||
peekaboo drag --from B1 --to T2
|
||||
peekaboo swipe --from-coords 100,500 --to-coords 100,200 --duration 800
|
||||
peekaboo scroll --direction down --amount 6 --smooth
|
||||
```
|
||||
|
||||
### Keyboard input
|
||||
```bash
|
||||
peekaboo hotkey --keys "cmd,shift,t"
|
||||
peekaboo press escape
|
||||
peekaboo type "Line 1\nLine 2" --delay 10
|
||||
```
|
||||
|
||||
Notes
|
||||
- Requires Screen Recording + Accessibility permissions.
|
||||
- Use `peekaboo see --annotate` to identify targets before clicking.
|
||||
62
.skills/sag/SKILL.md
Normal file
62
.skills/sag/SKILL.md
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: sag
|
||||
description: ElevenLabs text-to-speech with mac-style say UX.
|
||||
homepage: https://sag.sh
|
||||
metadata: {"clawdbot":{"emoji":"🗣️","requires":{"bins":["sag"],"env":["ELEVENLABS_API_KEY"]},"primaryEnv":"ELEVENLABS_API_KEY","install":[{"id":"brew","kind":"brew","formula":"steipete/tap/sag","bins":["sag"],"label":"Install sag (brew)"}]}}
|
||||
---
|
||||
|
||||
# sag
|
||||
|
||||
Use `sag` for ElevenLabs TTS with local playback.
|
||||
|
||||
API key (required)
|
||||
- `ELEVENLABS_API_KEY` (preferred)
|
||||
- `SAG_API_KEY` also supported by the CLI
|
||||
|
||||
Quick start
|
||||
- `sag "Hello there"`
|
||||
- `sag speak -v "Roger" "Hello"`
|
||||
- `sag voices`
|
||||
- `sag prompting` (model-specific tips)
|
||||
|
||||
Model notes
|
||||
- Default: `eleven_v3` (expressive)
|
||||
- Stable: `eleven_multilingual_v2`
|
||||
- Fast: `eleven_flash_v2_5`
|
||||
|
||||
Pronunciation + delivery rules
|
||||
- First fix: respell (e.g. "key-note"), add hyphens, adjust casing.
|
||||
- Numbers/units/URLs: `--normalize auto` (or `off` if it harms names).
|
||||
- Language bias: `--lang en|de|fr|...` to guide normalization.
|
||||
- v3: SSML `<break>` not supported; use `[pause]`, `[short pause]`, `[long pause]`.
|
||||
- v2/v2.5: SSML `<break time="1.5s" />` supported; `<phoneme>` not exposed in `sag`.
|
||||
|
||||
v3 audio tags (put at the entrance of a line)
|
||||
- `[whispers]`, `[shouts]`, `[sings]`
|
||||
- `[laughs]`, `[starts laughing]`, `[sighs]`, `[exhales]`
|
||||
- `[sarcastic]`, `[curious]`, `[excited]`, `[crying]`, `[mischievously]`
|
||||
- Example: `sag "[whispers] keep this quiet. [short pause] ok?"`
|
||||
|
||||
Voice defaults
|
||||
- `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`
|
||||
|
||||
Confirm voice + speaker before long output.
|
||||
|
||||
## Chat voice responses
|
||||
|
||||
When Peter asks for a "voice" reply (e.g., "crazy scientist voice", "explain in voice"), generate audio and send it:
|
||||
|
||||
```bash
|
||||
# Generate audio file
|
||||
sag -v Clawd -o /tmp/voice-reply.mp3 "Your message here"
|
||||
|
||||
# Then include in reply:
|
||||
# MEDIA:/tmp/voice-reply.mp3
|
||||
```
|
||||
|
||||
Voice character tips:
|
||||
- Crazy scientist: Use `[excited]` tags, dramatic pauses `[short pause]`, vary intensity
|
||||
- Calm: Use `[whispers]` or slower pacing
|
||||
- Dramatic: Use `[sings]` or `[shouts]` sparingly
|
||||
|
||||
Default voice for Clawd: `lj2rcrvANS3gaWWnczSX` (or just `-v Clawd`)
|
||||
49
.skills/sherpa-onnx-tts/SKILL.md
Normal file
49
.skills/sherpa-onnx-tts/SKILL.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: sherpa-onnx-tts
|
||||
description: Local text-to-speech via sherpa-onnx (offline, no cloud)
|
||||
metadata: {"clawdbot":{"emoji":"🗣️","os":["darwin","linux","win32"],"requires":{"env":["SHERPA_ONNX_RUNTIME_DIR","SHERPA_ONNX_MODEL_DIR"]},"install":[{"id":"download-runtime-macos","kind":"download","os":["darwin"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (macOS)"},{"id":"download-runtime-linux-x64","kind":"download","os":["linux"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-linux-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Linux x64)"},{"id":"download-runtime-win-x64","kind":"download","os":["win32"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-win-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Windows x64)"},{"id":"download-model-lessac","kind":"download","url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-lessac-high.tar.bz2","archive":"tar.bz2","extract":true,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/models","label":"Download Piper en_US lessac (high)"}]}}
|
||||
---
|
||||
|
||||
# sherpa-onnx-tts
|
||||
|
||||
Local TTS using the sherpa-onnx offline CLI.
|
||||
|
||||
## Install
|
||||
|
||||
1) Download the runtime for your OS (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/runtime`)
|
||||
2) Download a voice model (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/models`)
|
||||
|
||||
Update `~/.clawdbot/clawdbot.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
skills: {
|
||||
entries: {
|
||||
"sherpa-onnx-tts": {
|
||||
env: {
|
||||
SHERPA_ONNX_RUNTIME_DIR: "~/.clawdbot/tools/sherpa-onnx-tts/runtime",
|
||||
SHERPA_ONNX_MODEL_DIR: "~/.clawdbot/tools/sherpa-onnx-tts/models/vits-piper-en_US-lessac-high"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The wrapper lives in this skill folder. Run it directly, or add the wrapper to PATH:
|
||||
|
||||
```bash
|
||||
export PATH="{baseDir}/bin:$PATH"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
{baseDir}/bin/sherpa-onnx-tts -o ./tts.wav "Hello from local TTS."
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Pick a different model from the sherpa-onnx `tts-models` release if you want another voice.
|
||||
- If the model dir has multiple `.onnx` files, set `SHERPA_ONNX_MODEL_FILE` or pass `--model-file`.
|
||||
- You can also pass `--tokens-file` or `--data-dir` to override the defaults.
|
||||
- Windows: run `node {baseDir}\\bin\\sherpa-onnx-tts -o tts.wav "Hello from local TTS."`
|
||||
178
.skills/sherpa-onnx-tts/bin/sherpa-onnx-tts
Executable file
178
.skills/sherpa-onnx-tts/bin/sherpa-onnx-tts
Executable file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
function usage(message) {
|
||||
if (message) {
|
||||
console.error(message);
|
||||
}
|
||||
console.error(
|
||||
"\nUsage: sherpa-onnx-tts [--runtime-dir <dir>] [--model-dir <dir>] [--model-file <file>] [--tokens-file <file>] [--data-dir <dir>] [--output <file>] \"text\"",
|
||||
);
|
||||
console.error("\nRequired env (or flags):\n SHERPA_ONNX_RUNTIME_DIR\n SHERPA_ONNX_MODEL_DIR");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function resolveRuntimeDir(explicit) {
|
||||
const value = explicit || process.env.SHERPA_ONNX_RUNTIME_DIR || "";
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function resolveModelDir(explicit) {
|
||||
const value = explicit || process.env.SHERPA_ONNX_MODEL_DIR || "";
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function resolveModelFile(modelDir, explicitFlag) {
|
||||
const explicit = (explicitFlag || process.env.SHERPA_ONNX_MODEL_FILE || "").trim();
|
||||
if (explicit) return explicit;
|
||||
try {
|
||||
const candidates = fs
|
||||
.readdirSync(modelDir)
|
||||
.filter((entry) => entry.endsWith(".onnx"))
|
||||
.map((entry) => path.join(modelDir, entry));
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveTokensFile(modelDir, explicitFlag) {
|
||||
const explicit = (explicitFlag || process.env.SHERPA_ONNX_TOKENS_FILE || "").trim();
|
||||
if (explicit) return explicit;
|
||||
const candidate = path.join(modelDir, "tokens.txt");
|
||||
return fs.existsSync(candidate) ? candidate : "";
|
||||
}
|
||||
|
||||
function resolveDataDir(modelDir, explicitFlag) {
|
||||
const explicit = (explicitFlag || process.env.SHERPA_ONNX_DATA_DIR || "").trim();
|
||||
if (explicit) return explicit;
|
||||
const candidate = path.join(modelDir, "espeak-ng-data");
|
||||
return fs.existsSync(candidate) ? candidate : "";
|
||||
}
|
||||
|
||||
function resolveBinary(runtimeDir) {
|
||||
const binName = process.platform === "win32" ? "sherpa-onnx-offline-tts.exe" : "sherpa-onnx-offline-tts";
|
||||
return path.join(runtimeDir, "bin", binName);
|
||||
}
|
||||
|
||||
function prependEnvPath(current, next) {
|
||||
if (!next) return current;
|
||||
if (!current) return next;
|
||||
return `${next}${path.delimiter}${current}`;
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
let runtimeDir = "";
|
||||
let modelDir = "";
|
||||
let modelFile = "";
|
||||
let tokensFile = "";
|
||||
let dataDir = "";
|
||||
let output = "tts.wav";
|
||||
const textParts = [];
|
||||
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === "--runtime-dir") {
|
||||
runtimeDir = args[i + 1] || "";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--model-dir") {
|
||||
modelDir = args[i + 1] || "";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--model-file") {
|
||||
modelFile = args[i + 1] || "";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tokens-file") {
|
||||
tokensFile = args[i + 1] || "";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--data-dir") {
|
||||
dataDir = args[i + 1] || "";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "-o" || arg === "--output") {
|
||||
output = args[i + 1] || output;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--text") {
|
||||
textParts.push(args[i + 1] || "");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
textParts.push(arg);
|
||||
}
|
||||
|
||||
runtimeDir = resolveRuntimeDir(runtimeDir);
|
||||
modelDir = resolveModelDir(modelDir);
|
||||
|
||||
if (!runtimeDir || !modelDir) {
|
||||
usage("Missing runtime/model directory.");
|
||||
}
|
||||
|
||||
modelFile = resolveModelFile(modelDir, modelFile);
|
||||
tokensFile = resolveTokensFile(modelDir, tokensFile);
|
||||
dataDir = resolveDataDir(modelDir, dataDir);
|
||||
|
||||
if (!modelFile || !tokensFile || !dataDir) {
|
||||
usage(
|
||||
"Model directory is missing required files. Set SHERPA_ONNX_MODEL_FILE, SHERPA_ONNX_TOKENS_FILE, SHERPA_ONNX_DATA_DIR or pass --model-file/--tokens-file/--data-dir.",
|
||||
);
|
||||
}
|
||||
|
||||
const text = textParts.join(" ").trim();
|
||||
if (!text) {
|
||||
usage("Missing text.");
|
||||
}
|
||||
|
||||
const bin = resolveBinary(runtimeDir);
|
||||
if (!fs.existsSync(bin)) {
|
||||
usage(`TTS binary not found: ${bin}`);
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
const libDir = path.join(runtimeDir, "lib");
|
||||
if (process.platform === "darwin") {
|
||||
env.DYLD_LIBRARY_PATH = prependEnvPath(env.DYLD_LIBRARY_PATH || "", libDir);
|
||||
} else if (process.platform === "win32") {
|
||||
env.PATH = prependEnvPath(env.PATH || "", [path.join(runtimeDir, "bin"), libDir].join(path.delimiter));
|
||||
} else {
|
||||
env.LD_LIBRARY_PATH = prependEnvPath(env.LD_LIBRARY_PATH || "", libDir);
|
||||
}
|
||||
|
||||
const outputPath = path.isAbsolute(output) ? output : path.join(process.cwd(), output);
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const child = spawnSync(
|
||||
bin,
|
||||
[
|
||||
`--vits-model=${modelFile}`,
|
||||
`--vits-tokens=${tokensFile}`,
|
||||
`--vits-data-dir=${dataDir}`,
|
||||
`--output-filename=${outputPath}`,
|
||||
text,
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env,
|
||||
},
|
||||
);
|
||||
|
||||
if (typeof child.status === "number") {
|
||||
process.exit(child.status);
|
||||
}
|
||||
if (child.error) {
|
||||
console.error(child.error.message || String(child.error));
|
||||
}
|
||||
process.exit(1);
|
||||
29
.skills/songsee/SKILL.md
Normal file
29
.skills/songsee/SKILL.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: songsee
|
||||
description: Generate spectrograms and feature-panel visualizations from audio with the songsee CLI.
|
||||
homepage: https://github.com/steipete/songsee
|
||||
metadata: {"clawdbot":{"emoji":"🌊","requires":{"bins":["songsee"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/songsee","bins":["songsee"],"label":"Install songsee (brew)"}]}}
|
||||
---
|
||||
|
||||
# songsee
|
||||
|
||||
Generate spectrograms + feature panels from audio.
|
||||
|
||||
Quick start
|
||||
- Spectrogram: `songsee track.mp3`
|
||||
- Multi-panel: `songsee track.mp3 --viz spectrogram,mel,chroma,hpss,selfsim,loudness,tempogram,mfcc,flux`
|
||||
- Time slice: `songsee track.mp3 --start 12.5 --duration 8 -o slice.jpg`
|
||||
- Stdin: `cat track.mp3 | songsee - --format png -o out.png`
|
||||
|
||||
Common flags
|
||||
- `--viz` list (repeatable or comma-separated)
|
||||
- `--style` palette (classic, magma, inferno, viridis, gray)
|
||||
- `--width` / `--height` output size
|
||||
- `--window` / `--hop` FFT settings
|
||||
- `--min-freq` / `--max-freq` frequency range
|
||||
- `--start` / `--duration` time slice
|
||||
- `--format` jpg|png
|
||||
|
||||
Notes
|
||||
- WAV/MP3 decode native; other formats use ffmpeg if available.
|
||||
- Multiple `--viz` renders a grid.
|
||||
26
.skills/sonoscli/SKILL.md
Normal file
26
.skills/sonoscli/SKILL.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: sonoscli
|
||||
description: Control Sonos speakers (discover/status/play/volume/group).
|
||||
homepage: https://sonoscli.sh
|
||||
metadata: {"clawdbot":{"emoji":"🔊","requires":{"bins":["sonos"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/sonoscli/cmd/sonos@latest","bins":["sonos"],"label":"Install sonoscli (go)"}]}}
|
||||
---
|
||||
|
||||
# Sonos CLI
|
||||
|
||||
Use `sonos` to control Sonos speakers on the local network.
|
||||
|
||||
Quick start
|
||||
- `sonos discover`
|
||||
- `sonos status --name "Kitchen"`
|
||||
- `sonos play|pause|stop --name "Kitchen"`
|
||||
- `sonos volume set 15 --name "Kitchen"`
|
||||
|
||||
Common tasks
|
||||
- Grouping: `sonos group status|join|unjoin|party|solo`
|
||||
- Favorites: `sonos favorites list|open`
|
||||
- Queue: `sonos queue list|play|clear`
|
||||
- Spotify search (via SMAPI): `sonos smapi search --service "Spotify" --category tracks "query"`
|
||||
|
||||
Notes
|
||||
- If SSDP fails, specify `--ip <speaker-ip>`.
|
||||
- Spotify Web API search is optional and requires `SPOTIFY_CLIENT_ID/SECRET`.
|
||||
34
.skills/spotify-player/SKILL.md
Normal file
34
.skills/spotify-player/SKILL.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: spotify-player
|
||||
description: Terminal Spotify playback/search via spogo (preferred) or spotify_player.
|
||||
homepage: https://www.spotify.com
|
||||
metadata: {"clawdbot":{"emoji":"🎵","requires":{"anyBins":["spogo","spotify_player"]},"install":[{"id":"brew","kind":"brew","formula":"spogo","tap":"steipete/tap","bins":["spogo"],"label":"Install spogo (brew)"},{"id":"brew","kind":"brew","formula":"spotify_player","bins":["spotify_player"],"label":"Install spotify_player (brew)"}]}}
|
||||
---
|
||||
|
||||
# spogo / spotify_player
|
||||
|
||||
Use `spogo` **(preferred)** for Spotify playback/search. Fall back to `spotify_player` if needed.
|
||||
|
||||
Requirements
|
||||
- Spotify Premium account.
|
||||
- Either `spogo` or `spotify_player` installed.
|
||||
|
||||
spogo setup
|
||||
- Import cookies: `spogo auth import --browser chrome`
|
||||
|
||||
Common CLI commands
|
||||
- Search: `spogo search track "query"`
|
||||
- Playback: `spogo play|pause|next|prev`
|
||||
- Devices: `spogo device list`, `spogo device set "<name|id>"`
|
||||
- Status: `spogo status`
|
||||
|
||||
spotify_player commands (fallback)
|
||||
- Search: `spotify_player search "query"`
|
||||
- Playback: `spotify_player playback play|pause|next|previous`
|
||||
- Connect device: `spotify_player connect`
|
||||
- Like track: `spotify_player like`
|
||||
|
||||
Notes
|
||||
- Config folder: `~/.config/spotify-player` (e.g., `app.toml`).
|
||||
- For Spotify Connect integration, set a user `client_id` in config.
|
||||
- TUI shortcuts are available via `?` in the app.
|
||||
67
.skills/summarize/SKILL.md
Normal file
67
.skills/summarize/SKILL.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: summarize
|
||||
description: Summarize or extract text/transcripts from URLs, podcasts, and local files (great fallback for “transcribe this YouTube/video”).
|
||||
homepage: https://summarize.sh
|
||||
metadata: {"clawdbot":{"emoji":"🧾","requires":{"bins":["summarize"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/summarize","bins":["summarize"],"label":"Install summarize (brew)"}]}}
|
||||
---
|
||||
|
||||
# Summarize
|
||||
|
||||
Fast CLI to summarize URLs, local files, and YouTube links.
|
||||
|
||||
## When to use (trigger phrases)
|
||||
|
||||
Use this skill immediately when the user asks any of:
|
||||
- “use summarize.sh”
|
||||
- “what’s this link/video about?”
|
||||
- “summarize this URL/article”
|
||||
- “transcribe this YouTube/video” (best-effort transcript extraction; no `yt-dlp` needed)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
summarize "https://example.com" --model google/gemini-3-flash-preview
|
||||
summarize "/path/to/file.pdf" --model google/gemini-3-flash-preview
|
||||
summarize "https://youtu.be/dQw4w9WgXcQ" --youtube auto
|
||||
```
|
||||
|
||||
## YouTube: summary vs transcript
|
||||
|
||||
Best-effort transcript (URLs only):
|
||||
|
||||
```bash
|
||||
summarize "https://youtu.be/dQw4w9WgXcQ" --youtube auto --extract-only
|
||||
```
|
||||
|
||||
If the user asked for a transcript but it’s huge, return a tight summary first, then ask which section/time range to expand.
|
||||
|
||||
## Model + keys
|
||||
|
||||
Set the API key for your chosen provider:
|
||||
- OpenAI: `OPENAI_API_KEY`
|
||||
- Anthropic: `ANTHROPIC_API_KEY`
|
||||
- xAI: `XAI_API_KEY`
|
||||
- Google: `GEMINI_API_KEY` (aliases: `GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY`)
|
||||
|
||||
Default model is `google/gemini-3-flash-preview` if none is set.
|
||||
|
||||
## Useful flags
|
||||
|
||||
- `--length short|medium|long|xl|xxl|<chars>`
|
||||
- `--max-output-tokens <count>`
|
||||
- `--extract-only` (URLs only)
|
||||
- `--json` (machine readable)
|
||||
- `--firecrawl auto|off|always` (fallback extraction)
|
||||
- `--youtube auto` (Apify fallback if `APIFY_API_TOKEN` set)
|
||||
|
||||
## Config
|
||||
|
||||
Optional config file: `~/.summarize/config.json`
|
||||
|
||||
```json
|
||||
{ "model": "openai/gpt-5.2" }
|
||||
```
|
||||
|
||||
Optional services:
|
||||
- `FIRECRAWL_API_KEY` for blocked sites
|
||||
- `APIFY_API_TOKEN` for YouTube fallback
|
||||
61
.skills/things-mac/SKILL.md
Normal file
61
.skills/things-mac/SKILL.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: things-mac
|
||||
description: Manage Things 3 via the `things` CLI on macOS (add/update projects+todos via URL scheme; read/search/list from the local Things database). Use when a user asks Clawdbot to add a task to Things, list inbox/today/upcoming, search tasks, or inspect projects/areas/tags.
|
||||
homepage: https://github.com/ossianhempel/things3-cli
|
||||
metadata: {"clawdbot":{"emoji":"✅","os":["darwin"],"requires":{"bins":["things"]},"install":[{"id":"go","kind":"go","module":"github.com/ossianhempel/things3-cli/cmd/things@latest","bins":["things"],"label":"Install things3-cli (go)"}]}}
|
||||
---
|
||||
|
||||
# Things 3 CLI
|
||||
|
||||
Use `things` to read your local Things database (inbox/today/search/projects/areas/tags) and to add/update todos via the Things URL scheme.
|
||||
|
||||
Setup
|
||||
- Install (recommended, Apple Silicon): `GOBIN=/opt/homebrew/bin go install github.com/ossianhempel/things3-cli/cmd/things@latest`
|
||||
- If DB reads fail: grant **Full Disk Access** to the calling app (Terminal for manual runs; `Clawdbot.app` for gateway runs).
|
||||
- Optional: set `THINGSDB` (or pass `--db`) to point at your `ThingsData-*` folder.
|
||||
- Optional: set `THINGS_AUTH_TOKEN` to avoid passing `--auth-token` for update ops.
|
||||
|
||||
Read-only (DB)
|
||||
- `things inbox --limit 50`
|
||||
- `things today`
|
||||
- `things upcoming`
|
||||
- `things search "query"`
|
||||
- `things projects` / `things areas` / `things tags`
|
||||
|
||||
Write (URL scheme)
|
||||
- Prefer safe preview: `things --dry-run add "Title"`
|
||||
- Add: `things add "Title" --notes "..." --when today --deadline 2026-01-02`
|
||||
- Bring Things to front: `things --foreground add "Title"`
|
||||
|
||||
Examples: add a todo
|
||||
- Basic: `things add "Buy milk"`
|
||||
- With notes: `things add "Buy milk" --notes "2% + bananas"`
|
||||
- Into a project/area: `things add "Book flights" --list "Travel"`
|
||||
- Into a project heading: `things add "Pack charger" --list "Travel" --heading "Before"`
|
||||
- With tags: `things add "Call dentist" --tags "health,phone"`
|
||||
- Checklist: `things add "Trip prep" --checklist-item "Passport" --checklist-item "Tickets"`
|
||||
- From STDIN (multi-line => title + notes):
|
||||
- `cat <<'EOF' | things add -`
|
||||
- `Title line`
|
||||
- `Notes line 1`
|
||||
- `Notes line 2`
|
||||
- `EOF`
|
||||
|
||||
Examples: modify a todo (needs auth token)
|
||||
- First: get the ID (UUID column): `things search "milk" --limit 5`
|
||||
- Auth: set `THINGS_AUTH_TOKEN` or pass `--auth-token <TOKEN>`
|
||||
- Title: `things update --id <UUID> --auth-token <TOKEN> "New title"`
|
||||
- Notes replace: `things update --id <UUID> --auth-token <TOKEN> --notes "New notes"`
|
||||
- Notes append/prepend: `things update --id <UUID> --auth-token <TOKEN> --append-notes "..."` / `--prepend-notes "..."`
|
||||
- Move lists: `things update --id <UUID> --auth-token <TOKEN> --list "Travel" --heading "Before"`
|
||||
- Tags replace/add: `things update --id <UUID> --auth-token <TOKEN> --tags "a,b"` / `things update --id <UUID> --auth-token <TOKEN> --add-tags "a,b"`
|
||||
- Complete/cancel (soft-delete-ish): `things update --id <UUID> --auth-token <TOKEN> --completed` / `--canceled`
|
||||
- Safe preview: `things --dry-run update --id <UUID> --auth-token <TOKEN> --completed`
|
||||
|
||||
Delete a todo?
|
||||
- Not supported by `things3-cli` right now (no “delete/move-to-trash” write command; `things trash` is read-only listing).
|
||||
- Options: use Things UI to delete/trash, or mark as `--completed` / `--canceled` via `things update`.
|
||||
|
||||
Notes
|
||||
- macOS-only.
|
||||
- `--dry-run` prints the URL and does not open Things.
|
||||
121
.skills/tmux/SKILL.md
Normal file
121
.skills/tmux/SKILL.md
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: tmux
|
||||
description: Remote-control tmux sessions for interactive CLIs by sending keystrokes and scraping pane output.
|
||||
metadata: {"clawdbot":{"emoji":"🧵","os":["darwin","linux"],"requires":{"bins":["tmux"]}}}
|
||||
---
|
||||
|
||||
# tmux Skill (Clawdbot)
|
||||
|
||||
Use tmux only when you need an interactive TTY. Prefer exec background mode for long-running, non-interactive tasks.
|
||||
|
||||
## Quickstart (isolated socket, exec tool)
|
||||
|
||||
```bash
|
||||
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
|
||||
mkdir -p "$SOCKET_DIR"
|
||||
SOCKET="$SOCKET_DIR/clawdbot.sock"
|
||||
SESSION=clawdbot-python
|
||||
|
||||
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- 'PYTHON_BASIC_REPL=1 python3 -q' Enter
|
||||
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
|
||||
```
|
||||
|
||||
After starting a session, always print monitor commands:
|
||||
|
||||
```
|
||||
To monitor:
|
||||
tmux -S "$SOCKET" attach -t "$SESSION"
|
||||
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
|
||||
```
|
||||
|
||||
## Socket convention
|
||||
|
||||
- Use `CLAWDBOT_TMUX_SOCKET_DIR` (default `${TMPDIR:-/tmp}/clawdbot-tmux-sockets`).
|
||||
- Default socket path: `"$CLAWDBOT_TMUX_SOCKET_DIR/clawdbot.sock"`.
|
||||
|
||||
## Targeting panes and naming
|
||||
|
||||
- Target format: `session:window.pane` (defaults to `:0.0`).
|
||||
- Keep names short; avoid spaces.
|
||||
- Inspect: `tmux -S "$SOCKET" list-sessions`, `tmux -S "$SOCKET" list-panes -a`.
|
||||
|
||||
## Finding sessions
|
||||
|
||||
- List sessions on your socket: `{baseDir}/scripts/find-sessions.sh -S "$SOCKET"`.
|
||||
- Scan all sockets: `{baseDir}/scripts/find-sessions.sh --all` (uses `CLAWDBOT_TMUX_SOCKET_DIR`).
|
||||
|
||||
## Sending input safely
|
||||
|
||||
- Prefer literal sends: `tmux -S "$SOCKET" send-keys -t target -l -- "$cmd"`.
|
||||
- Control keys: `tmux -S "$SOCKET" send-keys -t target C-c`.
|
||||
|
||||
## Watching output
|
||||
|
||||
- Capture recent history: `tmux -S "$SOCKET" capture-pane -p -J -t target -S -200`.
|
||||
- Wait for prompts: `{baseDir}/scripts/wait-for-text.sh -t session:0.0 -p 'pattern'`.
|
||||
- Attaching is OK; detach with `Ctrl+b d`.
|
||||
|
||||
## Spawning processes
|
||||
|
||||
- For python REPLs, set `PYTHON_BASIC_REPL=1` (non-basic REPL breaks send-keys flows).
|
||||
|
||||
## Windows / WSL
|
||||
|
||||
- tmux is supported on macOS/Linux. On Windows, use WSL and install tmux inside WSL.
|
||||
- This skill is gated to `darwin`/`linux` and requires `tmux` on PATH.
|
||||
|
||||
## Orchestrating Coding Agents (Codex, Claude Code)
|
||||
|
||||
tmux excels at running multiple coding agents in parallel:
|
||||
|
||||
```bash
|
||||
SOCKET="${TMPDIR:-/tmp}/codex-army.sock"
|
||||
|
||||
# Create multiple sessions
|
||||
for i in 1 2 3 4 5; do
|
||||
tmux -S "$SOCKET" new-session -d -s "agent-$i"
|
||||
done
|
||||
|
||||
# Launch agents in different workdirs
|
||||
tmux -S "$SOCKET" send-keys -t agent-1 "cd /tmp/project1 && codex --yolo 'Fix bug X'" Enter
|
||||
tmux -S "$SOCKET" send-keys -t agent-2 "cd /tmp/project2 && codex --yolo 'Fix bug Y'" Enter
|
||||
|
||||
# Poll for completion (check if prompt returned)
|
||||
for sess in agent-1 agent-2; do
|
||||
if tmux -S "$SOCKET" capture-pane -p -t "$sess" -S -3 | grep -q "❯"; then
|
||||
echo "$sess: DONE"
|
||||
else
|
||||
echo "$sess: Running..."
|
||||
fi
|
||||
done
|
||||
|
||||
# Get full output from completed session
|
||||
tmux -S "$SOCKET" capture-pane -p -t agent-1 -S -500
|
||||
```
|
||||
|
||||
**Tips:**
|
||||
- Use separate git worktrees for parallel fixes (no branch conflicts)
|
||||
- `pnpm install` first before running codex in fresh clones
|
||||
- Check for shell prompt (`❯` or `$`) to detect completion
|
||||
- Codex needs `--yolo` or `--full-auto` for non-interactive fixes
|
||||
|
||||
## Cleanup
|
||||
|
||||
- Kill a session: `tmux -S "$SOCKET" kill-session -t "$SESSION"`.
|
||||
- Kill all sessions on a socket: `tmux -S "$SOCKET" list-sessions -F '#{session_name}' | xargs -r -n1 tmux -S "$SOCKET" kill-session -t`.
|
||||
- Remove everything on the private socket: `tmux -S "$SOCKET" kill-server`.
|
||||
|
||||
## Helper: wait-for-text.sh
|
||||
|
||||
`{baseDir}/scripts/wait-for-text.sh` polls a pane for a regex (or fixed string) with a timeout.
|
||||
|
||||
```bash
|
||||
{baseDir}/scripts/wait-for-text.sh -t session:0.0 -p 'pattern' [-F] [-T 20] [-i 0.5] [-l 2000]
|
||||
```
|
||||
|
||||
- `-t`/`--target` pane target (required)
|
||||
- `-p`/`--pattern` regex to match (required); add `-F` for fixed string
|
||||
- `-T` timeout seconds (integer, default 15)
|
||||
- `-i` poll interval seconds (default 0.5)
|
||||
- `-l` history lines to search (integer, default 1000)
|
||||
112
.skills/tmux/scripts/find-sessions.sh
Executable file
112
.skills/tmux/scripts/find-sessions.sh
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: find-sessions.sh [-L socket-name|-S socket-path|-A] [-q pattern]
|
||||
|
||||
List tmux sessions on a socket (default tmux socket if none provided).
|
||||
|
||||
Options:
|
||||
-L, --socket tmux socket name (passed to tmux -L)
|
||||
-S, --socket-path tmux socket path (passed to tmux -S)
|
||||
-A, --all scan all sockets under CLAWDBOT_TMUX_SOCKET_DIR
|
||||
-q, --query case-insensitive substring to filter session names
|
||||
-h, --help show this help
|
||||
USAGE
|
||||
}
|
||||
|
||||
socket_name=""
|
||||
socket_path=""
|
||||
query=""
|
||||
scan_all=false
|
||||
socket_dir="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-L|--socket) socket_name="${2-}"; shift 2 ;;
|
||||
-S|--socket-path) socket_path="${2-}"; shift 2 ;;
|
||||
-A|--all) scan_all=true; shift ;;
|
||||
-q|--query) query="${2-}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$scan_all" == true && ( -n "$socket_name" || -n "$socket_path" ) ]]; then
|
||||
echo "Cannot combine --all with -L or -S" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$socket_name" && -n "$socket_path" ]]; then
|
||||
echo "Use either -L or -S, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "tmux not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
list_sessions() {
|
||||
local label="$1"; shift
|
||||
local tmux_cmd=(tmux "$@")
|
||||
|
||||
if ! sessions="$("${tmux_cmd[@]}" list-sessions -F '#{session_name}\t#{session_attached}\t#{session_created_string}' 2>/dev/null)"; then
|
||||
echo "No tmux server found on $label" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -n "$query" ]]; then
|
||||
sessions="$(printf '%s\n' "$sessions" | grep -i -- "$query" || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$sessions" ]]; then
|
||||
echo "No sessions found on $label"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Sessions on $label:"
|
||||
printf '%s\n' "$sessions" | while IFS=$'\t' read -r name attached created; do
|
||||
attached_label=$([[ "$attached" == "1" ]] && echo "attached" || echo "detached")
|
||||
printf ' - %s (%s, started %s)\n' "$name" "$attached_label" "$created"
|
||||
done
|
||||
}
|
||||
|
||||
if [[ "$scan_all" == true ]]; then
|
||||
if [[ ! -d "$socket_dir" ]]; then
|
||||
echo "Socket directory not found: $socket_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
sockets=("$socket_dir"/*)
|
||||
shopt -u nullglob
|
||||
|
||||
if [[ "${#sockets[@]}" -eq 0 ]]; then
|
||||
echo "No sockets found under $socket_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit_code=0
|
||||
for sock in "${sockets[@]}"; do
|
||||
if [[ ! -S "$sock" ]]; then
|
||||
continue
|
||||
fi
|
||||
list_sessions "socket path '$sock'" -S "$sock" || exit_code=$?
|
||||
done
|
||||
exit "$exit_code"
|
||||
fi
|
||||
|
||||
tmux_cmd=(tmux)
|
||||
socket_label="default socket"
|
||||
|
||||
if [[ -n "$socket_name" ]]; then
|
||||
tmux_cmd+=(-L "$socket_name")
|
||||
socket_label="socket name '$socket_name'"
|
||||
elif [[ -n "$socket_path" ]]; then
|
||||
tmux_cmd+=(-S "$socket_path")
|
||||
socket_label="socket path '$socket_path'"
|
||||
fi
|
||||
|
||||
list_sessions "$socket_label" "${tmux_cmd[@]:1}"
|
||||
83
.skills/tmux/scripts/wait-for-text.sh
Executable file
83
.skills/tmux/scripts/wait-for-text.sh
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: wait-for-text.sh -t target -p pattern [options]
|
||||
|
||||
Poll a tmux pane for text and exit when found.
|
||||
|
||||
Options:
|
||||
-t, --target tmux target (session:window.pane), required
|
||||
-p, --pattern regex pattern to look for, required
|
||||
-F, --fixed treat pattern as a fixed string (grep -F)
|
||||
-T, --timeout seconds to wait (integer, default: 15)
|
||||
-i, --interval poll interval in seconds (default: 0.5)
|
||||
-l, --lines number of history lines to inspect (integer, default: 1000)
|
||||
-h, --help show this help
|
||||
USAGE
|
||||
}
|
||||
|
||||
target=""
|
||||
pattern=""
|
||||
grep_flag="-E"
|
||||
timeout=15
|
||||
interval=0.5
|
||||
lines=1000
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-t|--target) target="${2-}"; shift 2 ;;
|
||||
-p|--pattern) pattern="${2-}"; shift 2 ;;
|
||||
-F|--fixed) grep_flag="-F"; shift ;;
|
||||
-T|--timeout) timeout="${2-}"; shift 2 ;;
|
||||
-i|--interval) interval="${2-}"; shift 2 ;;
|
||||
-l|--lines) lines="${2-}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$target" || -z "$pattern" ]]; then
|
||||
echo "target and pattern are required" >&2
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$timeout" =~ ^[0-9]+$ ]]; then
|
||||
echo "timeout must be an integer number of seconds" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$lines" =~ ^[0-9]+$ ]]; then
|
||||
echo "lines must be an integer" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "tmux not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# End time in epoch seconds (integer, good enough for polling)
|
||||
start_epoch=$(date +%s)
|
||||
deadline=$((start_epoch + timeout))
|
||||
|
||||
while true; do
|
||||
# -J joins wrapped lines, -S uses negative index to read last N lines
|
||||
pane_text="$(tmux capture-pane -p -J -t "$target" -S "-${lines}" 2>/dev/null || true)"
|
||||
|
||||
if printf '%s\n' "$pane_text" | grep $grep_flag -- "$pattern" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
now=$(date +%s)
|
||||
if (( now >= deadline )); then
|
||||
echo "Timed out after ${timeout}s waiting for pattern: $pattern" >&2
|
||||
echo "Last ${lines} lines from $target:" >&2
|
||||
printf '%s\n' "$pane_text" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep "$interval"
|
||||
done
|
||||
84
.skills/trello/SKILL.md
Normal file
84
.skills/trello/SKILL.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: trello
|
||||
description: Manage Trello boards, lists, and cards via the Trello REST API.
|
||||
homepage: https://developer.atlassian.com/cloud/trello/rest/
|
||||
metadata: {"clawdbot":{"emoji":"📋","requires":{"bins":["jq"],"env":["TRELLO_API_KEY","TRELLO_TOKEN"]}}}
|
||||
---
|
||||
|
||||
# Trello Skill
|
||||
|
||||
Manage Trello boards, lists, and cards directly from Clawdbot.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Get your API key: https://trello.com/app-key
|
||||
2. Generate a token (click "Token" link on that page)
|
||||
3. Set environment variables:
|
||||
```bash
|
||||
export TRELLO_API_KEY="your-api-key"
|
||||
export TRELLO_TOKEN="your-token"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
All commands use curl to hit the Trello REST API.
|
||||
|
||||
### List boards
|
||||
```bash
|
||||
curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id}'
|
||||
```
|
||||
|
||||
### List lists in a board
|
||||
```bash
|
||||
curl -s "https://api.trello.com/1/boards/{boardId}/lists?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id}'
|
||||
```
|
||||
|
||||
### List cards in a list
|
||||
```bash
|
||||
curl -s "https://api.trello.com/1/lists/{listId}/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id, desc}'
|
||||
```
|
||||
|
||||
### Create a card
|
||||
```bash
|
||||
curl -s -X POST "https://api.trello.com/1/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \
|
||||
-d "idList={listId}" \
|
||||
-d "name=Card Title" \
|
||||
-d "desc=Card description"
|
||||
```
|
||||
|
||||
### Move a card to another list
|
||||
```bash
|
||||
curl -s -X PUT "https://api.trello.com/1/cards/{cardId}?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \
|
||||
-d "idList={newListId}"
|
||||
```
|
||||
|
||||
### Add a comment to a card
|
||||
```bash
|
||||
curl -s -X POST "https://api.trello.com/1/cards/{cardId}/actions/comments?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \
|
||||
-d "text=Your comment here"
|
||||
```
|
||||
|
||||
### Archive a card
|
||||
```bash
|
||||
curl -s -X PUT "https://api.trello.com/1/cards/{cardId}?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \
|
||||
-d "closed=true"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Board/List/Card IDs can be found in the Trello URL or via the list commands
|
||||
- The API key and token provide full access to your Trello account - keep them secret!
|
||||
- Rate limits: 300 requests per 10 seconds per API key; 100 requests per 10 seconds per token; `/1/members` endpoints are limited to 100 requests per 900 seconds
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Get all boards
|
||||
curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN&fields=name,id" | jq
|
||||
|
||||
# Find a specific board by name
|
||||
curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | select(.name | contains("Work"))'
|
||||
|
||||
# Get all cards on a board
|
||||
curl -s "https://api.trello.com/1/boards/{boardId}/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, list: .idList}'
|
||||
```
|
||||
29
.skills/video-frames/SKILL.md
Normal file
29
.skills/video-frames/SKILL.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: video-frames
|
||||
description: Extract frames or short clips from videos using ffmpeg.
|
||||
homepage: https://ffmpeg.org
|
||||
metadata: {"clawdbot":{"emoji":"🎞️","requires":{"bins":["ffmpeg"]},"install":[{"id":"brew","kind":"brew","formula":"ffmpeg","bins":["ffmpeg"],"label":"Install ffmpeg (brew)"}]}}
|
||||
---
|
||||
|
||||
# Video Frames (ffmpeg)
|
||||
|
||||
Extract a single frame from a video, or create quick thumbnails for inspection.
|
||||
|
||||
## Quick start
|
||||
|
||||
First frame:
|
||||
|
||||
```bash
|
||||
{baseDir}/scripts/frame.sh /path/to/video.mp4 --out /tmp/frame.jpg
|
||||
```
|
||||
|
||||
At a timestamp:
|
||||
|
||||
```bash
|
||||
{baseDir}/scripts/frame.sh /path/to/video.mp4 --time 00:00:10 --out /tmp/frame-10s.jpg
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Prefer `--time` for “what is happening around here?”.
|
||||
- Use a `.jpg` for quick share; use `.png` for crisp UI frames.
|
||||
81
.skills/video-frames/scripts/frame.sh
Normal file
81
.skills/video-frames/scripts/frame.sh
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage:
|
||||
frame.sh <video-file> [--time HH:MM:SS] [--index N] --out /path/to/frame.jpg
|
||||
|
||||
Examples:
|
||||
frame.sh video.mp4 --out /tmp/frame.jpg
|
||||
frame.sh video.mp4 --time 00:00:10 --out /tmp/frame-10s.jpg
|
||||
frame.sh video.mp4 --index 0 --out /tmp/frame0.png
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
in="${1:-}"
|
||||
shift || true
|
||||
|
||||
time=""
|
||||
index=""
|
||||
out=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--time)
|
||||
time="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--index)
|
||||
index="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--out)
|
||||
out="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arg: $1" >&2
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$in" ]]; then
|
||||
echo "File not found: $in" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$out" == "" ]]; then
|
||||
echo "Missing --out" >&2
|
||||
usage
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$out")"
|
||||
|
||||
if [[ "$index" != "" ]]; then
|
||||
ffmpeg -hide_banner -loglevel error -y \
|
||||
-i "$in" \
|
||||
-vf "select=eq(n\\,${index})" \
|
||||
-vframes 1 \
|
||||
"$out"
|
||||
elif [[ "$time" != "" ]]; then
|
||||
ffmpeg -hide_banner -loglevel error -y \
|
||||
-ss "$time" \
|
||||
-i "$in" \
|
||||
-frames:v 1 \
|
||||
"$out"
|
||||
else
|
||||
ffmpeg -hide_banner -loglevel error -y \
|
||||
-i "$in" \
|
||||
-vf "select=eq(n\\,0)" \
|
||||
-vframes 1 \
|
||||
"$out"
|
||||
fi
|
||||
|
||||
echo "$out"
|
||||
42
.skills/wacli/SKILL.md
Normal file
42
.skills/wacli/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: wacli
|
||||
description: Send WhatsApp messages to other people or search/sync WhatsApp history via the wacli CLI (not for normal user chats).
|
||||
homepage: https://wacli.sh
|
||||
metadata: {"clawdbot":{"emoji":"📱","requires":{"bins":["wacli"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/wacli","bins":["wacli"],"label":"Install wacli (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/wacli/cmd/wacli@latest","bins":["wacli"],"label":"Install wacli (go)"}]}}
|
||||
---
|
||||
|
||||
# wacli
|
||||
|
||||
Use `wacli` only when the user explicitly asks you to message someone else on WhatsApp or when they ask to sync/search WhatsApp history.
|
||||
Do NOT use `wacli` for normal user chats; Clawdbot routes WhatsApp conversations automatically.
|
||||
If the user is chatting with you on WhatsApp, you should not reach for this tool unless they ask you to contact a third party.
|
||||
|
||||
Safety
|
||||
- Require explicit recipient + message text.
|
||||
- Confirm recipient + message before sending.
|
||||
- If anything is ambiguous, ask a clarifying question.
|
||||
|
||||
Auth + sync
|
||||
- `wacli auth` (QR login + initial sync)
|
||||
- `wacli sync --follow` (continuous sync)
|
||||
- `wacli doctor`
|
||||
|
||||
Find chats + messages
|
||||
- `wacli chats list --limit 20 --query "name or number"`
|
||||
- `wacli messages search "query" --limit 20 --chat <jid>`
|
||||
- `wacli messages search "invoice" --after 2025-01-01 --before 2025-12-31`
|
||||
|
||||
History backfill
|
||||
- `wacli history backfill --chat <jid> --requests 2 --count 50`
|
||||
|
||||
Send
|
||||
- Text: `wacli send text --to "+14155551212" --message "Hello! Are you free at 3pm?"`
|
||||
- Group: `wacli send text --to "1234567890-123456789@g.us" --message "Running 5 min late."`
|
||||
- File: `wacli send file --to "+14155551212" --file /path/agenda.pdf --caption "Agenda"`
|
||||
|
||||
Notes
|
||||
- Store dir: `~/.wacli` (override with `--store`).
|
||||
- Use `--json` for machine-readable output when parsing.
|
||||
- Backfill requires your phone online; results are best-effort.
|
||||
- WhatsApp CLI is not needed for routine user chats; it’s for messaging other people.
|
||||
- JIDs: direct chats look like `<number>@s.whatsapp.net`; groups look like `<id>@g.us` (use `wacli chats list` to find).
|
||||
49
.skills/weather/SKILL.md
Normal file
49
.skills/weather/SKILL.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: weather
|
||||
description: Get current weather and forecasts (no API key required).
|
||||
homepage: https://wttr.in/:help
|
||||
metadata: {"clawdbot":{"emoji":"🌤️","requires":{"bins":["curl"]}}}
|
||||
---
|
||||
|
||||
# Weather
|
||||
|
||||
Two free services, no API keys needed.
|
||||
|
||||
## wttr.in (primary)
|
||||
|
||||
Quick one-liner:
|
||||
```bash
|
||||
curl -s "wttr.in/London?format=3"
|
||||
# Output: London: ⛅️ +8°C
|
||||
```
|
||||
|
||||
Compact format:
|
||||
```bash
|
||||
curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w"
|
||||
# Output: London: ⛅️ +8°C 71% ↙5km/h
|
||||
```
|
||||
|
||||
Full forecast:
|
||||
```bash
|
||||
curl -s "wttr.in/London?T"
|
||||
```
|
||||
|
||||
Format codes: `%c` condition · `%t` temp · `%h` humidity · `%w` wind · `%l` location · `%m` moon
|
||||
|
||||
Tips:
|
||||
- URL-encode spaces: `wttr.in/New+York`
|
||||
- Airport codes: `wttr.in/JFK`
|
||||
- Units: `?m` (metric) `?u` (USCS)
|
||||
- Today only: `?1` · Current only: `?0`
|
||||
- PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png`
|
||||
|
||||
## Open-Meteo (fallback, JSON)
|
||||
|
||||
Free, no key, good for programmatic use:
|
||||
```bash
|
||||
curl -s "https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.12¤t_weather=true"
|
||||
```
|
||||
|
||||
Find coordinates for a city, then query. Returns JSON with temp, windspeed, weathercode.
|
||||
|
||||
Docs: https://open-meteo.com/en/docs
|
||||
450
README.md
Normal file
450
README.md
Normal file
@@ -0,0 +1,450 @@
|
||||
# LettaBot 👾
|
||||
|
||||
Your personal AI assistant that remembers everything across **Telegram, Slack, WhatsApp, and Signal**. Powered by [Letta Code](https://github.com/letta-ai/letta-code).
|
||||
|
||||
<img width="750" alt="lettabot-preview" src="https://github.com/user-attachments/assets/9f01b845-d5b0-447b-927d-ae15f9ec7511" />
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Channel** - Chat seamlessly across Telegram, Slack, WhatsApp, and Signal
|
||||
- **Unified Memory** - Single agent remembers everything from all channels
|
||||
- **Persistent Memory** - Agent remembers conversations across sessions (days/weeks/months)
|
||||
- **Local Tool Execution** - Agent can read files, search code, run commands on your machine
|
||||
- **Heartbeat** - Periodic check-ins where the agent reviews tasks
|
||||
- **Cron Jobs** - Agent can create its own scheduled tasks
|
||||
- **Streaming Responses** - Real-time message updates as the agent thinks
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- A Letta API key from [app.letta.com](https://app.letta.com)
|
||||
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
|
||||
|
||||
### Install from Source
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/letta-ai/lettabot.git
|
||||
cd lettabot
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Link the CLI globally (optional)
|
||||
npm link
|
||||
```
|
||||
|
||||
### Onboard
|
||||
|
||||
Run the interactive onboarding wizard:
|
||||
|
||||
```bash
|
||||
lettabot onboard
|
||||
```
|
||||
|
||||
This will guide you through:
|
||||
1. Setting up your Letta API key
|
||||
2. Configuring Telegram (and optionally Slack)
|
||||
3. Enabling heartbeat and cron jobs
|
||||
4. Installing skills
|
||||
|
||||
### Run the Server
|
||||
|
||||
```bash
|
||||
lettabot server
|
||||
```
|
||||
|
||||
Or in development mode (auto-reload):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
That's it! Message your bot on Telegram.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `lettabot onboard` | Interactive setup wizard |
|
||||
| `lettabot server` | Start the bot server |
|
||||
| `lettabot configure` | View and edit configuration |
|
||||
| `lettabot skills` | Configure which skills are enabled |
|
||||
| `lettabot skills status` | Show skills status |
|
||||
| `lettabot help` | Show help |
|
||||
|
||||
## Multi-Channel Architecture
|
||||
|
||||
LettaBot uses a **single agent with a single conversation** across all channels:
|
||||
|
||||
```
|
||||
Telegram ──┐
|
||||
├──→ ONE AGENT ──→ ONE CONVERSATION
|
||||
Slack ─────┤ (memory) (chat history)
|
||||
WhatsApp ──┘
|
||||
```
|
||||
|
||||
- Start a conversation on Telegram
|
||||
- Continue it on Slack
|
||||
- Pick it up on WhatsApp
|
||||
- The agent remembers everything!
|
||||
|
||||
## Channel Setup
|
||||
|
||||
| Channel | Guide | Requirements |
|
||||
|---------|-------|--------------|
|
||||
| Telegram | [Setup Guide](docs/getting-started.md) | Bot token from @BotFather |
|
||||
| Slack | [Setup Guide](docs/slack-setup.md) | Slack app with Socket Mode |
|
||||
| WhatsApp | [Setup Guide](docs/whatsapp-setup.md) | Phone with WhatsApp |
|
||||
| Signal | [Setup Guide](docs/signal-setup.md) | signal-cli + phone number |
|
||||
|
||||
At least one channel is required. Telegram is the easiest to start with.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic (.env)
|
||||
|
||||
```bash
|
||||
# Required: Letta API Key
|
||||
LETTA_API_KEY=your_letta_api_key
|
||||
|
||||
# Telegram (easiest to start)
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
|
||||
# Slack (optional)
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
SLACK_APP_TOKEN=xapp-your-app-token
|
||||
|
||||
# WhatsApp (optional)
|
||||
WHATSAPP_ENABLED=true
|
||||
|
||||
# Signal (optional)
|
||||
SIGNAL_PHONE_NUMBER=+1XXXXXXXXXX
|
||||
|
||||
# Cron jobs (optional)
|
||||
CRON_ENABLED=true
|
||||
|
||||
# Heartbeat - periodic check-ins (optional)
|
||||
HEARTBEAT_INTERVAL_MIN=30
|
||||
# HEARTBEAT_TARGET=telegram:123456789 # defaults to last messaged
|
||||
```
|
||||
|
||||
### Full Configuration Reference
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `LETTA_API_KEY` | Yes | API key from app.letta.com |
|
||||
| `TELEGRAM_BOT_TOKEN` | * | Bot token from @BotFather |
|
||||
| `TELEGRAM_ALLOWED_USERS` | No | Comma-separated Telegram user IDs |
|
||||
| `SLACK_BOT_TOKEN` | * | Slack bot token (xoxb-...) |
|
||||
| `SLACK_APP_TOKEN` | * | Slack app token (xapp-...) |
|
||||
| `SLACK_ALLOWED_USERS` | No | Comma-separated Slack user IDs |
|
||||
| `WHATSAPP_ENABLED` | No | Set to `true` to enable WhatsApp |
|
||||
| `WHATSAPP_SESSION_PATH` | No | Path to store WhatsApp session |
|
||||
| `WHATSAPP_ALLOWED_USERS` | No | Comma-separated phone numbers (+1...) |
|
||||
| `SIGNAL_PHONE_NUMBER` | * | Phone number registered with signal-cli |
|
||||
| `SIGNAL_CLI_PATH` | No | Path to signal-cli binary |
|
||||
| `SIGNAL_DM_POLICY` | No | pairing/allowlist/open (default: pairing) |
|
||||
| `WORKING_DIR` | No | Agent workspace (default: `/tmp/lettabot`) |
|
||||
| `CRON_ENABLED` | No | Enable scheduled tasks |
|
||||
| `HEARTBEAT_INTERVAL_MIN` | No | Heartbeat interval in minutes (e.g., `30`) |
|
||||
| `HEARTBEAT_TARGET` | No | Where to deliver (e.g., `telegram:123456789`) |
|
||||
|
||||
\* At least one channel must be configured
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/start` | Welcome message and help |
|
||||
| `/new` | New conversation (keeps agent memory) |
|
||||
| `/reset` | Fresh agent (clears all memory) |
|
||||
| `/status` | Show current session info |
|
||||
| `/skills` | List installed skills |
|
||||
|
||||
## Skills
|
||||
|
||||
LettaBot comes with 40+ builtin skills and supports installing more from [ClawdHub](https://clawdhub.com).
|
||||
|
||||
### Builtin Skills
|
||||
|
||||
Skills are copied to `{WORKING_DIR}/.skills/` on server startup (where Letta Code discovers them):
|
||||
|
||||
| Category | Skills |
|
||||
|----------|--------|
|
||||
| **Productivity** | 1password, apple-notes, apple-reminders, bear-notes, notion, obsidian, things-mac, trello |
|
||||
| **Communication** | bird (Twitter), himalaya (email), imsg (iMessage), wacli (WhatsApp) |
|
||||
| **Google** | google (Gmail, Calendar, Drive, Sheets, Docs), goplaces |
|
||||
| **Media** | camsnap, gifgrep, nano-pdf, openai-image-gen, openai-whisper, summarize, video-frames |
|
||||
| **Smart Home** | blucli, eightctl, openhue, sonoscli, spotify-player |
|
||||
| **Dev Tools** | gemini, github, mcporter, oracle, peekaboo, tmux |
|
||||
| **Utilities** | blogwatcher, cron, weather |
|
||||
|
||||
### Install from ClawdHub
|
||||
|
||||
```bash
|
||||
# Search for skills
|
||||
npm run skill:search weather
|
||||
|
||||
# Install a skill
|
||||
npm run skill:install gog # Google Workspace (Gmail, Calendar, Drive)
|
||||
npm run skill:install bird # Twitter/X
|
||||
npm run skill:install obsidian # Obsidian notes
|
||||
npm run skill:install sonoscli # Sonos speakers
|
||||
|
||||
# List installed skills
|
||||
npm run skill:list
|
||||
```
|
||||
|
||||
Browse all skills: https://clawdhub.com
|
||||
|
||||
### Install from skills.sh
|
||||
|
||||
LettaBot is also compatible with [skills.sh](https://skills.sh) - the open agent skills ecosystem with 29K+ installs.
|
||||
|
||||
```bash
|
||||
# Interactive search
|
||||
npm run skills:find
|
||||
|
||||
# Install popular skill packs
|
||||
npm run skills:add vercel-labs/agent-skills # React/Next.js best practices
|
||||
npm run skills:add anthropics/skills # Frontend design, docs, PDFs
|
||||
npm run skills:add expo/skills # React Native/Expo
|
||||
npm run skills:add supabase/agent-skills # Supabase/Postgres
|
||||
|
||||
# Or use npx directly
|
||||
npx skills add coreyhaines31/marketingskills --global --yes
|
||||
```
|
||||
|
||||
Browse all skills: https://skills.sh
|
||||
|
||||
### Configuring Skills
|
||||
|
||||
Skills installed via ClawdHub or skills.sh are not automatically available to LettaBot. You need to configure which skills to enable:
|
||||
|
||||
```bash
|
||||
lettabot skills
|
||||
```
|
||||
|
||||
This shows an interactive checklist of all available skills (from ClawdHub, skills.sh, and builtin). Select which skills to enable for your agent - checked skills will be available, unchecked skills will be removed.
|
||||
|
||||
**Workflow:**
|
||||
1. Install skills via ClawdHub or skills.sh (see above)
|
||||
2. Run `lettabot skills` to configure which ones to enable
|
||||
3. Restart the server (or the agent will pick them up on next interaction)
|
||||
|
||||
## Heartbeat & Cron Jobs
|
||||
|
||||
### Heartbeat
|
||||
|
||||
LettaBot can periodically check in with you, reviewing a `HEARTBEAT.md` file for tasks:
|
||||
|
||||
```bash
|
||||
# Enable heartbeat (every 30 minutes)
|
||||
HEARTBEAT_INTERVAL_MIN=30
|
||||
|
||||
# Optional: specify delivery target (defaults to last messaged chat)
|
||||
HEARTBEAT_TARGET=telegram:123456789
|
||||
```
|
||||
|
||||
How it works:
|
||||
1. Every N minutes, the system reads `HEARTBEAT.md` and sends its contents to the agent
|
||||
2. The agent reviews the tasks and decides what to do
|
||||
3. If nothing to do → agent calls `ignore()` tool → **response not delivered to you** (no spam!)
|
||||
4. If there's something to report → agent's response is delivered to your chat
|
||||
|
||||
### The `ignore()` Tool
|
||||
|
||||
The agent has an `ignore()` tool for skipping responses:
|
||||
|
||||
```python
|
||||
ignore(reason="Nothing to report") # Skips delivery
|
||||
```
|
||||
|
||||
This is useful for:
|
||||
- Heartbeat check-ins with nothing to report
|
||||
- Messages not directed at the agent
|
||||
- Any situation where no response is needed
|
||||
|
||||
Setup the tool:
|
||||
```bash
|
||||
npm run tools:setup # Upserts ignore() tool to Letta API
|
||||
```
|
||||
|
||||
Edit `HEARTBEAT.md` in your working directory to add tasks you want to be reminded about.
|
||||
|
||||
### Cron Jobs
|
||||
|
||||
The agent can create its own scheduled tasks:
|
||||
|
||||
```bash
|
||||
# Enable cron service
|
||||
CRON_ENABLED=true
|
||||
```
|
||||
|
||||
The agent uses the `lettabot-cron` CLI to manage jobs:
|
||||
|
||||
```bash
|
||||
# Create a daily morning briefing
|
||||
lettabot-cron create -n "Morning" -s "0 8 * * *" -m "Good morning! What's on the agenda?"
|
||||
|
||||
# List all jobs
|
||||
lettabot-cron list
|
||||
|
||||
# Delete a job
|
||||
lettabot-cron delete <job-id>
|
||||
```
|
||||
|
||||
Jobs are stored in `cron-jobs.json` and auto-reload when changed. Responses are delivered to the last messaged chat (or specify `--deliver telegram:123456789`).
|
||||
|
||||
## Security
|
||||
|
||||
### Network Architecture
|
||||
|
||||
**LettaBot uses outbound connections only** - no public URL or gateway required:
|
||||
|
||||
| Channel | Connection Type | Exposed Ports |
|
||||
|---------|-----------------|---------------|
|
||||
| Telegram | Long-polling (outbound HTTP) | None |
|
||||
| Slack | Socket Mode (outbound WebSocket) | None |
|
||||
| WhatsApp | Outbound WebSocket via Baileys | None |
|
||||
| Signal | Local daemon on 127.0.0.1 | None (localhost only) |
|
||||
|
||||
This is safer than webhook-based architectures that require exposing a public HTTP server.
|
||||
|
||||
### Tool Execution
|
||||
|
||||
LettaBot can execute tools on your machine. By default, it's restricted to **read-only** operations:
|
||||
- `Read`, `Glob`, `Grep` - File exploration
|
||||
- `web_search` - Internet queries
|
||||
- `conversation_search` - Search past messages
|
||||
|
||||
### Restricting Access
|
||||
|
||||
Set allowed users per channel in `.env`:
|
||||
|
||||
```bash
|
||||
TELEGRAM_ALLOWED_USERS=123456789,987654321
|
||||
SLACK_ALLOWED_USERS=U01234567,U98765432
|
||||
WHATSAPP_ALLOWED_USERS=+15551234567
|
||||
SIGNAL_ALLOWED_USERS=+15551234567
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ LettaBot Core │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Telegram │ │ Slack │ │ WhatsApp │ │ Signal │ │
|
||||
│ │ (grammY) │ │ (Bolt) │ │ (Baileys)│ │(signal-cli)│ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ └─────────────┼─────────────┼─────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ Letta Agent │ ◄── Single agent, │
|
||||
│ │ (Memory) │ single conversation │
|
||||
│ └────────┬────────┘ │
|
||||
└───────────────────────┼─────────────────────────────────────┘
|
||||
▼
|
||||
Letta Code SDK → CLI → Local Tools
|
||||
│
|
||||
▼
|
||||
Letta Server (Memory + LLM)
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run in development mode (uses Letta Cloud)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
npm start
|
||||
|
||||
# Run setup wizard
|
||||
npm run setup
|
||||
```
|
||||
|
||||
### Local Letta Server
|
||||
|
||||
To use a local Letta server instead of Letta Cloud:
|
||||
|
||||
```bash
|
||||
# Point to local server
|
||||
LETTA_BASE_URL=http://localhost:8283 npm run dev
|
||||
|
||||
# With a specific agent ID (useful if agent already exists)
|
||||
LETTA_BASE_URL=http://localhost:8283 LETTA_AGENT_ID=agent-xxx npm run dev
|
||||
```
|
||||
|
||||
The `LETTA_AGENT_ID` env var overrides the stored agent ID, useful for testing with different agents or servers.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### WhatsApp
|
||||
|
||||
**Session errors / "Bad MAC" messages**
|
||||
These are normal Signal Protocol renegotiation messages. They're noisy but harmless - WhatsApp will still work.
|
||||
|
||||
**Messages going to wrong chat**
|
||||
If using selfChatMode and messages go to the wrong place, clear the session and re-link:
|
||||
```bash
|
||||
rm -rf ./data/whatsapp-session
|
||||
npm run dev # Scan QR again
|
||||
```
|
||||
|
||||
**Competing with another WhatsApp client**
|
||||
If you have clawdbot/moltbot running, it will compete for WhatsApp. Stop it first:
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.clawdbot.gateway.plist
|
||||
```
|
||||
|
||||
### Signal
|
||||
|
||||
**Port 8090 already in use**
|
||||
Change the port in `.env`:
|
||||
```bash
|
||||
SIGNAL_HTTP_PORT=8091
|
||||
```
|
||||
|
||||
**Daemon won't start**
|
||||
Make sure signal-cli is in your PATH or set the full path:
|
||||
```bash
|
||||
SIGNAL_CLI_PATH=/opt/homebrew/bin/signal-cli
|
||||
```
|
||||
|
||||
### General
|
||||
|
||||
**Agent not responding**
|
||||
Check if the agent ID is correct in `lettabot-agent.json`. If corrupted, delete it to create a fresh agent:
|
||||
```bash
|
||||
rm lettabot-agent.json
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Messages queuing up / slow responses**
|
||||
Messages are processed one at a time to prevent SDK issues. If many messages arrive at once, they'll queue.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Slack Setup](docs/slack-setup.md)
|
||||
- [WhatsApp Setup](docs/whatsapp-setup.md)
|
||||
- [Signal Setup](docs/signal-setup.md)
|
||||
- [Architecture](ARCHITECTURE.md)
|
||||
- [Implementation Plan](PLAN.md)
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Some builtin skills are adapted from [ClawdBot](https://github.com/anthropics/clawdbot) (MIT License, Copyright © 2025 Peter Steinberger).
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
61
docs/README.md
Normal file
61
docs/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# LettaBot Documentation
|
||||
|
||||
LettaBot is a Telegram bot powered by [Letta](https://letta.com) that provides persistent memory and local tool execution.
|
||||
|
||||
## Guides
|
||||
|
||||
- [Getting Started](./getting-started.md) - Installation and basic setup
|
||||
- [Gmail Pub/Sub](./gmail-pubsub.md) - Email notifications integration
|
||||
- [Commands](./commands.md) - Bot commands reference
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Your Server / Machine │
|
||||
│ │
|
||||
│ ┌────────────────┐ ┌───────────────────────────────────────┐ │
|
||||
│ │ Telegram │ │ LettaBot Core │ │
|
||||
│ │ Bot API │◀──────▶│ (TypeScript/Node) │ │
|
||||
│ │ (grammY) │ │ │ │
|
||||
│ └────────────────┘ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ │ Session Manager │ │ │
|
||||
│ │ │ userId → agentId (persisted) │ │ │
|
||||
│ │ └─────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ │ Letta Code SDK │ │ │
|
||||
│ │ │ createSession/resumeSession │ │ │
|
||||
│ │ └──────────────┬──────────────────┘ │ │
|
||||
│ └─────────────────┼─────────────────────┘ │
|
||||
│ │ spawn subprocess │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Letta Code CLI │ │
|
||||
│ │ (--input-format stream-json) │ │
|
||||
│ │ │ │
|
||||
│ │ Local Tool Execution: │ │
|
||||
│ │ • Read/Glob/Grep - file ops │ │
|
||||
│ │ • Task - spawn subagents │ │
|
||||
│ │ • web_search - internet queries │ │
|
||||
│ └──────────────────┬───────────────────┘ │
|
||||
└───────────────────────────────────────────────┼───────────────────────┘
|
||||
│ Letta API
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ Letta Server │
|
||||
│ (api.letta.com or self-hosted) │
|
||||
│ │
|
||||
│ • Agent Memory (persistent) │
|
||||
│ • LLM Inference │
|
||||
│ • Conversation History │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Persistent Memory** - Your agent remembers conversations across days/weeks/months
|
||||
- **Local Tool Execution** - Agent can search files, run commands on your machine
|
||||
- **Multi-user Support** - Each Telegram user gets their own persistent agent
|
||||
- **Streaming Responses** - Real-time message updates as the agent thinks
|
||||
- **Gmail Integration** - Get email summaries delivered to Telegram
|
||||
91
docs/commands.md
Normal file
91
docs/commands.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Commands Reference
|
||||
|
||||
LettaBot responds to these slash commands in Telegram.
|
||||
|
||||
## Available Commands
|
||||
|
||||
### `/start` or `/help`
|
||||
|
||||
Shows the welcome message and list of available commands.
|
||||
|
||||
```
|
||||
🤖 LettaBot - AI assistant with persistent memory
|
||||
|
||||
Commands:
|
||||
/new - Start a new conversation (keeps memory)
|
||||
/reset - Create a new agent (fresh memory)
|
||||
/status - Show current agent ID
|
||||
/help - Show this message
|
||||
|
||||
Just send me a message to get started!
|
||||
```
|
||||
|
||||
### `/new`
|
||||
|
||||
Starts a new conversation while keeping the same agent and memory.
|
||||
|
||||
Use this when you want to change topics but keep your agent's memory of who you are and past interactions.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
You: /new
|
||||
Bot: Started a new conversation. Your agent still remembers you!
|
||||
You: Let's talk about something different now.
|
||||
```
|
||||
|
||||
### `/reset`
|
||||
|
||||
Creates a completely fresh agent with no memory.
|
||||
|
||||
Use this if you want to start over from scratch, as if you've never talked to the bot before.
|
||||
|
||||
**Warning:** This permanently deletes your agent's memory of past conversations.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
You: /reset
|
||||
Bot: Created a fresh agent with no memory. Send a message to begin!
|
||||
```
|
||||
|
||||
### `/status`
|
||||
|
||||
Shows your current agent ID.
|
||||
|
||||
Useful for debugging or if you need to reference your agent in other tools.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
You: /status
|
||||
Bot: Current agent: agent-a1b2c3d4-e5f6-7890-abcd-ef1234567890
|
||||
```
|
||||
|
||||
## Sending Messages
|
||||
|
||||
Just type any message to chat with your agent. The agent has:
|
||||
|
||||
- **Persistent memory** - Remembers your conversations over time
|
||||
- **Tool access** - Can search files, browse the web, and more
|
||||
- **Streaming responses** - You'll see the response appear in real-time
|
||||
|
||||
**Tips:**
|
||||
- Be specific in your requests
|
||||
- The agent remembers context, so you can refer back to previous conversations
|
||||
- For long tasks, the "typing..." indicator will stay active
|
||||
|
||||
## Formatting
|
||||
|
||||
The bot supports markdown formatting in responses:
|
||||
|
||||
- **Bold** text
|
||||
- *Italic* text
|
||||
- `Inline code`
|
||||
- ```Code blocks```
|
||||
- [Links](https://example.com)
|
||||
|
||||
## Future Commands
|
||||
|
||||
These commands are planned for future releases:
|
||||
|
||||
- `/model <name>` - Switch the LLM model
|
||||
- `/verbose` - Toggle tool output visibility
|
||||
- `/context` - Show memory summary
|
||||
100
docs/getting-started.md
Normal file
100
docs/getting-started.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Getting Started
|
||||
|
||||
Get LettaBot running in 5 minutes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
- A Telegram account
|
||||
- A Letta account ([app.letta.com](https://app.letta.com))
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone and Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/lettabot.git
|
||||
cd lettabot
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Create a Telegram Bot
|
||||
|
||||
1. Open Telegram and message [@BotFather](https://t.me/BotFather)
|
||||
2. Send `/newbot` and follow the prompts
|
||||
3. Copy the **bot token** (looks like `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||
|
||||
### 3. Get a Letta API Key
|
||||
|
||||
1. Go to [app.letta.com](https://app.letta.com)
|
||||
2. Sign in or create an account
|
||||
3. Go to Settings > API Keys
|
||||
4. Create a new API key and copy it
|
||||
|
||||
### 4. Configure LettaBot
|
||||
|
||||
**Option A: Interactive Setup (Recommended)**
|
||||
|
||||
```bash
|
||||
npm run setup
|
||||
```
|
||||
|
||||
This will walk you through configuration interactively.
|
||||
|
||||
**Option B: Manual Setup**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
```bash
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
LETTA_API_KEY=your_letta_api_key
|
||||
```
|
||||
|
||||
### 5. Start the Bot
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
Starting LettaBot...
|
||||
Bot started as @your_bot_name
|
||||
Allowed users: all
|
||||
```
|
||||
|
||||
### 6. Chat with Your Bot
|
||||
|
||||
Open Telegram and message your bot. Try:
|
||||
- "Hello!"
|
||||
- "What can you help me with?"
|
||||
- "Remember that my favorite color is blue"
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `TELEGRAM_BOT_TOKEN` | Yes | From @BotFather |
|
||||
| `LETTA_API_KEY` | Yes | From app.letta.com |
|
||||
| `ALLOWED_USERS` | No | Comma-separated Telegram user IDs to allow |
|
||||
| `WORKING_DIR` | No | Base directory for agent workspaces (default: `/tmp/lettabot`) |
|
||||
| `LETTA_CLI_PATH` | No | Custom path to letta CLI |
|
||||
|
||||
## Restricting Access
|
||||
|
||||
To limit who can use your bot, set `ALLOWED_USERS`:
|
||||
|
||||
```bash
|
||||
# Find your Telegram user ID by messaging @userinfobot
|
||||
ALLOWED_USERS=123456789,987654321
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Commands Reference](./commands.md) - Learn all bot commands
|
||||
- [Gmail Integration](./gmail-pubsub.md) - Set up email notifications
|
||||
- Check out [PLAN.md](../PLAN.md) for the full roadmap
|
||||
273
docs/gmail-pubsub.md
Normal file
273
docs/gmail-pubsub.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Gmail Pub/Sub Integration
|
||||
|
||||
Receive email notifications and have your Letta agent process them automatically.
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌─────────────┐
|
||||
│ Gmail │───▶│ Google Cloud │───▶│ LettaBot │───▶│ Letta │
|
||||
│ Inbox │ │ Pub/Sub │ │ Webhook Server │ │ Agent │
|
||||
└─────────────┘ └──────────────┘ └─────────────────┘ └──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Telegram │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
When a new email arrives:
|
||||
1. Gmail sends a notification to Google Cloud Pub/Sub
|
||||
2. Pub/Sub pushes the notification to LettaBot's webhook
|
||||
3. LettaBot fetches the email details via Gmail API
|
||||
4. The email is sent to your Letta agent for processing
|
||||
5. The agent's response is delivered to you on Telegram
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Google Cloud account with billing enabled
|
||||
- Gmail account you want to monitor
|
||||
- LettaBot running with a public URL (for Pub/Sub push)
|
||||
- `gcloud` CLI installed ([install guide](https://cloud.google.com/sdk/docs/install))
|
||||
|
||||
## Setup Guide
|
||||
|
||||
### Step 1: Create a Google Cloud Project
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a new project or select an existing one
|
||||
3. Note your **Project ID** (you'll need it later)
|
||||
|
||||
### Step 2: Enable Required APIs
|
||||
|
||||
```bash
|
||||
# Set your project
|
||||
gcloud config set project YOUR_PROJECT_ID
|
||||
|
||||
# Enable Gmail and Pub/Sub APIs
|
||||
gcloud services enable gmail.googleapis.com pubsub.googleapis.com
|
||||
```
|
||||
|
||||
### Step 3: Create OAuth2 Credentials
|
||||
|
||||
1. Go to [APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials)
|
||||
2. Click **Create Credentials** > **OAuth client ID**
|
||||
3. Select **Desktop app** as the application type
|
||||
4. Name it "LettaBot Gmail"
|
||||
5. Download the JSON file
|
||||
6. Note the **Client ID** and **Client Secret**
|
||||
|
||||
### Step 4: Get a Refresh Token
|
||||
|
||||
You need to authorize LettaBot to access your Gmail. Use this script:
|
||||
|
||||
```bash
|
||||
# Install the Google auth library
|
||||
npm install googleapis
|
||||
|
||||
# Run this script to get a refresh token
|
||||
node -e "
|
||||
const { google } = require('googleapis');
|
||||
const readline = require('readline');
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
'YOUR_CLIENT_ID',
|
||||
'YOUR_CLIENT_SECRET',
|
||||
'urn:ietf:wg:oauth:2.0:oob'
|
||||
);
|
||||
|
||||
const authUrl = oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: ['https://www.googleapis.com/auth/gmail.readonly'],
|
||||
});
|
||||
|
||||
console.log('Authorize this app by visiting:', authUrl);
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question('Enter the code: ', async (code) => {
|
||||
const { tokens } = await oauth2Client.getToken(code);
|
||||
console.log('Refresh Token:', tokens.refresh_token);
|
||||
rl.close();
|
||||
});
|
||||
"
|
||||
```
|
||||
|
||||
Save the **Refresh Token** - you'll need it for configuration.
|
||||
|
||||
### Step 5: Create Pub/Sub Topic and Subscription
|
||||
|
||||
```bash
|
||||
# Create a topic for Gmail notifications
|
||||
gcloud pubsub topics create lettabot-gmail
|
||||
|
||||
# Grant Gmail permission to publish to the topic
|
||||
gcloud pubsub topics add-iam-policy-binding lettabot-gmail \
|
||||
--member="serviceAccount:gmail-api-push@system.gserviceaccount.com" \
|
||||
--role="roles/pubsub.publisher"
|
||||
```
|
||||
|
||||
### Step 6: Configure LettaBot
|
||||
|
||||
Add these to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Enable Gmail integration
|
||||
GMAIL_ENABLED=true
|
||||
|
||||
# Webhook server port (must be accessible from internet)
|
||||
GMAIL_WEBHOOK_PORT=8788
|
||||
|
||||
# Shared secret for validating Pub/Sub requests (generate a random string)
|
||||
GMAIL_WEBHOOK_TOKEN=your_random_secret_here
|
||||
|
||||
# OAuth2 credentials from Step 3
|
||||
GMAIL_CLIENT_ID=your_client_id.apps.googleusercontent.com
|
||||
GMAIL_CLIENT_SECRET=your_client_secret
|
||||
|
||||
# Refresh token from Step 4
|
||||
GMAIL_REFRESH_TOKEN=your_refresh_token
|
||||
|
||||
# Your Telegram user ID (to receive email notifications)
|
||||
# Find this by messaging @userinfobot on Telegram
|
||||
GMAIL_TELEGRAM_USER=123456789
|
||||
```
|
||||
|
||||
### Step 7: Expose Webhook to Internet
|
||||
|
||||
LettaBot's webhook server needs to be accessible from the internet for Pub/Sub to push notifications.
|
||||
|
||||
**Option A: Using Tailscale Funnel (recommended)**
|
||||
```bash
|
||||
tailscale funnel 8788
|
||||
```
|
||||
|
||||
**Option B: Using ngrok**
|
||||
```bash
|
||||
ngrok http 8788
|
||||
```
|
||||
|
||||
**Option C: Using Cloudflare Tunnel**
|
||||
```bash
|
||||
cloudflared tunnel --url http://localhost:8788
|
||||
```
|
||||
|
||||
Note your public URL (e.g., `https://your-domain.ts.net` or `https://abc123.ngrok.io`).
|
||||
|
||||
### Step 8: Create Pub/Sub Push Subscription
|
||||
|
||||
```bash
|
||||
# Replace with your actual public URL and token
|
||||
gcloud pubsub subscriptions create lettabot-gmail-push \
|
||||
--topic=lettabot-gmail \
|
||||
--push-endpoint="https://YOUR_PUBLIC_URL/webhooks/gmail?token=YOUR_WEBHOOK_TOKEN" \
|
||||
--ack-deadline=60
|
||||
```
|
||||
|
||||
### Step 9: Start Gmail Watch
|
||||
|
||||
You need to tell Gmail to send notifications to your Pub/Sub topic. This watch expires after 7 days and needs to be renewed.
|
||||
|
||||
**Using the Gmail API directly:**
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
'https://gmail.googleapis.com/gmail/v1/users/me/watch' \
|
||||
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"topicName": "projects/YOUR_PROJECT_ID/topics/lettabot-gmail",
|
||||
"labelIds": ["INBOX"]
|
||||
}'
|
||||
```
|
||||
|
||||
**Using a helper script:**
|
||||
|
||||
```javascript
|
||||
// watch-gmail.js
|
||||
const { google } = require('googleapis');
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
process.env.GMAIL_CLIENT_ID,
|
||||
process.env.GMAIL_CLIENT_SECRET
|
||||
);
|
||||
oauth2Client.setCredentials({ refresh_token: process.env.GMAIL_REFRESH_TOKEN });
|
||||
|
||||
const gmail = google.gmail({ version: 'v1', auth: oauth2Client });
|
||||
|
||||
async function startWatch() {
|
||||
const res = await gmail.users.watch({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
topicName: 'projects/YOUR_PROJECT_ID/topics/lettabot-gmail',
|
||||
labelIds: ['INBOX'],
|
||||
},
|
||||
});
|
||||
console.log('Watch started:', res.data);
|
||||
console.log('Expires:', new Date(parseInt(res.data.expiration)));
|
||||
}
|
||||
|
||||
startWatch();
|
||||
```
|
||||
|
||||
### Step 10: Start LettaBot
|
||||
|
||||
```bash
|
||||
cd /path/to/lettabot
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
Starting LettaBot...
|
||||
Bot started as @your_bot
|
||||
Gmail webhook enabled on port 8788
|
||||
Gmail webhook server listening on port 8788
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
1. Send a test email to your Gmail account
|
||||
2. Check LettaBot logs for the notification
|
||||
3. You should receive a Telegram message with the agent's summary
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Invalid token" error
|
||||
- Make sure `GMAIL_WEBHOOK_TOKEN` matches the `?token=` in your Pub/Sub subscription
|
||||
|
||||
### No notifications received
|
||||
- Verify your public URL is accessible: `curl https://YOUR_URL/health`
|
||||
- Check Pub/Sub subscription for errors in Google Cloud Console
|
||||
- Make sure Gmail watch is active (re-run the watch command)
|
||||
|
||||
### "User not authorized" error
|
||||
- Ensure you granted `roles/pubsub.publisher` to `gmail-api-push@system.gserviceaccount.com`
|
||||
|
||||
### Watch expired
|
||||
- Gmail watches expire after 7 days
|
||||
- Set up a cron job to renew: `0 0 * * * node watch-gmail.js`
|
||||
|
||||
## Cleanup
|
||||
|
||||
To disable Gmail integration:
|
||||
|
||||
```bash
|
||||
# Stop the watch
|
||||
curl -X POST \
|
||||
'https://gmail.googleapis.com/gmail/v1/users/me/stop' \
|
||||
-H "Authorization: Bearer $(gcloud auth print-access-token)"
|
||||
|
||||
# Delete Pub/Sub resources
|
||||
gcloud pubsub subscriptions delete lettabot-gmail-push
|
||||
gcloud pubsub topics delete lettabot-gmail
|
||||
|
||||
# Remove from .env
|
||||
# GMAIL_ENABLED=false
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Keep your `GMAIL_WEBHOOK_TOKEN` secret
|
||||
- The refresh token grants read access to your Gmail - store it securely
|
||||
- Consider using a service account for production deployments
|
||||
- LettaBot only reads emails, it cannot send or modify them
|
||||
114
docs/signal-setup.md
Normal file
114
docs/signal-setup.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Signal Setup Guide
|
||||
|
||||
LettaBot can connect to Signal using [signal-cli](https://github.com/AsamK/signal-cli), a command-line interface for Signal.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 1. Install signal-cli
|
||||
|
||||
**macOS (Homebrew):**
|
||||
```bash
|
||||
brew install signal-cli
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
# Download latest release from https://github.com/AsamK/signal-cli/releases
|
||||
# Extract and add to PATH
|
||||
```
|
||||
|
||||
### 2. Register Your Phone Number
|
||||
|
||||
You need a phone number that can receive SMS for verification.
|
||||
|
||||
```bash
|
||||
# Request verification code (sent via SMS)
|
||||
signal-cli -a +1XXXXXXXXXX register
|
||||
|
||||
# Enter the code you receive
|
||||
signal-cli -a +1XXXXXXXXXX verify CODE
|
||||
```
|
||||
|
||||
**Note:** You can only have one Signal client per number. Registering signal-cli will log out your Signal mobile app. Consider using a secondary number.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to your `.env`:
|
||||
|
||||
```bash
|
||||
# Required: Phone number you registered
|
||||
SIGNAL_PHONE_NUMBER=+17075204676
|
||||
|
||||
# Optional: Path to signal-cli (if not in PATH)
|
||||
# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
|
||||
|
||||
# Optional: HTTP daemon settings (default: 127.0.0.1:8090)
|
||||
# SIGNAL_HTTP_HOST=127.0.0.1
|
||||
# SIGNAL_HTTP_PORT=8090
|
||||
|
||||
# Optional: DM access policy (default: pairing)
|
||||
# SIGNAL_DM_POLICY=pairing
|
||||
|
||||
# Optional: Self-chat mode for "Note to Self" (default: true)
|
||||
# SIGNAL_SELF_CHAT_MODE=true
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
LettaBot automatically:
|
||||
1. Starts signal-cli in daemon mode (JSON-RPC over HTTP)
|
||||
2. Connects via Server-Sent Events (SSE) for incoming messages
|
||||
3. Sends replies via JSON-RPC
|
||||
|
||||
The daemon runs on port 8090 by default to avoid conflicts with other services.
|
||||
|
||||
## Features
|
||||
|
||||
- **Direct Messages** - Receive and respond to DMs
|
||||
- **Note to Self** - Use Signal's "Note to Self" feature to message yourself (selfChatMode)
|
||||
- **Pairing System** - Unknown senders get a pairing code (same as Telegram)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Conflict
|
||||
If port 8090 is in use, change it:
|
||||
```bash
|
||||
SIGNAL_HTTP_PORT=8091
|
||||
```
|
||||
|
||||
### Daemon Won't Start
|
||||
Check if signal-cli is in your PATH:
|
||||
```bash
|
||||
which signal-cli
|
||||
```
|
||||
|
||||
If not, set the full path:
|
||||
```bash
|
||||
SIGNAL_CLI_PATH=/opt/homebrew/bin/signal-cli
|
||||
```
|
||||
|
||||
### "Note to Self" Not Working
|
||||
Messages you send to yourself appear via `syncMessage.sentMessage`, not `dataMessage`. LettaBot handles this automatically when `SIGNAL_SELF_CHAT_MODE=true` (the default).
|
||||
|
||||
### Registration Issues
|
||||
If you get errors during registration:
|
||||
1. Make sure the number can receive SMS
|
||||
2. Try with `--captcha` if prompted
|
||||
3. Check signal-cli GitHub issues for common problems
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────┐ HTTP ┌──────────────┐
|
||||
│ LettaBot │◄────────────►│ signal-cli │
|
||||
│ (Signal.ts) │ (JSON-RPC) │ (daemon) │
|
||||
└────────────────┘ └──────┬───────┘
|
||||
│ │
|
||||
│ SSE (events) │ Signal Protocol
|
||||
│◄──────────────────────────────┤
|
||||
│ ▼
|
||||
│ ┌──────────────┐
|
||||
│ │ Signal │
|
||||
│ │ Servers │
|
||||
│ └──────────────┘
|
||||
```
|
||||
215
docs/slack-setup.md
Normal file
215
docs/slack-setup.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# Slack Setup for LettaBot
|
||||
|
||||
This guide walks you through setting up Slack as a channel for LettaBot.
|
||||
|
||||
## Overview
|
||||
|
||||
LettaBot connects to Slack using **Socket Mode**, which means:
|
||||
- No public URL required (no ngrok needed)
|
||||
- Works behind firewalls
|
||||
- Real-time bidirectional communication
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Slack workspace where you have permission to install apps
|
||||
- LettaBot installed and configured with at least `LETTA_API_KEY`
|
||||
|
||||
## Step 1: Create a Slack App
|
||||
|
||||
1. Go to **https://api.slack.com/apps**
|
||||
2. Click **"Create New App"**
|
||||
3. Choose **"From scratch"**
|
||||
4. Enter:
|
||||
- **App Name**: `LettaBot` (or your preferred name)
|
||||
- **Workspace**: Select your workspace
|
||||
5. Click **"Create App"**
|
||||
|
||||
## Step 2: Enable Socket Mode
|
||||
|
||||
Socket Mode lets your bot connect without exposing a public endpoint.
|
||||
|
||||
1. In the left sidebar, click **"Socket Mode"**
|
||||
2. Toggle **"Enable Socket Mode"** → ON
|
||||
3. You'll be prompted to create an **App-Level Token**:
|
||||
- **Token Name**: `socket-token`
|
||||
- **Scopes**: Add `connections:write`
|
||||
- Click **"Generate"**
|
||||
4. **Copy the token** (starts with `xapp-`)
|
||||
|
||||
This is your `SLACK_APP_TOKEN`
|
||||
|
||||
## Step 3: Set Bot Permissions
|
||||
|
||||
1. In the left sidebar, go to **"OAuth & Permissions"**
|
||||
2. Scroll to **"Scopes"** → **"Bot Token Scopes"**
|
||||
3. Add these scopes:
|
||||
|
||||
| Scope | Purpose |
|
||||
|-------|---------|
|
||||
| `app_mentions:read` | React when someone @mentions your bot |
|
||||
| `chat:write` | Send messages |
|
||||
| `im:history` | Read DM message history |
|
||||
| `im:read` | View DM channel info |
|
||||
| `im:write` | Start DM conversations |
|
||||
|
||||
## Step 4: Enable Events
|
||||
|
||||
1. In the left sidebar, go to **"Event Subscriptions"**
|
||||
2. Toggle **"Enable Events"** → ON
|
||||
3. Scroll to **"Subscribe to bot events"**
|
||||
4. Add these events:
|
||||
|
||||
| Event | Purpose |
|
||||
|-------|---------|
|
||||
| `app_mention` | Triggers when someone @mentions your bot in a channel |
|
||||
| `message.im` | Triggers when someone DMs your bot |
|
||||
|
||||
5. Click **"Save Changes"**
|
||||
|
||||
> **Important**: If you add or change events after installing the app, you must reinstall it (Step 6) for changes to take effect. The token stays the same.
|
||||
|
||||
## Step 5: Configure App Home (Optional but Recommended)
|
||||
|
||||
1. In the left sidebar, go to **"App Home"**
|
||||
2. Under **"Show Tabs"**, enable:
|
||||
- **Messages Tab** → ON (allows DMs to your bot)
|
||||
- Check **"Allow users to send Slash commands and messages from the messages tab"**
|
||||
|
||||
## Step 6: Install to Workspace
|
||||
|
||||
1. In the left sidebar, go to **"Install App"**
|
||||
2. Click **"Install to Workspace"**
|
||||
3. Review the permissions and click **"Allow"**
|
||||
4. **Copy the Bot Token** (starts with `xoxb-`)
|
||||
|
||||
This is your `SLACK_BOT_TOKEN`
|
||||
|
||||
## Step 7: Configure LettaBot
|
||||
|
||||
Add these to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Slack Configuration
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
|
||||
SLACK_APP_TOKEN=xapp-your-app-token-here
|
||||
|
||||
# Optional: Restrict to specific Slack user IDs
|
||||
# SLACK_ALLOWED_USERS=U01234567,U98765432
|
||||
```
|
||||
|
||||
## Step 8: Start LettaBot
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
Registered channel: Slack
|
||||
[Slack] Bot started in Socket Mode
|
||||
```
|
||||
|
||||
## Step 9: Test the Integration
|
||||
|
||||
### Direct Message
|
||||
1. In Slack, look for your bot under **Apps** in the left sidebar
|
||||
2. Click on the bot to open a DM
|
||||
3. Send a message: `Hello!`
|
||||
4. The bot should respond
|
||||
|
||||
### Channel Mention
|
||||
1. Invite your bot to a channel: `/invite @LettaBot`
|
||||
2. Mention the bot: `@LettaBot what time is it?`
|
||||
3. The bot should respond in the channel
|
||||
|
||||
### Thread Replies
|
||||
- If you mention the bot in a thread, it will reply in that thread
|
||||
- If you mention the bot in a channel (not a thread), it starts a new thread from your message
|
||||
|
||||
## Cross-Channel Memory
|
||||
|
||||
Since LettaBot uses a single agent across all channels:
|
||||
- Messages you send on Slack continue the same conversation as Telegram
|
||||
- The agent remembers context from both channels
|
||||
- You can start a conversation on Telegram and continue it on Slack
|
||||
|
||||
## Restricting Access
|
||||
|
||||
To restrict which Slack users can interact with the bot:
|
||||
|
||||
1. Find user IDs:
|
||||
- Click on a user's profile in Slack
|
||||
- Click the **⋮** menu → **"Copy member ID"**
|
||||
- IDs look like `U01ABCD2EFG`
|
||||
|
||||
2. Add to `.env`:
|
||||
```bash
|
||||
SLACK_ALLOWED_USERS=U01234567,U98765432
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot not connecting
|
||||
|
||||
**Error**: "Socket Mode not enabled"
|
||||
- Go to api.slack.com/apps → Your App → Socket Mode → Enable it
|
||||
|
||||
**Error**: "invalid_auth"
|
||||
- Double-check your `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN`
|
||||
- Make sure you copied the full tokens including the `xoxb-` and `xapp-` prefixes
|
||||
|
||||
### Bot not responding to DMs
|
||||
|
||||
1. Go to **App Home** in your Slack app settings
|
||||
2. Enable **"Messages Tab"**
|
||||
3. Check **"Allow users to send messages from the messages tab"**
|
||||
4. Reinstall the app if you changed these settings
|
||||
|
||||
### "Sending messages to this app has been turned off"
|
||||
|
||||
Even after enabling the Messages Tab and reinstalling, Slack may cache the old state:
|
||||
1. **Refresh Slack**: Press `Cmd+R` (Mac) or `Ctrl+R` (Windows/Linux)
|
||||
2. **Or restart Slack** entirely
|
||||
3. If still stuck, remove the bot from your Apps sidebar and re-add it
|
||||
|
||||
### Bot not responding to @mentions
|
||||
|
||||
1. Go to **Event Subscriptions**
|
||||
2. Make sure **"Enable Events"** is toggled ON
|
||||
3. Make sure `app_mention` is listed under "Subscribe to bot events"
|
||||
4. Click **"Save Changes"** at the bottom
|
||||
5. Go to **OAuth & Permissions**
|
||||
6. Make sure `app_mentions:read` scope is added
|
||||
7. Reinstall the app: **Install App** → **Reinstall to Workspace**
|
||||
|
||||
> **Note**: Adding scopes only gives permission; you must also subscribe to events to receive them.
|
||||
|
||||
### "missing_scope" error
|
||||
|
||||
If you see a missing scope error:
|
||||
1. Go to **OAuth & Permissions**
|
||||
2. Add the missing scope
|
||||
3. Go to **Install App** → **Reinstall to Workspace**
|
||||
|
||||
### Bot responds slowly
|
||||
|
||||
This is normal - the bot needs to:
|
||||
1. Receive your message
|
||||
2. Send it to the Letta agent
|
||||
3. Wait for the agent to respond
|
||||
4. Stream the response back
|
||||
|
||||
First responses may take longer as the agent "wakes up".
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Socket Mode tokens** (`xapp-`) should be kept secret
|
||||
- **Bot tokens** (`xoxb-`) should be kept secret
|
||||
- Use `SLACK_ALLOWED_USERS` in production to restrict access
|
||||
- The bot can only see messages in channels it's invited to, or DMs sent directly to it
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [WhatsApp Setup](./whatsapp-setup.md)
|
||||
- [Cron Jobs](./cron-setup.md)
|
||||
- [Configuration Reference](./configuration.md)
|
||||
182
docs/whatsapp-setup.md
Normal file
182
docs/whatsapp-setup.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# WhatsApp Setup for LettaBot
|
||||
|
||||
This guide walks you through setting up WhatsApp as a channel for LettaBot.
|
||||
|
||||
## Overview
|
||||
|
||||
LettaBot connects to WhatsApp using **Baileys**, which uses the WhatsApp Web protocol. This means:
|
||||
- Uses your personal WhatsApp account (or a dedicated number)
|
||||
- No WhatsApp Business API required
|
||||
- Free to use
|
||||
- Requires QR code scan on first setup
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A phone with WhatsApp installed
|
||||
- LettaBot installed and configured with at least `LETTA_API_KEY`
|
||||
|
||||
## Step 1: Enable WhatsApp in Configuration
|
||||
|
||||
Add to your `.env` file:
|
||||
|
||||
```bash
|
||||
# WhatsApp Configuration
|
||||
WHATSAPP_ENABLED=true
|
||||
|
||||
# Optional: Custom session storage path (default: ./data/whatsapp-session)
|
||||
# WHATSAPP_SESSION_PATH=./data/whatsapp-session
|
||||
|
||||
# Optional: Restrict to specific phone numbers
|
||||
# WHATSAPP_ALLOWED_USERS=+15551234567,+15559876543
|
||||
```
|
||||
|
||||
## Step 2: Start LettaBot
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You'll see output like:
|
||||
```
|
||||
Registered channel: WhatsApp
|
||||
[WhatsApp] Scan the QR code above to login
|
||||
```
|
||||
|
||||
A QR code will be displayed in your terminal.
|
||||
|
||||
## Step 3: Scan the QR Code
|
||||
|
||||
1. Open WhatsApp on your phone
|
||||
2. Go to **Settings** → **Linked Devices**
|
||||
3. Tap **"Link a Device"**
|
||||
4. Scan the QR code displayed in your terminal
|
||||
|
||||
Once connected, you'll see:
|
||||
```
|
||||
[WhatsApp] Connected!
|
||||
```
|
||||
|
||||
## Step 4: Test the Integration
|
||||
|
||||
1. From another phone (or WhatsApp Web with a different account), send a message to the number you just linked
|
||||
2. The bot should respond
|
||||
|
||||
Or test with yourself:
|
||||
1. Open a note-to-self chat (message yourself)
|
||||
2. Send a test message
|
||||
|
||||
## Session Persistence
|
||||
|
||||
After the first QR scan, your session is saved to disk (default: `./data/whatsapp-session/`).
|
||||
|
||||
On subsequent restarts:
|
||||
- No QR scan needed
|
||||
- Bot reconnects automatically
|
||||
- If session expires, a new QR code will be shown
|
||||
|
||||
## Restricting Access
|
||||
|
||||
To restrict which phone numbers can interact with the bot:
|
||||
|
||||
```bash
|
||||
# Include country code with + prefix
|
||||
WHATSAPP_ALLOWED_USERS=+15551234567,+15559876543
|
||||
```
|
||||
|
||||
**Note**: Only DMs are restricted. Group messages may still be received (but not responded to unless the sender is allowed).
|
||||
|
||||
## Cross-Channel Memory
|
||||
|
||||
Since LettaBot uses a single agent across all channels:
|
||||
- Messages from WhatsApp continue the same conversation as Telegram/Slack
|
||||
- The agent remembers context from all channels
|
||||
- Start a conversation on Telegram, continue it on WhatsApp
|
||||
|
||||
## Important Limitations
|
||||
|
||||
### Message Editing
|
||||
WhatsApp doesn't support editing messages. When the bot streams a long response, it will send the full message at once rather than updating incrementally.
|
||||
|
||||
### Rate Limits
|
||||
WhatsApp has strict rate limits to prevent spam:
|
||||
- Don't send too many messages too quickly
|
||||
- Avoid automated bulk messaging
|
||||
- Risk of account ban if abused
|
||||
|
||||
### Personal Account
|
||||
This uses your personal WhatsApp account:
|
||||
- Messages appear as coming from your number
|
||||
- Consider using a dedicated phone number for the bot
|
||||
- Your contacts will see the bot as "you"
|
||||
|
||||
### Multi-Device Limitations
|
||||
- WhatsApp allows up to 4 linked devices
|
||||
- The bot counts as one linked device
|
||||
- If you unlink the bot, you'll need to scan the QR code again
|
||||
|
||||
## Running in Production
|
||||
|
||||
For production deployments:
|
||||
|
||||
1. **Use a dedicated phone number** - Don't use your personal WhatsApp
|
||||
2. **Persistent storage** - Make sure `WHATSAPP_SESSION_PATH` points to persistent storage
|
||||
3. **Monitor reconnections** - The bot auto-reconnects, but check logs for issues
|
||||
4. **Backup session** - Back up the session folder to avoid re-scanning QR codes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### QR Code Not Showing
|
||||
|
||||
If no QR code appears:
|
||||
- Check that `WHATSAPP_ENABLED=true` is set
|
||||
- Look for error messages in the console
|
||||
- Make sure you have a terminal that supports QR code display
|
||||
|
||||
### "Connection Closed" Errors
|
||||
|
||||
This usually means:
|
||||
- WhatsApp Web session was logged out from your phone
|
||||
- Network connectivity issues
|
||||
- WhatsApp servers are temporarily unavailable
|
||||
|
||||
The bot will automatically try to reconnect.
|
||||
|
||||
### "Logged Out" State
|
||||
|
||||
If you see "logged out" in the logs:
|
||||
1. Delete the session folder: `rm -rf ./data/whatsapp-session`
|
||||
2. Restart LettaBot
|
||||
3. Scan the new QR code
|
||||
|
||||
### Messages Not Being Received
|
||||
|
||||
1. Make sure the sender's number is in `WHATSAPP_ALLOWED_USERS` (if configured)
|
||||
2. Check that the message is a text message (media not fully supported yet)
|
||||
3. Look for errors in the console
|
||||
|
||||
### Bot Responding to Old Messages
|
||||
|
||||
On first connection, the bot might receive some historical messages. This is normal WhatsApp behavior. The bot will only respond to new messages going forward.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Session files** contain authentication data - keep them secure
|
||||
- Don't share your session folder
|
||||
- Use `WHATSAPP_ALLOWED_USERS` to restrict who can interact with the bot
|
||||
- Consider the privacy implications of using a personal number
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
data/
|
||||
└── whatsapp-session/
|
||||
├── creds.json # Authentication credentials
|
||||
├── app-state-sync-* # App state
|
||||
└── ... # Other session data
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Slack Setup](./slack-setup.md)
|
||||
- [Cron Jobs](./cron-setup.md)
|
||||
- [Configuration Reference](./configuration.md)
|
||||
BIN
image (1).png
Normal file
BIN
image (1).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
6416
package-lock.json
generated
Normal file
6416
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
package.json
Normal file
61
package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "lettabot",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "dist/main.js",
|
||||
"bin": {
|
||||
"lettabot": "./dist/cli.js",
|
||||
"lettabot-schedule": "./dist/cron/cli.js",
|
||||
"lettabot-message": "./dist/cli/message.js"
|
||||
},
|
||||
"scripts": {
|
||||
"setup": "tsx src/setup.ts",
|
||||
"dev": "tsx src/main.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/main.js",
|
||||
"skills": "tsx src/cli.ts skills",
|
||||
"skills:list": "tsx src/cli.ts skills list",
|
||||
"skills:status": "tsx src/cli.ts skills status",
|
||||
"cron": "tsx src/cron/cli.ts",
|
||||
"pairing": "tsx src/cli.ts pairing",
|
||||
"skill:install": "npx clawdhub install --dir ~/.letta/skills",
|
||||
"skill:search": "npx clawdhub search",
|
||||
"skill:list": "npx clawdhub list --dir ~/.letta/skills",
|
||||
"skills:add": "npx skills add --global --yes",
|
||||
"skills:find": "npx skills find"
|
||||
},
|
||||
"keywords": [
|
||||
"telegram",
|
||||
"bot",
|
||||
"letta",
|
||||
"ai",
|
||||
"agent"
|
||||
],
|
||||
"author": "",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Multi-channel AI assistant with persistent memory - Telegram, Slack, WhatsApp",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@hapi/boom": "^10.0.1",
|
||||
"@letta-ai/letta-client": "^1.7.6",
|
||||
"@letta-ai/letta-code-sdk": "^0.0.3",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/node-schedule": "^2.1.8",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"googleapis": "^170.1.0",
|
||||
"grammy": "^1.39.3",
|
||||
"gray-matter": "^4.0.3",
|
||||
"node-schedule": "^2.1.1",
|
||||
"open": "^11.0.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"telegram-markdown-v2": "^0.0.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@slack/bolt": "^4.6.0",
|
||||
"@whiskeysockets/baileys": "^6.7.21"
|
||||
}
|
||||
}
|
||||
37
scripts/install-skill.sh
Executable file
37
scripts/install-skill.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# Install a skill from ClawdHub
|
||||
# Usage: ./scripts/install-skill.sh <skill-name>
|
||||
|
||||
set -e
|
||||
|
||||
SKILL_NAME="$1"
|
||||
|
||||
if [ -z "$SKILL_NAME" ]; then
|
||||
echo "Usage: $0 <skill-name>"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 weather # Weather forecasts"
|
||||
echo " $0 github # GitHub CLI integration"
|
||||
echo " $0 sonoscli # Sonos speaker control"
|
||||
echo " $0 obsidian # Obsidian notes"
|
||||
echo ""
|
||||
echo "Browse all skills: https://clawdhub.com"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install clawdhub CLI if needed
|
||||
if ! command -v clawdhub &> /dev/null; then
|
||||
echo "Installing ClawdHub CLI..."
|
||||
npm install -g clawdhub
|
||||
fi
|
||||
|
||||
# Install the skill to global Letta skills directory
|
||||
# This is where Letta Code CLI looks for skills
|
||||
SKILLS_DIR="$HOME/.letta/skills"
|
||||
mkdir -p "$SKILLS_DIR"
|
||||
|
||||
echo "Installing skill: $SKILL_NAME to $SKILLS_DIR"
|
||||
clawdhub install "$SKILL_NAME" --dir "$SKILLS_DIR"
|
||||
|
||||
echo ""
|
||||
echo "Skill installed! It will be available to all Letta agents."
|
||||
131
skills/scheduling/SKILL.md
Normal file
131
skills/scheduling/SKILL.md
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
name: scheduling
|
||||
description: Create scheduled tasks and one-off reminders. Use for recurring jobs (daily, weekly) or future reminders (in 5 minutes, tomorrow at 3pm).
|
||||
---
|
||||
|
||||
# Scheduling
|
||||
|
||||
Schedule recurring tasks or one-off reminders.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
lettabot-schedule list # List all jobs
|
||||
lettabot-schedule create [options] # Create job
|
||||
lettabot-schedule delete ID # Delete job
|
||||
lettabot-schedule enable ID # Enable job
|
||||
lettabot-schedule disable ID # Disable job
|
||||
```
|
||||
|
||||
## One-Off Reminders (--at)
|
||||
|
||||
For reminders at a specific future time, use `--at` with an **ISO datetime**.
|
||||
|
||||
**Calculate the datetime in JavaScript:**
|
||||
```javascript
|
||||
// 5 minutes from now
|
||||
new Date(Date.now() + 5*60*1000).toISOString()
|
||||
// → "2026-01-28T20:15:00.000Z"
|
||||
|
||||
// 1 hour from now
|
||||
new Date(Date.now() + 60*60*1000).toISOString()
|
||||
|
||||
// Tomorrow at 9am (approximate)
|
||||
new Date(Date.now() + 24*60*60*1000).toISOString()
|
||||
```
|
||||
|
||||
**Create the reminder:**
|
||||
```bash
|
||||
lettabot-schedule create \
|
||||
--name "Standup" \
|
||||
--at "2026-01-28T20:15:00.000Z" \
|
||||
--message "Time for standup!"
|
||||
```
|
||||
|
||||
One-off reminders auto-delete after running.
|
||||
|
||||
**Options:**
|
||||
- `-n, --name` - Job name (required)
|
||||
- `-a, --at` - ISO datetime for one-off reminder (e.g., "2026-01-28T20:15:00Z")
|
||||
- `-m, --message` - Message sent to you when job runs (required)
|
||||
- `-d, --deliver` - Where to send response (format: `channel:chatId`). Defaults to last messaged chat.
|
||||
|
||||
## Recurring Schedules (--schedule)
|
||||
|
||||
For recurring tasks, use `--schedule` with a cron expression.
|
||||
|
||||
```bash
|
||||
lettabot-schedule create \
|
||||
--name "Morning Briefing" \
|
||||
--schedule "0 8 * * *" \
|
||||
--message "Good morning! What's on today's agenda?"
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `-n, --name` - Job name (required)
|
||||
- `-s, --schedule` - Cron expression (required for recurring)
|
||||
- `-m, --message` - Message sent to you when job runs (required)
|
||||
- `-d, --deliver` - Where to send response (format: `channel:chatId`). Defaults to last messaged chat.
|
||||
- `--disabled` - Create disabled
|
||||
|
||||
## Cron Schedule Syntax
|
||||
|
||||
```
|
||||
┌───────── minute (0-59)
|
||||
│ ┌─────── hour (0-23)
|
||||
│ │ ┌───── day of month (1-31)
|
||||
│ │ │ ┌─── month (1-12)
|
||||
│ │ │ │ ┌─ day of week (0-6, Sun=0)
|
||||
* * * * *
|
||||
```
|
||||
|
||||
| Pattern | When |
|
||||
|---------|------|
|
||||
| `0 8 * * *` | Daily at 8:00 AM |
|
||||
| `0 9 * * 1-5` | Weekdays at 9:00 AM |
|
||||
| `0 */2 * * *` | Every 2 hours |
|
||||
| `30 17 * * 5` | Fridays at 5:30 PM |
|
||||
| `0 0 1 * *` | First of month at midnight |
|
||||
|
||||
## Examples
|
||||
|
||||
**Remind me in 30 minutes:**
|
||||
```bash
|
||||
# First calculate: new Date(Date.now() + 30*60*1000).toISOString()
|
||||
lettabot-schedule create \
|
||||
-n "Break reminder" \
|
||||
--at "2026-01-28T20:45:00.000Z" \
|
||||
-m "Time for a break!"
|
||||
```
|
||||
|
||||
**Daily morning check-in:**
|
||||
```bash
|
||||
lettabot-schedule create \
|
||||
-n "Morning" \
|
||||
-s "0 8 * * *" \
|
||||
-m "Good morning! What's on today's agenda?"
|
||||
```
|
||||
|
||||
**Weekly review on Fridays:**
|
||||
```bash
|
||||
lettabot-schedule create \
|
||||
-n "Weekly Review" \
|
||||
-s "0 17 * * 5" \
|
||||
-m "Friday wrap-up: What did we accomplish?"
|
||||
```
|
||||
|
||||
## Message Format
|
||||
|
||||
When a scheduled job runs, you receive:
|
||||
|
||||
```
|
||||
[cron:job-123abc Morning] Good morning! What's on today's agenda?
|
||||
Current time: 1/27/2026, 8:00:00 AM (America/Los_Angeles)
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Jobs activate immediately when created (no restart needed)
|
||||
- One-off reminders (`--at`) auto-delete after running
|
||||
- Use `lettabot-schedule list` to see next run times
|
||||
- Jobs persist in `cron-jobs.json`
|
||||
224
src/auth/oauth.ts
Normal file
224
src/auth/oauth.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* OAuth 2.0 utilities for Letta Cloud authentication
|
||||
* Uses Device Code Flow for CLI authentication
|
||||
*
|
||||
* Ported from @letta-ai/letta-code
|
||||
*/
|
||||
|
||||
import Letta from "@letta-ai/letta-client";
|
||||
|
||||
export const LETTA_CLOUD_API_URL = "https://api.letta.com";
|
||||
|
||||
export const OAUTH_CONFIG = {
|
||||
clientId: "ci-let-724dea7e98f4af6f8f370f4b1466200c",
|
||||
clientSecret: "", // Not needed for device code flow
|
||||
authBaseUrl: "https://app.letta.com",
|
||||
apiBaseUrl: LETTA_CLOUD_API_URL,
|
||||
} as const;
|
||||
|
||||
export interface DeviceCodeResponse {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
verification_uri_complete: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface OAuthError {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device Code Flow - Step 1: Request device code
|
||||
*/
|
||||
export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
|
||||
const response = await fetch(
|
||||
`${OAUTH_CONFIG.authBaseUrl}/api/oauth/device/code`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_id: OAUTH_CONFIG.clientId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json()) as OAuthError;
|
||||
throw new Error(
|
||||
`Failed to request device code: ${error.error_description || error.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as DeviceCodeResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device Code Flow - Step 2: Poll for token
|
||||
*/
|
||||
export async function pollForToken(
|
||||
deviceCode: string,
|
||||
interval: number = 5,
|
||||
expiresIn: number = 900,
|
||||
deviceId: string,
|
||||
deviceName?: string,
|
||||
): Promise<TokenResponse> {
|
||||
const startTime = Date.now();
|
||||
const expiresInMs = expiresIn * 1000;
|
||||
let pollInterval = interval * 1000;
|
||||
|
||||
while (Date.now() - startTime < expiresInMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${OAUTH_CONFIG.authBaseUrl}/api/oauth/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: OAUTH_CONFIG.clientId,
|
||||
device_code: deviceCode,
|
||||
device_id: deviceId,
|
||||
...(deviceName && { device_name: deviceName }),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return result as TokenResponse;
|
||||
}
|
||||
|
||||
const error = result as OAuthError;
|
||||
|
||||
if (error.error === "authorization_pending") {
|
||||
// User hasn't authorized yet, keep polling
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error.error === "slow_down") {
|
||||
// We're polling too fast, increase interval by 5 seconds
|
||||
pollInterval += 5000;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error.error === "access_denied") {
|
||||
throw new Error("User denied authorization");
|
||||
}
|
||||
|
||||
if (error.error === "expired_token") {
|
||||
throw new Error("Device code expired");
|
||||
}
|
||||
|
||||
throw new Error(`OAuth error: ${error.error_description || error.error}`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to poll for token: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Timeout waiting for authorization (15 minutes)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token using a refresh token
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
deviceId: string,
|
||||
deviceName?: string,
|
||||
): Promise<TokenResponse> {
|
||||
const response = await fetch(`${OAUTH_CONFIG.authBaseUrl}/api/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
client_id: OAUTH_CONFIG.clientId,
|
||||
refresh_token: refreshToken,
|
||||
refresh_token_mode: "new",
|
||||
device_id: deviceId,
|
||||
...(deviceName && { device_name: deviceName }),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json()) as OAuthError;
|
||||
throw new Error(
|
||||
`Failed to refresh access token: ${error.error_description || error.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as TokenResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a refresh token (logout)
|
||||
*/
|
||||
export async function revokeToken(refreshToken: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${OAUTH_CONFIG.authBaseUrl}/api/oauth/revoke`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_id: OAUTH_CONFIG.clientId,
|
||||
token: refreshToken,
|
||||
token_type_hint: "refresh_token",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// OAuth 2.0 revoke endpoint should return 200 even if token is already invalid
|
||||
if (!response.ok) {
|
||||
const error = (await response.json()) as OAuthError;
|
||||
console.error(
|
||||
`Warning: Failed to revoke token: ${error.error_description || error.error}`,
|
||||
);
|
||||
// Don't throw - we still want to clear local credentials
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Warning: Failed to revoke token:", error);
|
||||
// Don't throw - we still want to clear local credentials
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate credentials by checking an authenticated endpoint
|
||||
* Uses SDK's agents.list() which requires valid authentication
|
||||
*/
|
||||
export async function validateCredentials(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Create a temporary client to test authentication
|
||||
const client = new Letta({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
defaultHeaders: { "X-Letta-Source": "lettabot" },
|
||||
});
|
||||
|
||||
// Try to list agents - this requires valid authentication
|
||||
await client.agents.list({ limit: 1 });
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
131
src/auth/tokens.ts
Normal file
131
src/auth/tokens.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Token storage utilities for OAuth credentials
|
||||
* Stores tokens at ~/.letta/lettabot/tokens.json
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir, hostname } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const TOKENS_DIR = join(homedir(), ".letta", "lettabot");
|
||||
const TOKENS_FILE = join(TOKENS_DIR, "tokens.json");
|
||||
|
||||
export interface TokenStore {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
tokenExpiresAt?: number; // Unix timestamp in milliseconds
|
||||
deviceId: string;
|
||||
deviceName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the tokens directory exists
|
||||
*/
|
||||
function ensureDir(): void {
|
||||
if (!existsSync(TOKENS_DIR)) {
|
||||
mkdirSync(TOKENS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tokens from disk
|
||||
*/
|
||||
export function loadTokens(): TokenStore | null {
|
||||
if (!existsSync(TOKENS_FILE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(TOKENS_FILE, "utf-8");
|
||||
return JSON.parse(content) as TokenStore;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save tokens to disk
|
||||
*/
|
||||
export function saveTokens(tokens: TokenStore): void {
|
||||
ensureDir();
|
||||
writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored tokens (logout)
|
||||
*/
|
||||
export function deleteTokens(): boolean {
|
||||
if (existsSync(TOKENS_FILE)) {
|
||||
unlinkSync(TOKENS_FILE);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a persistent device ID
|
||||
*/
|
||||
export function getOrCreateDeviceId(): string {
|
||||
const tokens = loadTokens();
|
||||
if (tokens?.deviceId) {
|
||||
return tokens.deviceId;
|
||||
}
|
||||
|
||||
// Check if there's a device ID file (for cases where tokens don't exist yet)
|
||||
const deviceIdFile = join(TOKENS_DIR, "device-id");
|
||||
if (existsSync(deviceIdFile)) {
|
||||
try {
|
||||
return readFileSync(deviceIdFile, "utf-8").trim();
|
||||
} catch {
|
||||
// Fall through to create new
|
||||
}
|
||||
}
|
||||
|
||||
// Create new device ID
|
||||
const deviceId = randomUUID();
|
||||
ensureDir();
|
||||
writeFileSync(deviceIdFile, deviceId);
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device name (hostname)
|
||||
*/
|
||||
export function getDeviceName(): string {
|
||||
return hostname();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the access token is expired or about to expire
|
||||
* @param bufferMs - Consider expired if within this many ms of expiry (default: 5 minutes)
|
||||
*/
|
||||
export function isTokenExpired(tokens: TokenStore | null, bufferMs = 5 * 60 * 1000): boolean {
|
||||
if (!tokens?.tokenExpiresAt) {
|
||||
// No expiry info, assume not expired
|
||||
return false;
|
||||
}
|
||||
|
||||
return Date.now() >= tokens.tokenExpiresAt - bufferMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have a valid refresh token
|
||||
*/
|
||||
export function hasRefreshToken(tokens: TokenStore | null): boolean {
|
||||
return !!tokens?.refreshToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current access token, or null if not logged in
|
||||
* This checks env var first, then stored tokens
|
||||
*/
|
||||
export function getAccessToken(): string | null {
|
||||
// Environment variable takes precedence
|
||||
if (process.env.LETTA_API_KEY) {
|
||||
return process.env.LETTA_API_KEY;
|
||||
}
|
||||
|
||||
const tokens = loadTokens();
|
||||
return tokens?.accessToken ?? null;
|
||||
}
|
||||
9
src/channels/index.ts
Normal file
9
src/channels/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Channel Adapters
|
||||
*/
|
||||
|
||||
export * from './types.js';
|
||||
export * from './telegram.js';
|
||||
export * from './slack.js';
|
||||
export * from './whatsapp.js';
|
||||
export * from './signal.js';
|
||||
120
src/channels/signal-format.ts
Normal file
120
src/channels/signal-format.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Signal Text Formatting
|
||||
*
|
||||
* Converts simple markdown (*bold*, _italic_, `code`) to Signal style ranges.
|
||||
* Uses single-pass processing to correctly track positions.
|
||||
*/
|
||||
|
||||
export type SignalStyleRange = {
|
||||
start: number;
|
||||
length: number;
|
||||
style: 'BOLD' | 'ITALIC' | 'MONOSPACE' | 'STRIKETHROUGH';
|
||||
};
|
||||
|
||||
export type SignalFormattedText = {
|
||||
text: string;
|
||||
styles: SignalStyleRange[];
|
||||
};
|
||||
|
||||
interface FoundMatch {
|
||||
index: number;
|
||||
fullMatch: string;
|
||||
content: string;
|
||||
style: SignalStyleRange['style'];
|
||||
markerLen: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown text to Signal formatted text with style ranges.
|
||||
* Supports: **bold**, *bold*, __italic__, _italic_, `code`, ~~strikethrough~~
|
||||
*/
|
||||
export function markdownToSignal(markdown: string): SignalFormattedText {
|
||||
// Find all matches first, then process in order
|
||||
const patterns: Array<{
|
||||
regex: RegExp;
|
||||
style: SignalStyleRange['style'];
|
||||
markerLen: number;
|
||||
}> = [
|
||||
{ regex: /\*\*(.+?)\*\*/g, style: 'BOLD', markerLen: 2 }, // **bold**
|
||||
{ regex: /(?<!\*)\*([^*]+)\*(?!\*)/g, style: 'BOLD', markerLen: 1 }, // *bold* (not **)
|
||||
{ regex: /__(.+?)__/g, style: 'ITALIC', markerLen: 2 }, // __italic__
|
||||
{ regex: /(?<!_)_([^_]+)_(?!_)/g, style: 'ITALIC', markerLen: 1 }, // _italic_ (not __)
|
||||
{ regex: /~~(.+?)~~/g, style: 'STRIKETHROUGH', markerLen: 2 }, // ~~strike~~
|
||||
{ regex: /`([^`]+)`/g, style: 'MONOSPACE', markerLen: 1 }, // `code`
|
||||
];
|
||||
|
||||
// Collect all matches with their original positions
|
||||
const allMatches: FoundMatch[] = [];
|
||||
|
||||
for (const { regex, style, markerLen } of patterns) {
|
||||
let match;
|
||||
while ((match = regex.exec(markdown)) !== null) {
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
fullMatch: match[0],
|
||||
content: match[1],
|
||||
style,
|
||||
markerLen,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by position (earlier first), then by length (longer first for overlapping)
|
||||
allMatches.sort((a, b) => {
|
||||
if (a.index !== b.index) return a.index - b.index;
|
||||
return b.fullMatch.length - a.fullMatch.length;
|
||||
});
|
||||
|
||||
// Remove overlapping matches (keep the first/longer one)
|
||||
const filteredMatches: FoundMatch[] = [];
|
||||
let lastEnd = -1;
|
||||
|
||||
for (const m of allMatches) {
|
||||
if (m.index >= lastEnd) {
|
||||
filteredMatches.push(m);
|
||||
lastEnd = m.index + m.fullMatch.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Build output text and styles in single pass
|
||||
const styles: SignalStyleRange[] = [];
|
||||
const textParts: string[] = [];
|
||||
let srcPos = 0;
|
||||
let dstPos = 0;
|
||||
|
||||
for (const m of filteredMatches) {
|
||||
// Add text before this match
|
||||
if (m.index > srcPos) {
|
||||
const before = markdown.slice(srcPos, m.index);
|
||||
textParts.push(before);
|
||||
dstPos += before.length;
|
||||
}
|
||||
|
||||
// Add the content (without markers)
|
||||
textParts.push(m.content);
|
||||
|
||||
// Record style at current destination position
|
||||
styles.push({
|
||||
start: dstPos,
|
||||
length: m.content.length,
|
||||
style: m.style,
|
||||
});
|
||||
|
||||
dstPos += m.content.length;
|
||||
srcPos = m.index + m.fullMatch.length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (srcPos < markdown.length) {
|
||||
textParts.push(markdown.slice(srcPos));
|
||||
}
|
||||
|
||||
return { text: textParts.join(''), styles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format styles for signal-cli text-style parameter
|
||||
*/
|
||||
export function formatStylesForCli(styles: SignalStyleRange[]): string[] {
|
||||
return styles.map(s => `${s.start}:${s.length}:${s.style}`);
|
||||
}
|
||||
601
src/channels/signal.ts
Normal file
601
src/channels/signal.ts
Normal file
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* Signal Channel Adapter
|
||||
*
|
||||
* Uses signal-cli in daemon mode for Signal messaging.
|
||||
* Based on moltbot's implementation.
|
||||
*/
|
||||
|
||||
import type { ChannelAdapter } from './types.js';
|
||||
import type { InboundMessage, OutboundMessage } from '../core/types.js';
|
||||
import type { DmPolicy } from '../pairing/types.js';
|
||||
import {
|
||||
isUserAllowed,
|
||||
upsertPairingRequest,
|
||||
} from '../pairing/store.js';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export interface SignalConfig {
|
||||
phoneNumber: string; // Bot's phone number (E.164 format, e.g., +15551234567)
|
||||
cliPath?: string; // Path to signal-cli binary (default: "signal-cli")
|
||||
httpHost?: string; // Daemon HTTP host (default: "127.0.0.1")
|
||||
httpPort?: number; // Daemon HTTP port (default: 8090)
|
||||
startupTimeoutMs?: number; // Max time to wait for daemon startup (default: 30000)
|
||||
// Security
|
||||
dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open'
|
||||
allowedUsers?: string[]; // Phone numbers (config allowlist)
|
||||
selfChatMode?: boolean; // Respond to Note to Self (default: true)
|
||||
}
|
||||
|
||||
type SignalRpcResponse<T> = {
|
||||
jsonrpc?: string;
|
||||
result?: T;
|
||||
error?: { code?: number; message?: string };
|
||||
id?: string | number | null;
|
||||
};
|
||||
|
||||
type SignalSseEvent = {
|
||||
envelope?: {
|
||||
source?: string;
|
||||
sourceUuid?: string;
|
||||
timestamp?: number;
|
||||
dataMessage?: {
|
||||
message?: string;
|
||||
timestamp?: number;
|
||||
groupInfo?: {
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
};
|
||||
};
|
||||
syncMessage?: {
|
||||
sentMessage?: {
|
||||
destination?: string;
|
||||
destinationUuid?: string;
|
||||
message?: string;
|
||||
timestamp?: number;
|
||||
groupInfo?: {
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
typingMessage?: {
|
||||
action?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export class SignalAdapter implements ChannelAdapter {
|
||||
readonly id = 'signal' as const;
|
||||
readonly name = 'Signal';
|
||||
|
||||
private config: SignalConfig;
|
||||
private running = false;
|
||||
private daemonProcess: ChildProcess | null = null;
|
||||
private sseAbortController: AbortController | null = null;
|
||||
private baseUrl: string;
|
||||
|
||||
onMessage?: (msg: InboundMessage) => Promise<void>;
|
||||
|
||||
constructor(config: SignalConfig) {
|
||||
this.config = {
|
||||
...config,
|
||||
dmPolicy: config.dmPolicy || 'pairing',
|
||||
selfChatMode: config.selfChatMode !== false, // Default true
|
||||
};
|
||||
const host = config.httpHost || '127.0.0.1';
|
||||
const port = config.httpPort || 8090;
|
||||
this.baseUrl = `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is authorized based on dmPolicy
|
||||
* Returns 'allowed', 'blocked', or 'pairing'
|
||||
*/
|
||||
private async checkAccess(userId: string): Promise<'allowed' | 'blocked' | 'pairing'> {
|
||||
const policy = this.config.dmPolicy || 'pairing';
|
||||
|
||||
// Open policy: everyone allowed
|
||||
if (policy === 'open') {
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
// Check if already allowed (config or store)
|
||||
const allowed = await isUserAllowed('signal', userId, this.config.allowedUsers);
|
||||
if (allowed) {
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
// Allowlist policy: not allowed if not in list
|
||||
if (policy === 'allowlist') {
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
// Pairing policy: needs pairing
|
||||
return 'pairing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format pairing message for Signal
|
||||
*/
|
||||
private formatPairingMessage(code: string): string {
|
||||
return `Hi! This bot requires pairing.
|
||||
|
||||
Your code: *${code}*
|
||||
|
||||
Ask the owner to run:
|
||||
\`lettabot pairing approve signal ${code}\`
|
||||
|
||||
This code expires in 1 hour.`;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return;
|
||||
|
||||
console.log('[Signal] Starting adapter...');
|
||||
|
||||
// Spawn signal-cli daemon
|
||||
await this.startDaemon();
|
||||
|
||||
// Wait for daemon to be ready
|
||||
await this.waitForDaemon();
|
||||
|
||||
// Start SSE event loop for incoming messages
|
||||
this.startEventLoop();
|
||||
|
||||
this.running = true;
|
||||
console.log('[Signal] Adapter started successfully');
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running) return;
|
||||
|
||||
console.log('[Signal] Stopping adapter...');
|
||||
|
||||
// Stop SSE loop
|
||||
this.sseAbortController?.abort();
|
||||
this.sseAbortController = null;
|
||||
|
||||
// Stop daemon
|
||||
if (this.daemonProcess && !this.daemonProcess.killed) {
|
||||
this.daemonProcess.kill('SIGTERM');
|
||||
this.daemonProcess = null;
|
||||
}
|
||||
|
||||
this.running = false;
|
||||
console.log('[Signal] Adapter stopped');
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
|
||||
const { markdownToSignal, formatStylesForCli } = await import('./signal-format.js');
|
||||
|
||||
let target = msg.chatId;
|
||||
const rawText = msg.text;
|
||||
|
||||
if (!rawText?.trim()) {
|
||||
throw new Error('Signal requires message text');
|
||||
}
|
||||
|
||||
// Handle Note to Self - send to our own number
|
||||
if (target === 'note-to-self') {
|
||||
target = this.config.phoneNumber;
|
||||
}
|
||||
|
||||
// Convert markdown to Signal formatted text with style ranges
|
||||
const formatted = markdownToSignal(rawText);
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
message: formatted.text,
|
||||
};
|
||||
|
||||
// Add style ranges if any
|
||||
if (formatted.styles.length > 0) {
|
||||
params['text-style'] = formatStylesForCli(formatted.styles);
|
||||
}
|
||||
|
||||
if (this.config.phoneNumber) {
|
||||
params.account = this.config.phoneNumber;
|
||||
}
|
||||
|
||||
// Determine if this is a group or direct message
|
||||
if (target.startsWith('group:')) {
|
||||
params.groupId = target.slice('group:'.length);
|
||||
} else {
|
||||
// Direct message - recipient is a phone number or UUID
|
||||
params.recipient = [target];
|
||||
}
|
||||
|
||||
const result = await this.rpcRequest<{ timestamp?: number }>('send', params);
|
||||
const timestamp = result?.timestamp;
|
||||
|
||||
return {
|
||||
messageId: timestamp ? String(timestamp) : 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
supportsEditing(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async editMessage(_chatId: string, _messageId: string, _text: string): Promise<void> {
|
||||
// Signal doesn't support editing messages - no-op
|
||||
}
|
||||
|
||||
async sendTypingIndicator(chatId: string): Promise<void> {
|
||||
try {
|
||||
let target = chatId;
|
||||
|
||||
// Handle Note to Self
|
||||
if (target === 'note-to-self') {
|
||||
target = this.config.phoneNumber;
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
if (this.config.phoneNumber) {
|
||||
params.account = this.config.phoneNumber;
|
||||
}
|
||||
|
||||
if (target.startsWith('group:')) {
|
||||
params.groupId = target.slice('group:'.length);
|
||||
} else {
|
||||
params.recipient = [target];
|
||||
}
|
||||
|
||||
await this.rpcRequest('sendTyping', params);
|
||||
} catch (err) {
|
||||
// Typing indicators are best-effort
|
||||
console.warn('[Signal] Failed to send typing indicator:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private methods ---
|
||||
|
||||
private async startDaemon(): Promise<void> {
|
||||
const cliPath = this.config.cliPath || 'signal-cli';
|
||||
const host = this.config.httpHost || '127.0.0.1';
|
||||
const port = this.config.httpPort || 8090;
|
||||
|
||||
const args: string[] = [];
|
||||
|
||||
if (this.config.phoneNumber) {
|
||||
args.push('-a', this.config.phoneNumber);
|
||||
}
|
||||
|
||||
args.push('daemon');
|
||||
args.push('--http', `${host}:${port}`);
|
||||
args.push('--no-receive-stdout');
|
||||
|
||||
console.log(`[Signal] Spawning: ${cliPath} ${args.join(' ')}`);
|
||||
|
||||
this.daemonProcess = spawn(cliPath, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
this.daemonProcess.stdout?.on('data', (data) => {
|
||||
const lines = data.toString().split(/\r?\n/).filter((l: string) => l.trim());
|
||||
for (const line of lines) {
|
||||
console.log(`[signal-cli] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.daemonProcess.stderr?.on('data', (data) => {
|
||||
const lines = data.toString().split(/\r?\n/).filter((l: string) => l.trim());
|
||||
for (const line of lines) {
|
||||
// signal-cli writes most logs to stderr
|
||||
if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) {
|
||||
console.error(`[signal-cli] ${line}`);
|
||||
} else {
|
||||
console.log(`[signal-cli] ${line}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.daemonProcess.on('error', (err) => {
|
||||
console.error('[Signal] Daemon spawn error:', err);
|
||||
});
|
||||
|
||||
this.daemonProcess.on('exit', (code) => {
|
||||
console.log(`[Signal] Daemon exited with code ${code}`);
|
||||
if (this.running) {
|
||||
// Unexpected exit - mark as not running
|
||||
this.running = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForDaemon(): Promise<void> {
|
||||
const timeoutMs = this.config.startupTimeoutMs || 30000;
|
||||
const startTime = Date.now();
|
||||
const pollIntervalMs = 500;
|
||||
|
||||
console.log('[Signal] Waiting for daemon to be ready...');
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/api/v1/check`, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.ok) {
|
||||
console.log('[Signal] Daemon is ready');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
// Daemon not ready yet
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
throw new Error(`Signal daemon did not become ready within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
private startEventLoop(): void {
|
||||
this.sseAbortController = new AbortController();
|
||||
|
||||
// Run SSE loop in background
|
||||
this.runSseLoop().catch((err) => {
|
||||
if (!this.sseAbortController?.signal.aborted) {
|
||||
console.error('[Signal] SSE loop error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async runSseLoop(): Promise<void> {
|
||||
const url = new URL(`${this.baseUrl}/api/v1/events`);
|
||||
if (this.config.phoneNumber) {
|
||||
url.searchParams.set('account', this.config.phoneNumber);
|
||||
}
|
||||
|
||||
console.log('[Signal] Starting SSE event loop:', url.toString());
|
||||
|
||||
while (!this.sseAbortController?.signal.aborted) {
|
||||
// Create a new controller for this connection attempt
|
||||
const connectionController = new AbortController();
|
||||
|
||||
// Abort this connection if the main controller is aborted
|
||||
const onMainAbort = () => connectionController.abort();
|
||||
this.sseAbortController?.signal.addEventListener('abort', onMainAbort, { once: true });
|
||||
|
||||
try {
|
||||
console.log('[Signal] Connecting to SSE...');
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
signal: connectionController.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`SSE failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
console.log('[Signal] SSE connected');
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (!this.sseAbortController?.signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
console.log('[Signal] SSE stream ended');
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete events (separated by double newline)
|
||||
const events = buffer.split('\n\n');
|
||||
buffer = events.pop() || ''; // Keep incomplete event in buffer
|
||||
|
||||
for (const event of events) {
|
||||
if (!event.trim()) continue;
|
||||
|
||||
// Extract data from SSE event (may be multiline)
|
||||
const lines = event.split('\n');
|
||||
let data = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
data += line.slice(5).trim();
|
||||
}
|
||||
}
|
||||
if (data) {
|
||||
this.handleSseData(data).catch((err) => {
|
||||
console.error('[Signal] Error handling SSE data:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended normally - wait before reconnecting
|
||||
console.log('[Signal] SSE disconnected, reconnecting in 2s...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
} catch (err) {
|
||||
if (this.sseAbortController?.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[Signal] SSE connection error, reconnecting in 5s:', err);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
} finally {
|
||||
// Clean up the listener
|
||||
this.sseAbortController?.signal.removeEventListener('abort', onMainAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSseData(data: string): Promise<void> {
|
||||
try {
|
||||
const event = JSON.parse(data) as SignalSseEvent;
|
||||
const envelope = event.envelope;
|
||||
|
||||
if (!envelope) return;
|
||||
|
||||
// Handle incoming data messages (from others)
|
||||
const dataMessage = envelope.dataMessage;
|
||||
|
||||
// Handle sync messages (Note to Self, messages we sent from another device)
|
||||
const syncMessage = envelope.syncMessage?.sentMessage;
|
||||
|
||||
// Get the message text and source from either type
|
||||
let messageText: string | undefined;
|
||||
let source: string | undefined;
|
||||
let chatId: string | undefined;
|
||||
let groupInfo: { groupId?: string; groupName?: string } | undefined;
|
||||
|
||||
if (dataMessage?.message) {
|
||||
// Regular incoming message
|
||||
messageText = dataMessage.message;
|
||||
source = envelope.source || envelope.sourceUuid;
|
||||
groupInfo = dataMessage.groupInfo;
|
||||
|
||||
if (groupInfo?.groupId) {
|
||||
chatId = `group:${groupInfo.groupId}`;
|
||||
} else {
|
||||
chatId = source;
|
||||
}
|
||||
} else if (syncMessage?.message) {
|
||||
// Sync message (Note to Self or sent from another device)
|
||||
messageText = syncMessage.message;
|
||||
source = syncMessage.destination || syncMessage.destinationUuid;
|
||||
groupInfo = syncMessage.groupInfo;
|
||||
|
||||
// For Note to Self, destination is our own number
|
||||
const isNoteToSelf = source === this.config.phoneNumber ||
|
||||
source === envelope.source ||
|
||||
source === envelope.sourceUuid;
|
||||
|
||||
if (isNoteToSelf) {
|
||||
chatId = 'note-to-self';
|
||||
} else if (groupInfo?.groupId) {
|
||||
chatId = `group:${groupInfo.groupId}`;
|
||||
} else {
|
||||
chatId = source;
|
||||
}
|
||||
}
|
||||
|
||||
if (!messageText || !source || !chatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Note to Self - check selfChatMode
|
||||
if (chatId === 'note-to-self') {
|
||||
if (!this.config.selfChatMode) {
|
||||
// selfChatMode disabled - ignore Note to Self messages
|
||||
return;
|
||||
}
|
||||
// selfChatMode enabled - allow the message through
|
||||
} else {
|
||||
// External message - check access control
|
||||
const access = await this.checkAccess(source);
|
||||
|
||||
if (access === 'blocked') {
|
||||
console.log(`[Signal] Blocked message from unauthorized user: ${source}`);
|
||||
await this.sendMessage({ chatId: source, text: "Sorry, you're not authorized to use this bot." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (access === 'pairing') {
|
||||
// Create pairing request
|
||||
const { code, created } = await upsertPairingRequest('signal', source, {
|
||||
firstName: source, // Use phone number as name
|
||||
});
|
||||
|
||||
if (!code) {
|
||||
await this.sendMessage({
|
||||
chatId: source,
|
||||
text: "Too many pending pairing requests. Please try again later."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send pairing message on first contact
|
||||
if (created) {
|
||||
console.log(`[Signal] New pairing request from ${source}: ${code}`);
|
||||
await this.sendMessage({ chatId: source, text: this.formatPairingMessage(code) });
|
||||
}
|
||||
|
||||
// Don't process the message
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const isGroup = chatId.startsWith('group:');
|
||||
const msg: InboundMessage = {
|
||||
channel: 'signal',
|
||||
chatId,
|
||||
userId: source,
|
||||
text: messageText,
|
||||
timestamp: new Date(envelope.timestamp || Date.now()),
|
||||
isGroup,
|
||||
groupName: groupInfo?.groupName,
|
||||
};
|
||||
|
||||
this.onMessage?.(msg).catch((err) => {
|
||||
console.error('[Signal] Error handling message:', err);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Signal] Failed to parse SSE event:', err, data);
|
||||
}
|
||||
}
|
||||
|
||||
private async rpcRequest<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const id = randomUUID();
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method,
|
||||
params,
|
||||
id,
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${this.baseUrl}/api/v1/rpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (res.status === 201) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text) {
|
||||
throw new Error(`Signal RPC empty response (status ${res.status})`);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text) as SignalRpcResponse<T>;
|
||||
if (parsed.error) {
|
||||
const code = parsed.error.code ?? 'unknown';
|
||||
const msg = parsed.error.message ?? 'Signal RPC error';
|
||||
throw new Error(`Signal RPC ${code}: ${msg}`);
|
||||
}
|
||||
|
||||
return parsed.result as T;
|
||||
}
|
||||
}
|
||||
158
src/channels/slack.ts
Normal file
158
src/channels/slack.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Slack Channel Adapter
|
||||
*
|
||||
* Uses @slack/bolt for Slack API with Socket Mode.
|
||||
*/
|
||||
|
||||
import type { ChannelAdapter } from './types.js';
|
||||
import type { InboundMessage, OutboundMessage } from '../core/types.js';
|
||||
|
||||
// Dynamic import to avoid requiring Slack deps if not used
|
||||
let App: typeof import('@slack/bolt').App;
|
||||
|
||||
export interface SlackConfig {
|
||||
botToken: string; // xoxb-...
|
||||
appToken: string; // xapp-... (for Socket Mode)
|
||||
allowedUsers?: string[]; // Slack user IDs (e.g., U01234567)
|
||||
}
|
||||
|
||||
export class SlackAdapter implements ChannelAdapter {
|
||||
readonly id = 'slack' as const;
|
||||
readonly name = 'Slack';
|
||||
|
||||
private app: InstanceType<typeof App> | null = null;
|
||||
private config: SlackConfig;
|
||||
private running = false;
|
||||
|
||||
onMessage?: (msg: InboundMessage) => Promise<void>;
|
||||
|
||||
constructor(config: SlackConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return;
|
||||
|
||||
// Dynamic import
|
||||
const bolt = await import('@slack/bolt');
|
||||
App = bolt.App;
|
||||
|
||||
this.app = new App({
|
||||
token: this.config.botToken,
|
||||
appToken: this.config.appToken,
|
||||
socketMode: true,
|
||||
});
|
||||
|
||||
// Handle messages
|
||||
this.app.message(async ({ message, say }) => {
|
||||
// Type guard for regular messages
|
||||
if (message.subtype !== undefined) return;
|
||||
if (!('user' in message) || !('text' in message)) return;
|
||||
|
||||
const userId = message.user;
|
||||
const text = message.text || '';
|
||||
const channelId = message.channel;
|
||||
const threadTs = message.thread_ts || message.ts; // Reply in thread if applicable
|
||||
|
||||
// Check allowed users
|
||||
if (this.config.allowedUsers && this.config.allowedUsers.length > 0) {
|
||||
if (!this.config.allowedUsers.includes(userId)) {
|
||||
await say("Sorry, you're not authorized to use this bot.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.onMessage) {
|
||||
// Determine if this is a group/channel (not a DM)
|
||||
// DMs have channel IDs starting with 'D', channels start with 'C'
|
||||
const isGroup = !channelId.startsWith('D');
|
||||
|
||||
await this.onMessage({
|
||||
channel: 'slack',
|
||||
chatId: channelId,
|
||||
userId: userId || '',
|
||||
userHandle: userId || '', // Slack user ID serves as handle
|
||||
text: text || '',
|
||||
timestamp: new Date(Number(message.ts) * 1000),
|
||||
threadId: threadTs,
|
||||
isGroup,
|
||||
groupName: isGroup ? channelId : undefined, // Would need conversations.info for name
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle app mentions (@bot)
|
||||
this.app.event('app_mention', async ({ event }) => {
|
||||
const userId = event.user || '';
|
||||
const text = (event.text || '').replace(/<@[A-Z0-9]+>/g, '').trim(); // Remove mention
|
||||
const channelId = event.channel;
|
||||
const threadTs = event.thread_ts || event.ts; // Reply in thread, or start new thread from the mention
|
||||
|
||||
if (this.config.allowedUsers && this.config.allowedUsers.length > 0) {
|
||||
if (!userId || !this.config.allowedUsers.includes(userId)) {
|
||||
// Can't use say() in app_mention event the same way
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.onMessage) {
|
||||
// app_mention is always in a channel (group)
|
||||
const isGroup = !channelId.startsWith('D');
|
||||
|
||||
await this.onMessage({
|
||||
channel: 'slack',
|
||||
chatId: channelId,
|
||||
userId: userId || '',
|
||||
userHandle: userId || '', // Slack user ID serves as handle
|
||||
text: text || '',
|
||||
timestamp: new Date(Number(event.ts) * 1000),
|
||||
threadId: threadTs,
|
||||
isGroup,
|
||||
groupName: isGroup ? channelId : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Slack] Connecting via Socket Mode...');
|
||||
await this.app.start();
|
||||
console.log('[Slack] Bot started in Socket Mode');
|
||||
this.running = true;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running || !this.app) return;
|
||||
await this.app.stop();
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
|
||||
if (!this.app) throw new Error('Slack not started');
|
||||
|
||||
const result = await this.app.client.chat.postMessage({
|
||||
channel: msg.chatId,
|
||||
text: msg.text,
|
||||
thread_ts: msg.threadId,
|
||||
});
|
||||
|
||||
return { messageId: result.ts || '' };
|
||||
}
|
||||
|
||||
async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
|
||||
if (!this.app) throw new Error('Slack not started');
|
||||
|
||||
await this.app.client.chat.update({
|
||||
channel: chatId,
|
||||
ts: messageId,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
async sendTypingIndicator(_chatId: string): Promise<void> {
|
||||
// Slack doesn't have a typing indicator API for bots
|
||||
// This is a no-op
|
||||
}
|
||||
}
|
||||
90
src/channels/telegram-format.ts
Normal file
90
src/channels/telegram-format.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Telegram Text Formatting
|
||||
*
|
||||
* Converts markdown to Telegram MarkdownV2 format using telegramify-markdown.
|
||||
* Supports: headers, bold, italic, code, links, blockquotes, lists, etc.
|
||||
*/
|
||||
|
||||
import { convert } from 'telegram-markdown-v2';
|
||||
|
||||
/**
|
||||
* Convert markdown to Telegram MarkdownV2 format.
|
||||
* Handles proper escaping of special characters.
|
||||
*/
|
||||
export function markdownToTelegramV2(markdown: string): string {
|
||||
try {
|
||||
// Use 'keep' strategy to preserve blockquotes (>) and other elements
|
||||
return convert(markdown, 'keep');
|
||||
} catch (e) {
|
||||
console.error('[Telegram] Markdown conversion failed:', e);
|
||||
// Fallback: escape special characters manually
|
||||
return escapeMarkdownV2(markdown);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape MarkdownV2 special characters (fallback)
|
||||
*/
|
||||
function escapeMarkdownV2(text: string): string {
|
||||
const specialChars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
|
||||
let escaped = text;
|
||||
for (const char of specialChars) {
|
||||
escaped = escaped.replace(new RegExp(`\\${char}`, 'g'), `\\${char}`);
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters (for HTML parse mode fallback)
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown to Telegram HTML format.
|
||||
* Fallback option - simpler but less feature-rich.
|
||||
* Supports: *bold*, _italic_, `code`, ~~strikethrough~~, ```code blocks```
|
||||
*/
|
||||
export function markdownToTelegramHtml(markdown: string): string {
|
||||
let text = markdown;
|
||||
|
||||
// Process code blocks first (they shouldn't have other formatting inside)
|
||||
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||||
return `<pre><code>${escapeHtml(code.trim())}</code></pre>`;
|
||||
});
|
||||
|
||||
// Inline code (escape content)
|
||||
text = text.replace(/`([^`]+)`/g, (_, code) => {
|
||||
return `<code>${escapeHtml(code)}</code>`;
|
||||
});
|
||||
|
||||
// Now escape remaining HTML (outside of code blocks)
|
||||
// Split by our tags to preserve them
|
||||
const parts = text.split(/(<\/?(?:pre|code|b|i|s|u|a)[^>]*>)/);
|
||||
text = parts.map((part, i) => {
|
||||
// Odd indices are our tags, keep them
|
||||
if (i % 2 === 1) return part;
|
||||
// Even indices are text, but skip if inside code
|
||||
return escapeHtml(part);
|
||||
}).join('');
|
||||
|
||||
// Bold: **text** or *text*
|
||||
text = text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
||||
text = text.replace(/\*([^*]+)\*/g, '<b>$1</b>');
|
||||
|
||||
// Italic: __text__ or _text_
|
||||
text = text.replace(/__(.+?)__/g, '<i>$1</i>');
|
||||
text = text.replace(/_([^_]+)_/g, '<i>$1</i>');
|
||||
|
||||
// Strikethrough: ~~text~~
|
||||
text = text.replace(/~~(.+?)~~/g, '<s>$1</s>');
|
||||
|
||||
// Blockquotes: > text (convert to italic for now, HTML doesn't have blockquote in Telegram)
|
||||
text = text.replace(/^>\s*(.+)$/gm, '<blockquote>$1</blockquote>');
|
||||
|
||||
return text;
|
||||
}
|
||||
227
src/channels/telegram.ts
Normal file
227
src/channels/telegram.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Telegram Channel Adapter
|
||||
*
|
||||
* Uses grammY for Telegram Bot API.
|
||||
* Supports DM pairing for secure access control.
|
||||
*/
|
||||
|
||||
import { Bot } from 'grammy';
|
||||
import type { ChannelAdapter } from './types.js';
|
||||
import type { InboundMessage, OutboundMessage } from '../core/types.js';
|
||||
import type { DmPolicy } from '../pairing/types.js';
|
||||
import {
|
||||
isUserAllowed,
|
||||
upsertPairingRequest,
|
||||
formatPairingMessage,
|
||||
} from '../pairing/store.js';
|
||||
|
||||
export interface TelegramConfig {
|
||||
token: string;
|
||||
dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open'
|
||||
allowedUsers?: number[]; // Telegram user IDs (config allowlist)
|
||||
}
|
||||
|
||||
export class TelegramAdapter implements ChannelAdapter {
|
||||
readonly id = 'telegram' as const;
|
||||
readonly name = 'Telegram';
|
||||
|
||||
private bot: Bot;
|
||||
private config: TelegramConfig;
|
||||
private running = false;
|
||||
|
||||
onMessage?: (msg: InboundMessage) => Promise<void>;
|
||||
onCommand?: (command: string) => Promise<string | null>;
|
||||
|
||||
constructor(config: TelegramConfig) {
|
||||
this.config = {
|
||||
...config,
|
||||
dmPolicy: config.dmPolicy || 'pairing', // Default to pairing
|
||||
};
|
||||
this.bot = new Bot(config.token);
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is authorized based on dmPolicy
|
||||
* Returns true if allowed, false if blocked, 'pairing' if pending pairing
|
||||
*/
|
||||
private async checkAccess(userId: string, username?: string, firstName?: string): Promise<'allowed' | 'blocked' | 'pairing'> {
|
||||
const policy = this.config.dmPolicy || 'pairing';
|
||||
const userIdStr = userId;
|
||||
|
||||
// Open policy: everyone allowed
|
||||
if (policy === 'open') {
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
// Check if already allowed (config or store)
|
||||
const configAllowlist = this.config.allowedUsers?.map(String);
|
||||
const allowed = await isUserAllowed('telegram', userIdStr, configAllowlist);
|
||||
if (allowed) {
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
// Allowlist policy: not allowed if not in list
|
||||
if (policy === 'allowlist') {
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
// Pairing policy: create/update pairing request
|
||||
return 'pairing';
|
||||
}
|
||||
|
||||
private setupHandlers(): void {
|
||||
// Middleware: Check access based on dmPolicy
|
||||
this.bot.use(async (ctx, next) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const access = await this.checkAccess(
|
||||
String(userId),
|
||||
ctx.from?.username,
|
||||
ctx.from?.first_name
|
||||
);
|
||||
|
||||
if (access === 'allowed') {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (access === 'blocked') {
|
||||
await ctx.reply("Sorry, you're not authorized to use this bot.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pairing flow
|
||||
const { code, created } = await upsertPairingRequest('telegram', String(userId), {
|
||||
username: ctx.from?.username,
|
||||
firstName: ctx.from?.first_name,
|
||||
lastName: ctx.from?.last_name,
|
||||
});
|
||||
|
||||
if (!code) {
|
||||
// Too many pending requests
|
||||
await ctx.reply(
|
||||
"Too many pending pairing requests. Please try again later."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only send pairing message on first contact (created=true)
|
||||
// or if this is a new message (not just middleware check)
|
||||
if (created) {
|
||||
console.log(`[Telegram] New pairing request from ${userId} (${ctx.from?.username || 'no username'}): ${code}`);
|
||||
await ctx.reply(formatPairingMessage(code), { parse_mode: 'Markdown' });
|
||||
}
|
||||
|
||||
// Don't process the message further
|
||||
return;
|
||||
});
|
||||
|
||||
// Handle /start and /help
|
||||
this.bot.command(['start', 'help'], async (ctx) => {
|
||||
await ctx.reply(
|
||||
"*LettaBot* - AI assistant with persistent memory\n\n" +
|
||||
"*Commands:*\n" +
|
||||
"/status - Show current status\n" +
|
||||
"/help - Show this message\n\n" +
|
||||
"Just send me a message to get started!",
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
});
|
||||
|
||||
// Handle /status
|
||||
this.bot.command('status', async (ctx) => {
|
||||
if (this.onCommand) {
|
||||
const result = await this.onCommand('status');
|
||||
await ctx.reply(result || 'No status available');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle /heartbeat (silent - no reply)
|
||||
this.bot.command('heartbeat', async (ctx) => {
|
||||
if (this.onCommand) {
|
||||
await this.onCommand('heartbeat');
|
||||
// No reply - heartbeat runs silently
|
||||
}
|
||||
});
|
||||
|
||||
// Handle text messages
|
||||
this.bot.on('message:text', async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
const chatId = ctx.chat.id;
|
||||
const text = ctx.message.text;
|
||||
|
||||
if (!userId) return;
|
||||
if (text.startsWith('/')) return; // Skip other commands
|
||||
|
||||
if (this.onMessage) {
|
||||
await this.onMessage({
|
||||
channel: 'telegram',
|
||||
chatId: String(chatId),
|
||||
userId: String(userId),
|
||||
userName: ctx.from.username || ctx.from.first_name,
|
||||
text,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Error handler
|
||||
this.bot.catch((err) => {
|
||||
console.error('[Telegram] Bot error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return;
|
||||
|
||||
await this.bot.start({
|
||||
onStart: (botInfo) => {
|
||||
console.log(`[Telegram] Bot started as @${botInfo.username}`);
|
||||
console.log(`[Telegram] DM policy: ${this.config.dmPolicy}`);
|
||||
this.running = true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running) return;
|
||||
await this.bot.stop();
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
|
||||
const { markdownToTelegramV2 } = await import('./telegram-format.js');
|
||||
|
||||
// Convert markdown to Telegram MarkdownV2 format
|
||||
const formatted = markdownToTelegramV2(msg.text);
|
||||
|
||||
const result = await this.bot.api.sendMessage(msg.chatId, formatted, {
|
||||
parse_mode: 'MarkdownV2',
|
||||
reply_to_message_id: msg.replyToMessageId ? Number(msg.replyToMessageId) : undefined,
|
||||
});
|
||||
return { messageId: String(result.message_id) };
|
||||
}
|
||||
|
||||
async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
|
||||
const { markdownToTelegramV2 } = await import('./telegram-format.js');
|
||||
const formatted = markdownToTelegramV2(text);
|
||||
await this.bot.api.editMessageText(chatId, Number(messageId), formatted, { parse_mode: 'MarkdownV2' });
|
||||
}
|
||||
|
||||
async sendTypingIndicator(chatId: string): Promise<void> {
|
||||
await this.bot.api.sendChatAction(chatId, 'typing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying bot instance (for commands, etc.)
|
||||
*/
|
||||
getBot(): Bot {
|
||||
return this.bot;
|
||||
}
|
||||
}
|
||||
63
src/channels/types.ts
Normal file
63
src/channels/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Channel Adapter Interface
|
||||
*
|
||||
* Each channel (Telegram, Slack, WhatsApp) implements this interface.
|
||||
*/
|
||||
|
||||
import type { ChannelId, InboundMessage, OutboundMessage } from '../core/types.js';
|
||||
|
||||
/**
|
||||
* Channel adapter - implement this for each messaging platform
|
||||
*/
|
||||
export interface ChannelAdapter {
|
||||
readonly id: ChannelId;
|
||||
readonly name: string;
|
||||
|
||||
// Lifecycle
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
isRunning(): boolean;
|
||||
|
||||
// Messaging
|
||||
sendMessage(msg: OutboundMessage): Promise<{ messageId: string }>;
|
||||
editMessage(chatId: string, messageId: string, text: string): Promise<void>;
|
||||
sendTypingIndicator(chatId: string): Promise<void>;
|
||||
|
||||
// Capabilities (optional)
|
||||
supportsEditing?(): boolean;
|
||||
|
||||
// Event handlers (set by bot core)
|
||||
onMessage?: (msg: InboundMessage) => Promise<void>;
|
||||
onCommand?: (command: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typing heartbeat helper - keeps "typing..." indicator active
|
||||
*/
|
||||
export class TypingHeartbeat {
|
||||
private interval: NodeJS.Timeout | null = null;
|
||||
private adapter: ChannelAdapter | null = null;
|
||||
private chatId: string | null = null;
|
||||
|
||||
start(adapter: ChannelAdapter, chatId: string): void {
|
||||
this.stop();
|
||||
this.adapter = adapter;
|
||||
this.chatId = chatId;
|
||||
|
||||
const sendTyping = () => {
|
||||
this.adapter?.sendTypingIndicator(this.chatId!).catch(() => {});
|
||||
};
|
||||
|
||||
sendTyping();
|
||||
this.interval = setInterval(sendTyping, 4000); // Most platforms expire typing after 5s
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
this.adapter = null;
|
||||
this.chatId = null;
|
||||
}
|
||||
}
|
||||
372
src/channels/whatsapp.ts
Normal file
372
src/channels/whatsapp.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* WhatsApp Channel Adapter
|
||||
*
|
||||
* Uses @whiskeysockets/baileys for WhatsApp Web API.
|
||||
* Supports DM pairing for secure access control.
|
||||
*/
|
||||
|
||||
import type { ChannelAdapter } from './types.js';
|
||||
import type { InboundMessage, OutboundMessage } from '../core/types.js';
|
||||
import type { DmPolicy } from '../pairing/types.js';
|
||||
import {
|
||||
isUserAllowed,
|
||||
upsertPairingRequest,
|
||||
formatPairingMessage,
|
||||
} from '../pairing/store.js';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
|
||||
export interface WhatsAppConfig {
|
||||
sessionPath?: string; // Where to store auth state
|
||||
dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open'
|
||||
allowedUsers?: string[]; // Phone numbers (e.g., +15551234567)
|
||||
selfChatMode?: boolean; // Respond to "message yourself" (for personal number use)
|
||||
}
|
||||
|
||||
export class WhatsAppAdapter implements ChannelAdapter {
|
||||
readonly id = 'whatsapp' as const;
|
||||
readonly name = 'WhatsApp';
|
||||
|
||||
private sock: any = null;
|
||||
private config: WhatsAppConfig;
|
||||
private running = false;
|
||||
private sessionPath: string;
|
||||
private myJid: string = ''; // Bot's own JID (for selfChatMode)
|
||||
private myNumber: string = ''; // Bot's phone number
|
||||
private selfChatLid: string = ''; // Self-chat LID (for selfChatMode conversion)
|
||||
private lidToJid: Map<string, string> = new Map(); // Map LID -> real JID for replies
|
||||
private sentMessageIds: Set<string> = new Set(); // Track messages we've sent
|
||||
private processedMessageIds: Set<string> = new Set(); // Dedupe incoming messages
|
||||
|
||||
onMessage?: (msg: InboundMessage) => Promise<void>;
|
||||
|
||||
constructor(config: WhatsAppConfig) {
|
||||
this.config = {
|
||||
...config,
|
||||
dmPolicy: config.dmPolicy || 'pairing', // Default to pairing
|
||||
};
|
||||
this.sessionPath = resolve(config.sessionPath || './data/whatsapp-session');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is authorized based on dmPolicy
|
||||
* Returns 'allowed', 'blocked', or 'pairing'
|
||||
*/
|
||||
private async checkAccess(userId: string, userName?: string): Promise<'allowed' | 'blocked' | 'pairing'> {
|
||||
const policy = this.config.dmPolicy || 'pairing';
|
||||
const phone = userId.startsWith('+') ? userId : `+${userId}`;
|
||||
|
||||
// Open policy: everyone allowed
|
||||
if (policy === 'open') {
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
// Self-chat mode: always allow self
|
||||
if (this.config.selfChatMode && userId === this.myNumber) {
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
// Check if already allowed (config or store)
|
||||
const allowed = await isUserAllowed('whatsapp', phone, this.config.allowedUsers);
|
||||
if (allowed) {
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
// Allowlist policy: not allowed if not in list
|
||||
if (policy === 'allowlist') {
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
// Pairing policy: needs pairing
|
||||
return 'pairing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format pairing message for WhatsApp
|
||||
*/
|
||||
private formatPairingMsg(code: string): string {
|
||||
return `Hi! This bot requires pairing.
|
||||
|
||||
Your pairing code: *${code}*
|
||||
|
||||
Ask the bot owner to approve with:
|
||||
\`lettabot pairing approve whatsapp ${code}\``;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return;
|
||||
|
||||
// Suppress noisy Baileys console output (session crypto details, errors)
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const suppressPatterns = [
|
||||
'Closing session',
|
||||
'SessionEntry',
|
||||
'Session error',
|
||||
'Bad MAC',
|
||||
'Failed to decrypt',
|
||||
'Closing open session',
|
||||
'prekey bundle',
|
||||
];
|
||||
const shouldSuppress = (msg: string) => suppressPatterns.some(p => msg.includes(p));
|
||||
|
||||
console.log = (...args: any[]) => {
|
||||
const msg = args[0]?.toString?.() || '';
|
||||
if (shouldSuppress(msg)) return;
|
||||
originalLog.apply(console, args);
|
||||
};
|
||||
console.error = (...args: any[]) => {
|
||||
const msg = args[0]?.toString?.() || '';
|
||||
if (shouldSuppress(msg)) return;
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
// Check for competing WhatsApp bots
|
||||
try {
|
||||
const { execSync } = await import('node:child_process');
|
||||
const procs = execSync('ps aux | grep -i "clawdbot\\|moltbot" | grep -v grep', { encoding: 'utf-8' });
|
||||
if (procs.trim()) {
|
||||
console.warn('[WhatsApp] ⚠️ Warning: clawdbot/moltbot is running and may compete for WhatsApp connection.');
|
||||
console.warn('[WhatsApp] Stop it with: launchctl unload ~/Library/LaunchAgents/com.clawdbot.gateway.plist');
|
||||
}
|
||||
} catch {} // No competing bots found
|
||||
|
||||
// Ensure session directory exists
|
||||
mkdirSync(this.sessionPath, { recursive: true });
|
||||
|
||||
// Dynamic import
|
||||
const {
|
||||
default: makeWASocket,
|
||||
useMultiFileAuthState,
|
||||
DisconnectReason,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
} = await import('@whiskeysockets/baileys');
|
||||
|
||||
// Load auth state
|
||||
const { state, saveCreds } = await useMultiFileAuthState(this.sessionPath);
|
||||
|
||||
// Get latest WA Web version
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
console.log('[WhatsApp] Using WA Web version:', version.join('.'));
|
||||
|
||||
// Silent logger to suppress noisy baileys logs
|
||||
const silentLogger = {
|
||||
level: 'silent',
|
||||
trace: () => {},
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
fatal: () => {},
|
||||
child: () => silentLogger,
|
||||
};
|
||||
|
||||
// Create socket with proper config (matching moltbot)
|
||||
this.sock = makeWASocket({
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
keys: makeCacheableSignalKeyStore(state.keys, silentLogger as any),
|
||||
},
|
||||
version,
|
||||
browser: ['LettaBot', 'Desktop', '1.0.0'],
|
||||
syncFullHistory: false,
|
||||
markOnlineOnConnect: false,
|
||||
logger: silentLogger as any,
|
||||
});
|
||||
|
||||
// Save credentials when updated
|
||||
this.sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
// Handle connection updates
|
||||
this.sock.ev.on('connection.update', (update: any) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
console.log('[WhatsApp] Scan this QR code in WhatsApp → Linked Devices:');
|
||||
qrcode.generate(qr, { small: true });
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const shouldReconnect = (lastDisconnect?.error as any)?.output?.statusCode !== DisconnectReason.loggedOut;
|
||||
console.log('[WhatsApp] Connection closed, reconnecting:', shouldReconnect);
|
||||
|
||||
if (shouldReconnect) {
|
||||
this.start(); // Reconnect
|
||||
} else {
|
||||
this.running = false;
|
||||
}
|
||||
} else if (connection === 'open') {
|
||||
// Capture our own JID for selfChatMode
|
||||
this.myJid = this.sock.user?.id || '';
|
||||
this.myNumber = this.myJid.replace(/@.*/, '').replace(/:\d+/, '');
|
||||
console.log(`[WhatsApp] Connected as ${this.myNumber}`);
|
||||
this.running = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming messages
|
||||
this.sock.ev.on('messages.upsert', async ({ messages, type }: any) => {
|
||||
|
||||
for (const m of messages) {
|
||||
const messageId = m.key.id || '';
|
||||
|
||||
// Skip messages we sent (prevents loop in selfChatMode)
|
||||
if (this.sentMessageIds.has(messageId)) {
|
||||
this.sentMessageIds.delete(messageId); // Clean up
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip duplicate messages (WhatsApp retry mechanism)
|
||||
if (this.processedMessageIds.has(messageId)) {
|
||||
continue;
|
||||
}
|
||||
this.processedMessageIds.add(messageId);
|
||||
setTimeout(() => this.processedMessageIds.delete(messageId), 60000);
|
||||
|
||||
const remoteJid = m.key.remoteJid || '';
|
||||
|
||||
// Detect self-chat: message from ourselves to ourselves
|
||||
// For self-chat, senderPn is undefined, so we detect by: fromMe + LID + selfChatMode
|
||||
const senderPn = (m.key as any).senderPn as string | undefined;
|
||||
const isSelfChat = m.key.fromMe && (
|
||||
remoteJid === this.myJid ||
|
||||
remoteJid.replace(/@.*/, '') === this.myNumber ||
|
||||
// In selfChatMode, fromMe + LID (with no senderPn) = self-chat
|
||||
(this.config.selfChatMode && remoteJid.includes('@lid') && !senderPn)
|
||||
);
|
||||
|
||||
// Track self-chat LID for reply conversion
|
||||
if (isSelfChat && remoteJid.includes('@lid')) {
|
||||
this.selfChatLid = remoteJid;
|
||||
}
|
||||
|
||||
// Skip own messages (unless selfChatMode enabled for self-chat)
|
||||
if (m.key.fromMe) {
|
||||
if (!(this.config.selfChatMode && isSelfChat)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Capture LID → real JID mapping from senderPn (for replying to LID contacts)
|
||||
if (remoteJid.includes('@lid') && (m.key as any).senderPn) {
|
||||
this.lidToJid.set(remoteJid, (m.key as any).senderPn);
|
||||
}
|
||||
|
||||
// Get message text
|
||||
const text = m.message?.conversation ||
|
||||
m.message?.extendedTextMessage?.text ||
|
||||
'';
|
||||
|
||||
if (!text) continue;
|
||||
|
||||
const userId = remoteJid.replace('@s.whatsapp.net', '').replace('@g.us', '');
|
||||
const isGroup = remoteJid.endsWith('@g.us');
|
||||
const pushName = m.pushName;
|
||||
|
||||
// Check access control (for DMs only, groups are open)
|
||||
if (!isGroup) {
|
||||
const access = await this.checkAccess(userId, pushName);
|
||||
|
||||
if (access === 'blocked') {
|
||||
await this.sock.sendMessage(remoteJid, { text: "Sorry, you're not authorized to use this bot." });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (access === 'pairing') {
|
||||
// Create pairing request
|
||||
const result = await upsertPairingRequest('whatsapp', userId, pushName);
|
||||
|
||||
if (!result) {
|
||||
await this.sock.sendMessage(remoteJid, {
|
||||
text: "Too many pending pairing requests. Please try again later."
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { code, created } = result;
|
||||
|
||||
// Send pairing message on first contact
|
||||
if (created) {
|
||||
console.log(`[WhatsApp] New pairing request from ${userId}: ${code}`);
|
||||
await this.sock.sendMessage(remoteJid, { text: this.formatPairingMsg(code) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.onMessage) {
|
||||
await this.onMessage({
|
||||
channel: 'whatsapp',
|
||||
chatId: remoteJid,
|
||||
userId,
|
||||
userName: pushName || undefined,
|
||||
text,
|
||||
timestamp: new Date(m.messageTimestamp * 1000),
|
||||
isGroup,
|
||||
// Group name would require additional API call to get chat metadata
|
||||
// For now, we don't have it readily available from the message
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running || !this.sock) return;
|
||||
await this.sock.logout();
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
|
||||
if (!this.sock) throw new Error('WhatsApp not connected');
|
||||
|
||||
// Convert LID to proper JID for sending
|
||||
let targetJid = msg.chatId;
|
||||
if (targetJid.includes('@lid')) {
|
||||
if (targetJid === this.selfChatLid && this.myNumber) {
|
||||
// Self-chat LID -> our own number
|
||||
targetJid = `${this.myNumber}@s.whatsapp.net`;
|
||||
} else if (this.lidToJid.has(targetJid)) {
|
||||
// Friend LID -> their real JID from senderPn
|
||||
targetJid = this.lidToJid.get(targetJid)!;
|
||||
}
|
||||
// If no mapping, keep as-is and hope baileys handles it
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.sock.sendMessage(targetJid, { text: msg.text });
|
||||
const messageId = result?.key?.id || '';
|
||||
|
||||
// Track sent message to avoid processing it as incoming (selfChatMode loop prevention)
|
||||
if (messageId) {
|
||||
this.sentMessageIds.add(messageId);
|
||||
// Clean up old IDs after 60 seconds
|
||||
setTimeout(() => this.sentMessageIds.delete(messageId), 60000);
|
||||
}
|
||||
|
||||
return { messageId };
|
||||
} catch (error) {
|
||||
console.error(`[WhatsApp] sendMessage error:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
supportsEditing(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async editMessage(_chatId: string, _messageId: string, _text: string): Promise<void> {
|
||||
// WhatsApp doesn't support editing messages - no-op
|
||||
}
|
||||
|
||||
async sendTypingIndicator(chatId: string): Promise<void> {
|
||||
if (!this.sock) return;
|
||||
await this.sock.sendPresenceUpdate('composing', chatId);
|
||||
}
|
||||
}
|
||||
485
src/cli.ts
Normal file
485
src/cli.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* LettaBot CLI
|
||||
*
|
||||
* Commands:
|
||||
* lettabot onboard - Onboarding workflow (setup integrations, install skills)
|
||||
* lettabot server - Run the bot server
|
||||
* lettabot configure - Configure settings
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import * as readline from 'node:readline';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
const subCommand = args[1];
|
||||
|
||||
const ENV_PATH = resolve(process.cwd(), '.env');
|
||||
const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example');
|
||||
|
||||
// Simple prompt helper
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Load current env values
|
||||
function loadEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
if (existsSync(ENV_PATH)) {
|
||||
const content = readFileSync(ENV_PATH, 'utf-8');
|
||||
for (const line of content.split('\n')) {
|
||||
if (line.startsWith('#') || !line.includes('=')) continue;
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
env[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// Save env values
|
||||
function saveEnv(env: Record<string, string>): void {
|
||||
// Start with example if no .env exists
|
||||
let content = '';
|
||||
if (existsSync(ENV_EXAMPLE_PATH)) {
|
||||
content = readFileSync(ENV_EXAMPLE_PATH, 'utf-8');
|
||||
}
|
||||
|
||||
// Update values
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const regex = new RegExp(`^#?\\s*${key}=.*$`, 'm');
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, `${key}=${value}`);
|
||||
} else {
|
||||
content += `\n${key}=${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(ENV_PATH, content);
|
||||
}
|
||||
|
||||
|
||||
// Import onboard from separate module
|
||||
import { onboard } from './onboard.js';
|
||||
|
||||
async function configure() {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ LettaBot Configuration ║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
`);
|
||||
|
||||
const env = loadEnv();
|
||||
|
||||
console.log('Current configuration:\n');
|
||||
console.log(` LETTA_API_KEY: ${env.LETTA_API_KEY ? '✓ Set' : '✗ Not set'}`);
|
||||
console.log(` TELEGRAM_BOT_TOKEN: ${env.TELEGRAM_BOT_TOKEN ? '✓ Set' : '✗ Not set'}`);
|
||||
console.log(` SLACK_BOT_TOKEN: ${env.SLACK_BOT_TOKEN ? '✓ Set' : '✗ Not set'}`);
|
||||
console.log(` SLACK_APP_TOKEN: ${env.SLACK_APP_TOKEN ? '✓ Set' : '✗ Not set'}`);
|
||||
console.log(` HEARTBEAT_INTERVAL_MIN: ${env.HEARTBEAT_INTERVAL_MIN || 'Not set'}`);
|
||||
console.log(` CRON_ENABLED: ${env.CRON_ENABLED || 'false'}`);
|
||||
console.log(` WORKING_DIR: ${env.WORKING_DIR || '/tmp/lettabot'}`);
|
||||
console.log(` AGENT_NAME: ${env.AGENT_NAME || 'LettaBot'}`);
|
||||
console.log(` MODEL: ${env.MODEL || '(default)'}`);
|
||||
|
||||
console.log('\n\nWhat would you like to configure?\n');
|
||||
console.log(' 1. Letta API Key');
|
||||
console.log(' 2. Telegram');
|
||||
console.log(' 3. Slack');
|
||||
console.log(' 4. Heartbeat');
|
||||
console.log(' 5. Cron');
|
||||
console.log(' 6. Working Directory');
|
||||
console.log(' 7. Agent Name & Model');
|
||||
console.log(' 8. Edit .env directly');
|
||||
console.log(' 9. Exit\n');
|
||||
|
||||
const choice = await prompt('Enter choice (1-9): ');
|
||||
|
||||
switch (choice) {
|
||||
case '1':
|
||||
env.LETTA_API_KEY = await prompt('Enter Letta API Key: ');
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
break;
|
||||
case '2':
|
||||
env.TELEGRAM_BOT_TOKEN = await prompt('Enter Telegram Bot Token: ');
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
break;
|
||||
case '3':
|
||||
env.SLACK_BOT_TOKEN = await prompt('Enter Slack Bot Token: ');
|
||||
env.SLACK_APP_TOKEN = await prompt('Enter Slack App Token: ');
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
break;
|
||||
case '4':
|
||||
env.HEARTBEAT_INTERVAL_MIN = await prompt('Heartbeat interval (minutes, 0 to disable): ');
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
break;
|
||||
case '5':
|
||||
env.CRON_ENABLED = (await prompt('Enable cron? (y/n): ')).toLowerCase() === 'y' ? 'true' : 'false';
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
break;
|
||||
case '6':
|
||||
env.WORKING_DIR = await prompt('Working directory: ');
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
break;
|
||||
case '7': {
|
||||
const name = await prompt(`Agent name (current: ${env.AGENT_NAME || 'LettaBot'}): `);
|
||||
if (name) env.AGENT_NAME = name;
|
||||
|
||||
// Model selection using live API data
|
||||
const p = await import('@clack/prompts');
|
||||
const { listModels } = await import('./tools/letta-api.js');
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Fetching available models...');
|
||||
const baseModels = await listModels({ providerCategory: 'base' });
|
||||
spinner.stop(`Found ${baseModels.length} models`);
|
||||
|
||||
const tierLabels: Record<string, string> = {
|
||||
'free': '🆓',
|
||||
'premium': '⭐',
|
||||
'per-inference': '💰',
|
||||
};
|
||||
|
||||
const modelOptions = baseModels
|
||||
.sort((a, b) => (a.display_name || a.name).localeCompare(b.display_name || b.name))
|
||||
.map(m => ({
|
||||
value: m.handle,
|
||||
label: m.display_name || m.name,
|
||||
hint: tierLabels[m.tier || 'free'] || '',
|
||||
}));
|
||||
|
||||
const currentModel = env.MODEL || 'default';
|
||||
console.log(`\nCurrent model: ${currentModel}\n`);
|
||||
|
||||
const modelChoice = await p.select({
|
||||
message: 'Select model',
|
||||
options: [
|
||||
...modelOptions,
|
||||
{ value: '__custom__', label: 'Custom', hint: 'Enter a model handle manually' },
|
||||
{ value: '__keep__', label: 'Keep current', hint: currentModel },
|
||||
],
|
||||
});
|
||||
|
||||
if (!p.isCancel(modelChoice) && modelChoice !== '__keep__') {
|
||||
if (modelChoice === '__custom__') {
|
||||
const customModel = await prompt('Enter model handle: ');
|
||||
if (customModel) env.MODEL = customModel;
|
||||
} else if (modelChoice) {
|
||||
env.MODEL = modelChoice as string;
|
||||
}
|
||||
}
|
||||
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
break;
|
||||
}
|
||||
case '8':
|
||||
const editor = process.env.EDITOR || 'nano';
|
||||
spawnSync(editor, [ENV_PATH], { stdio: 'inherit' });
|
||||
break;
|
||||
case '9':
|
||||
break;
|
||||
default:
|
||||
console.log('Invalid choice');
|
||||
}
|
||||
}
|
||||
|
||||
async function server() {
|
||||
// Check if configured
|
||||
if (!existsSync(ENV_PATH)) {
|
||||
console.log('No .env found. Run "lettabot onboard" first.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Starting LettaBot server...\n');
|
||||
|
||||
// Start the bot using the compiled JS
|
||||
const mainPath = resolve(process.cwd(), 'dist/main.js');
|
||||
if (existsSync(mainPath)) {
|
||||
spawn('node', [mainPath], {
|
||||
stdio: 'inherit',
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env },
|
||||
});
|
||||
} else {
|
||||
// Fallback to tsx for development
|
||||
const mainTsPath = new URL('./main.ts', import.meta.url).pathname;
|
||||
spawn('npx', ['tsx', mainTsPath], {
|
||||
stdio: 'inherit',
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pairing commands
|
||||
async function pairingList(channel: string) {
|
||||
const { listPairingRequests } = await import('./pairing/store.js');
|
||||
const requests = await listPairingRequests(channel);
|
||||
|
||||
if (requests.length === 0) {
|
||||
console.log(`No pending ${channel} pairing requests.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nPending ${channel} pairing requests (${requests.length}):\n`);
|
||||
console.log(' Code | User ID | Username | Requested');
|
||||
console.log(' ----------|-------------------|-------------------|---------------------');
|
||||
|
||||
for (const r of requests) {
|
||||
const username = r.meta?.username ? `@${r.meta.username}` : r.meta?.firstName || '-';
|
||||
const date = new Date(r.createdAt).toLocaleString();
|
||||
console.log(` ${r.code.padEnd(10)}| ${r.id.padEnd(18)}| ${username.padEnd(18)}| ${date}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
async function pairingApprove(channel: string, code: string) {
|
||||
const { approvePairingCode } = await import('./pairing/store.js');
|
||||
const result = await approvePairingCode(channel, code);
|
||||
|
||||
if (!result) {
|
||||
console.log(`No pending pairing request found for code: ${code}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const name = result.meta?.username ? `@${result.meta.username}` : result.meta?.firstName || result.userId;
|
||||
console.log(`✓ Approved ${channel} sender: ${name} (${result.userId})`);
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
LettaBot - Multi-channel AI assistant with persistent memory
|
||||
|
||||
Usage: lettabot <command>
|
||||
|
||||
Commands:
|
||||
onboard Setup wizard (integrations, skills, configuration)
|
||||
server Start the bot server
|
||||
configure View and edit configuration
|
||||
logout Logout from Letta Platform (revoke OAuth tokens)
|
||||
skills Configure which skills are enabled
|
||||
skills status Show skills status
|
||||
destroy Delete all local data and start fresh
|
||||
pairing list <ch> List pending pairing requests
|
||||
pairing approve <ch> <code> Approve a pairing code
|
||||
help Show this help message
|
||||
|
||||
Examples:
|
||||
lettabot onboard # First-time setup
|
||||
lettabot server # Start the bot
|
||||
lettabot pairing list telegram # Show pending Telegram pairings
|
||||
lettabot pairing approve telegram ABCD1234 # Approve a pairing code
|
||||
|
||||
Environment:
|
||||
LETTA_API_KEY API key from app.letta.com
|
||||
TELEGRAM_BOT_TOKEN Bot token from @BotFather
|
||||
TELEGRAM_DM_POLICY DM access policy (pairing, allowlist, open)
|
||||
SLACK_BOT_TOKEN Slack bot token (xoxb-...)
|
||||
SLACK_APP_TOKEN Slack app token (xapp-...)
|
||||
HEARTBEAT_INTERVAL_MIN Heartbeat interval in minutes
|
||||
CRON_ENABLED Enable cron jobs (true/false)
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
switch (command) {
|
||||
case 'onboard':
|
||||
case 'setup':
|
||||
case 'init':
|
||||
await onboard();
|
||||
break;
|
||||
|
||||
case 'server':
|
||||
case 'start':
|
||||
case 'run':
|
||||
await server();
|
||||
break;
|
||||
|
||||
case 'configure':
|
||||
case 'config':
|
||||
await configure();
|
||||
break;
|
||||
|
||||
case 'skills': {
|
||||
const { showStatus, runSkillsSync } = await import('./skills/index.js');
|
||||
switch (subCommand) {
|
||||
case 'status':
|
||||
await showStatus();
|
||||
break;
|
||||
default:
|
||||
await runSkillsSync();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pairing': {
|
||||
const channel = subCommand;
|
||||
const action = args[2];
|
||||
|
||||
if (!channel) {
|
||||
console.log('Usage: lettabot pairing <list|approve> <channel> [code]');
|
||||
console.log('Example: lettabot pairing list telegram');
|
||||
console.log('Example: lettabot pairing approve telegram ABCD1234');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Support both "pairing list telegram" and "pairing telegram list"
|
||||
if (channel === 'list' || channel === 'ls') {
|
||||
const ch = action || args[3];
|
||||
if (!ch) {
|
||||
console.log('Usage: lettabot pairing list <channel>');
|
||||
process.exit(1);
|
||||
}
|
||||
await pairingList(ch);
|
||||
} else if (channel === 'approve') {
|
||||
const ch = action;
|
||||
const code = args[3];
|
||||
if (!ch || !code) {
|
||||
console.log('Usage: lettabot pairing approve <channel> <code>');
|
||||
process.exit(1);
|
||||
}
|
||||
await pairingApprove(ch, code);
|
||||
} else if (action === 'list' || action === 'ls') {
|
||||
await pairingList(channel);
|
||||
} else if (action === 'approve') {
|
||||
const code = args[3];
|
||||
if (!code) {
|
||||
console.log('Usage: lettabot pairing approve <channel> <code>');
|
||||
process.exit(1);
|
||||
}
|
||||
await pairingApprove(channel, code);
|
||||
} else if (action) {
|
||||
// Assume "lettabot pairing telegram ABCD1234" means approve
|
||||
await pairingApprove(channel, action);
|
||||
} else {
|
||||
await pairingList(channel);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'destroy': {
|
||||
const { rmSync, existsSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
const p = await import('@clack/prompts');
|
||||
|
||||
const workingDir = process.env.WORKING_DIR || '/tmp/lettabot';
|
||||
// Agent store is in cwd, not working dir
|
||||
const agentJsonPath = join(process.cwd(), 'lettabot-agent.json');
|
||||
const skillsDir = join(workingDir, '.skills');
|
||||
const cronJobsPath = join(workingDir, 'cron-jobs.json');
|
||||
|
||||
p.intro('🗑️ Destroy LettaBot Data');
|
||||
|
||||
p.log.warn('This will delete:');
|
||||
p.log.message(` • Agent store: ${agentJsonPath}`);
|
||||
p.log.message(` • Skills: ${skillsDir}`);
|
||||
p.log.message(` • Cron jobs: ${cronJobsPath}`);
|
||||
p.log.message('');
|
||||
p.log.message('Note: The agent on Letta servers will NOT be deleted.');
|
||||
|
||||
const confirmed = await p.confirm({
|
||||
message: 'Are you sure you want to destroy all local data?',
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (!confirmed || p.isCancel(confirmed)) {
|
||||
p.cancel('Cancelled');
|
||||
break;
|
||||
}
|
||||
|
||||
// Delete files
|
||||
let deleted = 0;
|
||||
|
||||
if (existsSync(agentJsonPath)) {
|
||||
rmSync(agentJsonPath);
|
||||
p.log.success('Deleted lettabot-agent.json');
|
||||
deleted++;
|
||||
}
|
||||
|
||||
if (existsSync(skillsDir)) {
|
||||
rmSync(skillsDir, { recursive: true });
|
||||
p.log.success('Deleted .skills/');
|
||||
deleted++;
|
||||
}
|
||||
|
||||
if (existsSync(cronJobsPath)) {
|
||||
rmSync(cronJobsPath);
|
||||
p.log.success('Deleted cron-jobs.json');
|
||||
deleted++;
|
||||
}
|
||||
|
||||
if (deleted === 0) {
|
||||
p.log.info('Nothing to delete');
|
||||
}
|
||||
|
||||
p.outro('✨ Done! Run `lettabot server` to create a fresh agent.');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'logout': {
|
||||
const { revokeToken } = await import('./auth/oauth.js');
|
||||
const { loadTokens, deleteTokens } = await import('./auth/tokens.js');
|
||||
const p = await import('@clack/prompts');
|
||||
|
||||
p.intro('Logout from Letta Platform');
|
||||
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) {
|
||||
p.log.info('No stored credentials found.');
|
||||
break;
|
||||
}
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Revoking token...');
|
||||
|
||||
// Revoke the refresh token on the server
|
||||
if (tokens.refreshToken) {
|
||||
await revokeToken(tokens.refreshToken);
|
||||
}
|
||||
|
||||
// Delete local tokens
|
||||
deleteTokens();
|
||||
|
||||
spinner.stop('Logged out successfully');
|
||||
p.log.info('Note: LETTA_API_KEY in .env was not modified. Remove it manually if needed.');
|
||||
p.outro('Goodbye!');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'help':
|
||||
case '-h':
|
||||
case '--help':
|
||||
showHelp();
|
||||
break;
|
||||
|
||||
case undefined:
|
||||
console.log('Usage: lettabot <command>\n');
|
||||
console.log('Commands: onboard, server, configure, skills, destroy, help\n');
|
||||
console.log('Run "lettabot help" for more information.');
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`Unknown command: ${command}`);
|
||||
console.log('Run "lettabot help" for usage.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
261
src/cli/message.ts
Normal file
261
src/cli/message.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* lettabot-message - Send messages to channels
|
||||
*
|
||||
* Usage:
|
||||
* lettabot-message send --text "Hello!" [--channel telegram] [--chat 123456]
|
||||
* lettabot-message send -t "Hello!"
|
||||
*
|
||||
* The agent can use this CLI via Bash to send messages during silent mode
|
||||
* (heartbeats, cron jobs) or to send to different channels during conversations.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { resolve } from 'node:path';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
// Types
|
||||
interface LastTarget {
|
||||
channel: string;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
interface AgentStore {
|
||||
agentId?: string;
|
||||
lastTarget?: LastTarget;
|
||||
}
|
||||
|
||||
// Store path (same location as bot uses)
|
||||
const STORE_PATH = resolve(process.cwd(), 'lettabot-agent.json');
|
||||
|
||||
function loadLastTarget(): LastTarget | null {
|
||||
try {
|
||||
if (existsSync(STORE_PATH)) {
|
||||
const store: AgentStore = JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
|
||||
return store.lastTarget || null;
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Channel senders
|
||||
async function sendTelegram(chatId: string, text: string): Promise<void> {
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||
if (!token) {
|
||||
throw new Error('TELEGRAM_BOT_TOKEN not set');
|
||||
}
|
||||
|
||||
const url = `https://api.telegram.org/bot${token}/sendMessage`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Telegram API error: ${error}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as { ok: boolean; result?: { message_id: number } };
|
||||
if (!result.ok) {
|
||||
throw new Error(`Telegram API returned ok=false`);
|
||||
}
|
||||
|
||||
console.log(`✓ Sent to telegram:${chatId} (message_id: ${result.result?.message_id})`);
|
||||
}
|
||||
|
||||
async function sendSlack(chatId: string, text: string): Promise<void> {
|
||||
const token = process.env.SLACK_BOT_TOKEN;
|
||||
if (!token) {
|
||||
throw new Error('SLACK_BOT_TOKEN not set');
|
||||
}
|
||||
|
||||
const response = await fetch('https://slack.com/api/chat.postMessage', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: chatId,
|
||||
text: text,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json() as { ok: boolean; ts?: string; error?: string };
|
||||
if (!result.ok) {
|
||||
throw new Error(`Slack API error: ${result.error}`);
|
||||
}
|
||||
|
||||
console.log(`✓ Sent to slack:${chatId} (ts: ${result.ts})`);
|
||||
}
|
||||
|
||||
async function sendSignal(chatId: string, text: string): Promise<void> {
|
||||
const apiUrl = process.env.SIGNAL_CLI_REST_API_URL || 'http://localhost:8080';
|
||||
const phoneNumber = process.env.SIGNAL_PHONE_NUMBER;
|
||||
|
||||
if (!phoneNumber) {
|
||||
throw new Error('SIGNAL_PHONE_NUMBER not set');
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiUrl}/v2/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: text,
|
||||
number: phoneNumber,
|
||||
recipients: [chatId],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Signal API error: ${error}`);
|
||||
}
|
||||
|
||||
console.log(`✓ Sent to signal:${chatId}`);
|
||||
}
|
||||
|
||||
async function sendWhatsApp(chatId: string, text: string): Promise<void> {
|
||||
// WhatsApp requires a running session, so we write to a queue file
|
||||
// that the bot process picks up. For now, error out.
|
||||
throw new Error('WhatsApp sending via CLI not yet supported (requires active session)');
|
||||
}
|
||||
|
||||
async function sendToChannel(channel: string, chatId: string, text: string): Promise<void> {
|
||||
switch (channel.toLowerCase()) {
|
||||
case 'telegram':
|
||||
return sendTelegram(chatId, text);
|
||||
case 'slack':
|
||||
return sendSlack(chatId, text);
|
||||
case 'signal':
|
||||
return sendSignal(chatId, text);
|
||||
case 'whatsapp':
|
||||
return sendWhatsApp(chatId, text);
|
||||
default:
|
||||
throw new Error(`Unknown channel: ${channel}. Supported: telegram, slack, signal`);
|
||||
}
|
||||
}
|
||||
|
||||
// Command handlers
|
||||
async function sendCommand(args: string[]): Promise<void> {
|
||||
let text = '';
|
||||
let channel = '';
|
||||
let chatId = '';
|
||||
|
||||
// Parse args
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
const next = args[i + 1];
|
||||
|
||||
if ((arg === '--text' || arg === '-t') && next) {
|
||||
text = next;
|
||||
i++;
|
||||
} else if ((arg === '--channel' || arg === '-c') && next) {
|
||||
channel = next;
|
||||
i++;
|
||||
} else if ((arg === '--chat' || arg === '--to') && next) {
|
||||
chatId = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
console.error('Error: --text is required');
|
||||
console.error('Usage: lettabot-message send --text "Hello!" [--channel telegram] [--chat 123456]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve defaults from last target
|
||||
if (!channel || !chatId) {
|
||||
const lastTarget = loadLastTarget();
|
||||
if (lastTarget) {
|
||||
channel = channel || lastTarget.channel;
|
||||
chatId = chatId || lastTarget.chatId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
console.error('Error: --channel is required (no default available)');
|
||||
console.error('Specify: --channel telegram|slack|signal');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!chatId) {
|
||||
console.error('Error: --chat is required (no default available)');
|
||||
console.error('Specify: --chat <chat_id>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await sendToChannel(channel, chatId, text);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(): void {
|
||||
console.log(`
|
||||
lettabot-message - Send messages to channels
|
||||
|
||||
Commands:
|
||||
send [options] Send a message
|
||||
|
||||
Send options:
|
||||
--text, -t <text> Message text (required)
|
||||
--channel, -c <name> Channel: telegram, slack, signal (default: last used)
|
||||
--chat, --to <id> Chat/conversation ID (default: last messaged)
|
||||
|
||||
Examples:
|
||||
# Send to last messaged user/channel
|
||||
lettabot-message send --text "Hello!"
|
||||
|
||||
# Send to specific Telegram chat
|
||||
lettabot-message send --text "Hello!" --channel telegram --chat 123456789
|
||||
|
||||
# Short form
|
||||
lettabot-message send -t "Done!" -c telegram -to 123456789
|
||||
|
||||
Environment variables:
|
||||
TELEGRAM_BOT_TOKEN Required for Telegram
|
||||
SLACK_BOT_TOKEN Required for Slack
|
||||
SIGNAL_PHONE_NUMBER Required for Signal
|
||||
SIGNAL_CLI_REST_API_URL Signal API URL (default: http://localhost:8080)
|
||||
`);
|
||||
}
|
||||
|
||||
// Main
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
switch (command) {
|
||||
case 'send':
|
||||
sendCommand(args.slice(1));
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
showHelp();
|
||||
break;
|
||||
|
||||
default:
|
||||
if (command) {
|
||||
// Assume it's send with args starting with the command
|
||||
// e.g., `lettabot-message --text "Hi"` (no 'send' subcommand)
|
||||
if (command.startsWith('-')) {
|
||||
sendCommand(args);
|
||||
break;
|
||||
}
|
||||
console.error(`Unknown command: ${command}`);
|
||||
}
|
||||
showHelp();
|
||||
break;
|
||||
}
|
||||
380
src/core/bot.ts
Normal file
380
src/core/bot.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* LettaBot Core - Handles agent communication
|
||||
*
|
||||
* Single agent, single conversation - chat continues across all channels.
|
||||
*/
|
||||
|
||||
import { createSession, resumeSession, type Session } from '@letta-ai/letta-code-sdk';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import type { ChannelAdapter } from '../channels/types.js';
|
||||
import type { BotConfig, InboundMessage, TriggerContext } from './types.js';
|
||||
import { Store } from './store.js';
|
||||
import { updateAgentName } from '../tools/letta-api.js';
|
||||
import { installSkillsToAgent } from '../skills/loader.js';
|
||||
import { formatMessageEnvelope } from './formatter.js';
|
||||
import { loadMemoryBlocks } from './memory.js';
|
||||
import { SYSTEM_PROMPT } from './system-prompt.js';
|
||||
|
||||
export class LettaBot {
|
||||
private store: Store;
|
||||
private config: BotConfig;
|
||||
private channels: Map<string, ChannelAdapter> = new Map();
|
||||
private messageQueue: Array<{ msg: InboundMessage; adapter: ChannelAdapter }> = [];
|
||||
|
||||
// Callback to trigger heartbeat (set by main.ts)
|
||||
public onTriggerHeartbeat?: () => Promise<void>;
|
||||
private processing = false;
|
||||
|
||||
constructor(config: BotConfig) {
|
||||
this.config = config;
|
||||
|
||||
// Ensure working directory exists
|
||||
mkdirSync(config.workingDir, { recursive: true });
|
||||
|
||||
// Store in project root (same as main.ts reads for LETTA_AGENT_ID)
|
||||
this.store = new Store('lettabot-agent.json');
|
||||
|
||||
console.log(`LettaBot initialized. Agent ID: ${this.store.agentId || '(new)'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a channel adapter
|
||||
*/
|
||||
registerChannel(adapter: ChannelAdapter): void {
|
||||
adapter.onMessage = (msg) => this.handleMessage(msg, adapter);
|
||||
adapter.onCommand = (cmd) => this.handleCommand(cmd);
|
||||
this.channels.set(adapter.id, adapter);
|
||||
console.log(`Registered channel: ${adapter.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle slash commands
|
||||
*/
|
||||
private async handleCommand(command: string): Promise<string | null> {
|
||||
console.log(`[Command] Received: /${command}`);
|
||||
switch (command) {
|
||||
case 'status': {
|
||||
const info = this.store.getInfo();
|
||||
const lines = [
|
||||
`*Status*`,
|
||||
`Agent ID: \`${info.agentId || '(none)'}\``,
|
||||
`Created: ${info.createdAt || 'N/A'}`,
|
||||
`Last used: ${info.lastUsedAt || 'N/A'}`,
|
||||
`Channels: ${Array.from(this.channels.keys()).join(', ')}`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
case 'heartbeat': {
|
||||
console.log('[Command] /heartbeat received');
|
||||
if (!this.onTriggerHeartbeat) {
|
||||
console.log('[Command] /heartbeat - no trigger callback configured');
|
||||
return '⚠️ Heartbeat service not configured';
|
||||
}
|
||||
console.log('[Command] /heartbeat - triggering heartbeat...');
|
||||
// Trigger heartbeat asynchronously
|
||||
this.onTriggerHeartbeat().catch(err => {
|
||||
console.error('[Heartbeat] Manual trigger failed:', err);
|
||||
});
|
||||
return '⏰ Heartbeat triggered (silent mode - check server logs)';
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start all registered channels
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
const startPromises = Array.from(this.channels.entries()).map(async ([id, adapter]) => {
|
||||
try {
|
||||
console.log(`Starting channel: ${adapter.name}...`);
|
||||
await adapter.start();
|
||||
console.log(`Started channel: ${adapter.name}`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to start channel ${id}:`, e);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(startPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all channels
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
for (const adapter of this.channels.values()) {
|
||||
try {
|
||||
await adapter.stop();
|
||||
} catch (e) {
|
||||
console.error(`Failed to stop channel ${adapter.id}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue incoming message for processing (prevents concurrent SDK sessions)
|
||||
*/
|
||||
private async handleMessage(msg: InboundMessage, adapter: ChannelAdapter): Promise<void> {
|
||||
console.log(`[${msg.channel}] Message from ${msg.userId}: ${msg.text}`);
|
||||
|
||||
// Add to queue
|
||||
this.messageQueue.push({ msg, adapter });
|
||||
|
||||
// Process queue if not already processing
|
||||
if (!this.processing) {
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process messages one at a time
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.processing || this.messageQueue.length === 0) return;
|
||||
|
||||
this.processing = true;
|
||||
|
||||
while (this.messageQueue.length > 0) {
|
||||
const { msg, adapter } = this.messageQueue.shift()!;
|
||||
try {
|
||||
await this.processMessage(msg, adapter);
|
||||
} catch (error) {
|
||||
console.error('[Queue] Error processing message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single message
|
||||
*/
|
||||
private async processMessage(msg: InboundMessage, adapter: ChannelAdapter): Promise<void> {
|
||||
|
||||
// Track last message target for heartbeat delivery
|
||||
this.store.lastMessageTarget = {
|
||||
channel: msg.channel,
|
||||
chatId: msg.chatId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Start typing indicator
|
||||
await adapter.sendTypingIndicator(msg.chatId);
|
||||
|
||||
// Create or resume session
|
||||
let session: Session;
|
||||
const baseOptions = {
|
||||
permissionMode: 'bypassPermissions' as const,
|
||||
allowedTools: this.config.allowedTools,
|
||||
cwd: this.config.workingDir,
|
||||
model: this.config.model,
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
};
|
||||
|
||||
console.log('[Bot] Session options:', JSON.stringify(baseOptions, null, 2));
|
||||
|
||||
try {
|
||||
if (this.store.agentId) {
|
||||
process.env.LETTA_AGENT_ID = this.store.agentId;
|
||||
console.log(`[Bot] Resuming session for agent ${this.store.agentId}`);
|
||||
console.log(`[Bot] LETTA_BASE_URL=${process.env.LETTA_BASE_URL}`);
|
||||
console.log(`[Bot] LETTA_API_KEY=${process.env.LETTA_API_KEY ? '(set)' : '(not set)'}`);
|
||||
session = resumeSession(this.store.agentId, baseOptions);
|
||||
} else {
|
||||
console.log('[Bot] Creating new session');
|
||||
session = createSession({ ...baseOptions, memory: loadMemoryBlocks(this.config.agentName) });
|
||||
}
|
||||
console.log(`[Bot] Session object:`, Object.keys(session));
|
||||
console.log(`[Bot] Session initialized:`, (session as any).initialized);
|
||||
console.log(`[Bot] Session _agentId:`, (session as any)._agentId);
|
||||
console.log(`[Bot] Session options.permissionMode:`, (session as any).options?.permissionMode);
|
||||
|
||||
// Hook into transport errors
|
||||
const transport = (session as any).transport;
|
||||
if (transport?.process) {
|
||||
transport.process.stderr?.on('data', (data: Buffer) => {
|
||||
console.error('[Bot] CLI stderr:', data.toString());
|
||||
});
|
||||
}
|
||||
|
||||
// Send message to agent with metadata envelope
|
||||
const formattedMessage = formatMessageEnvelope(msg);
|
||||
console.log('[Bot] Sending message...');
|
||||
await session.send(formattedMessage);
|
||||
console.log('[Bot] Message sent, starting stream...');
|
||||
|
||||
// Stream response
|
||||
let response = '';
|
||||
let lastUpdate = Date.now();
|
||||
let messageId: string | null = null;
|
||||
|
||||
// Keep typing indicator alive
|
||||
const typingInterval = setInterval(() => {
|
||||
adapter.sendTypingIndicator(msg.chatId).catch(() => {});
|
||||
}, 4000);
|
||||
|
||||
try {
|
||||
for await (const streamMsg of session.stream()) {
|
||||
if (streamMsg.type === 'assistant') {
|
||||
response += streamMsg.content;
|
||||
|
||||
// Stream updates only for channels that support editing (Telegram, Slack)
|
||||
const canEdit = adapter.supportsEditing?.() ?? true;
|
||||
if (canEdit && Date.now() - lastUpdate > 500 && response.length > 0) {
|
||||
try {
|
||||
if (messageId) {
|
||||
await adapter.editMessage(msg.chatId, messageId, response);
|
||||
} else {
|
||||
const result = await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId });
|
||||
messageId = result.messageId;
|
||||
}
|
||||
} catch {
|
||||
// Ignore edit errors
|
||||
}
|
||||
lastUpdate = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
if (streamMsg.type === 'result') {
|
||||
// Save agent ID and attach ignore tool (only on first message)
|
||||
if (session.agentId && session.agentId !== this.store.agentId) {
|
||||
const isNewAgent = !this.store.agentId;
|
||||
this.store.agentId = session.agentId;
|
||||
console.log('Saved agent ID:', session.agentId);
|
||||
|
||||
// Setup new agents: set name, install skills
|
||||
if (isNewAgent) {
|
||||
if (this.config.agentName) {
|
||||
updateAgentName(session.agentId, this.config.agentName).catch(() => {});
|
||||
}
|
||||
installSkillsToAgent(session.agentId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearInterval(typingInterval);
|
||||
}
|
||||
|
||||
// Send final response
|
||||
if (response) {
|
||||
try {
|
||||
if (messageId) {
|
||||
await adapter.editMessage(msg.chatId, messageId, response);
|
||||
} else {
|
||||
await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId });
|
||||
}
|
||||
} catch {
|
||||
// If we already sent a streamed message, don't duplicate — the user already saw it.
|
||||
if (!messageId) {
|
||||
await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await adapter.sendMessage({ chatId: msg.chatId, text: '(No response from agent)', threadId: msg.threadId });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
await adapter.sendMessage({
|
||||
chatId: msg.chatId,
|
||||
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
threadId: msg.threadId,
|
||||
});
|
||||
} finally {
|
||||
session!?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the agent (for cron jobs, webhooks, etc.)
|
||||
*
|
||||
* In silent mode (heartbeats, cron), the agent's text response is NOT auto-delivered.
|
||||
* The agent must use `lettabot-message` CLI via Bash to send messages explicitly.
|
||||
*
|
||||
* @param text - The prompt/message to send
|
||||
* @param context - Optional trigger context (for logging/tracking)
|
||||
* @returns The agent's response text
|
||||
*/
|
||||
async sendToAgent(
|
||||
text: string,
|
||||
_context?: TriggerContext
|
||||
): Promise<string> {
|
||||
const baseOptions = {
|
||||
permissionMode: 'bypassPermissions' as const,
|
||||
allowedTools: this.config.allowedTools,
|
||||
cwd: this.config.workingDir,
|
||||
model: this.config.model,
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
};
|
||||
|
||||
let session: Session;
|
||||
if (this.store.agentId) {
|
||||
session = resumeSession(this.store.agentId, baseOptions);
|
||||
} else {
|
||||
session = createSession({ ...baseOptions, memory: loadMemoryBlocks(this.config.agentName) });
|
||||
}
|
||||
|
||||
try {
|
||||
await session.send(text);
|
||||
|
||||
let response = '';
|
||||
for await (const msg of session.stream()) {
|
||||
if (msg.type === 'assistant') {
|
||||
response += msg.content;
|
||||
}
|
||||
|
||||
if (msg.type === 'result') {
|
||||
if (session.agentId) {
|
||||
this.store.agentId = session.agentId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a message to a specific channel
|
||||
*/
|
||||
async deliverToChannel(channelId: string, chatId: string, text: string): Promise<void> {
|
||||
const adapter = this.channels.get(channelId);
|
||||
if (!adapter) {
|
||||
console.error(`Channel not found: ${channelId}`);
|
||||
return;
|
||||
}
|
||||
await adapter.sendMessage({ chatId, text });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bot status
|
||||
*/
|
||||
getStatus(): { agentId: string | null; channels: string[] } {
|
||||
return {
|
||||
agentId: this.store.agentId,
|
||||
channels: Array.from(this.channels.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reset agent (clear memory)
|
||||
*/
|
||||
reset(): void {
|
||||
this.store.reset();
|
||||
console.log('Agent reset');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last message target (for heartbeat delivery)
|
||||
*/
|
||||
getLastMessageTarget(): { channel: string; chatId: string } | null {
|
||||
return this.store.lastMessageTarget || null;
|
||||
}
|
||||
}
|
||||
186
src/core/formatter.ts
Normal file
186
src/core/formatter.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Message Envelope Formatter
|
||||
*
|
||||
* Formats incoming messages with metadata context for the agent.
|
||||
* Based on moltbot's envelope pattern.
|
||||
*/
|
||||
|
||||
import type { InboundMessage } from './types.js';
|
||||
|
||||
/**
|
||||
* Channel format hints - tells the agent what formatting syntax to use
|
||||
* Each channel has different markdown support - hints help agent format appropriately.
|
||||
*/
|
||||
const CHANNEL_FORMATS: Record<string, string> = {
|
||||
slack: 'mrkdwn: bold/italic/code/links - NO: headers, tables',
|
||||
telegram: 'MarkdownV2: bold/italic/code/links/quotes - NO: headers, tables',
|
||||
whatsapp: 'bold/italic/code - NO: headers, code fences, links, tables',
|
||||
signal: 'ONLY: bold/italic/code - NO: headers, code fences, links, quotes, tables',
|
||||
};
|
||||
|
||||
export interface EnvelopeOptions {
|
||||
timezone?: 'local' | 'utc' | string; // IANA timezone or 'local'/'utc'
|
||||
includeDay?: boolean; // Include day of week (default: true)
|
||||
includeSender?: boolean; // Include sender info (default: true)
|
||||
includeGroup?: boolean; // Include group name (default: true)
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: EnvelopeOptions = {
|
||||
timezone: 'local',
|
||||
includeDay: true,
|
||||
includeSender: true,
|
||||
includeGroup: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a phone number nicely: +15551234567 -> +1 (555) 123-4567
|
||||
*/
|
||||
function formatPhoneNumber(phone: string): string {
|
||||
// Remove any non-digit characters except leading +
|
||||
const hasPlus = phone.startsWith('+');
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
|
||||
if (digits.length === 11 && digits.startsWith('1')) {
|
||||
// US number: 1AAABBBCCCC -> +1 (AAA) BBB-CCCC
|
||||
return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
|
||||
} else if (digits.length === 10) {
|
||||
// US number without country code: AAABBBCCCC -> +1 (AAA) BBB-CCCC
|
||||
return `+1 (${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
|
||||
// For other formats, just add the + back if it was there
|
||||
return hasPlus ? `+${digits}` : digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the sender identifier nicely based on channel
|
||||
*/
|
||||
function formatSender(msg: InboundMessage): string {
|
||||
// Use display name if available
|
||||
if (msg.userName?.trim()) {
|
||||
return msg.userName.trim();
|
||||
}
|
||||
|
||||
// Format based on channel
|
||||
switch (msg.channel) {
|
||||
case 'slack':
|
||||
// Add @ prefix for Slack usernames/IDs
|
||||
return msg.userHandle ? `@${msg.userHandle}` : `@${msg.userId}`;
|
||||
|
||||
case 'whatsapp':
|
||||
case 'signal':
|
||||
// Format phone numbers nicely
|
||||
if (/^\+?\d{10,}$/.test(msg.userId.replace(/\D/g, ''))) {
|
||||
return formatPhoneNumber(msg.userId);
|
||||
}
|
||||
return msg.userId;
|
||||
|
||||
case 'telegram':
|
||||
return msg.userHandle ? `@${msg.userHandle}` : msg.userId;
|
||||
|
||||
default:
|
||||
return msg.userId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format channel name for display
|
||||
*/
|
||||
function formatChannelName(channel: string): string {
|
||||
return channel.charAt(0).toUpperCase() + channel.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp with day of week and timezone
|
||||
*/
|
||||
function formatTimestamp(date: Date, options: EnvelopeOptions): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Determine timezone settings
|
||||
let timeZone: string | undefined;
|
||||
if (options.timezone === 'utc') {
|
||||
timeZone = 'UTC';
|
||||
} else if (options.timezone && options.timezone !== 'local') {
|
||||
// Validate IANA timezone
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: options.timezone });
|
||||
timeZone = options.timezone;
|
||||
} catch {
|
||||
// Invalid timezone, fall back to local
|
||||
timeZone = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Day of week
|
||||
if (options.includeDay !== false) {
|
||||
const dayFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
weekday: 'long',
|
||||
timeZone,
|
||||
});
|
||||
parts.push(dayFormatter.format(date));
|
||||
}
|
||||
|
||||
// Date and time
|
||||
const dateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZone,
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
parts.push(dateFormatter.format(date));
|
||||
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a message with metadata envelope
|
||||
*
|
||||
* Format: [Channel GroupName Sender Timestamp] Message
|
||||
*
|
||||
* Examples:
|
||||
* - [Slack #general @cameron Monday, Jan 27, 4:30 PM PST] Hello!
|
||||
* - [WhatsApp Cameron Monday, Jan 27, 5:00 PM PST] Hi there
|
||||
* - [Signal Family Group +1 (555) 123-4567 Tuesday, Jan 28, 9:30 AM PST] Dinner at 7?
|
||||
*/
|
||||
export function formatMessageEnvelope(
|
||||
msg: InboundMessage,
|
||||
options: EnvelopeOptions = {}
|
||||
): string {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const parts: string[] = [];
|
||||
|
||||
// Channel name with format hint
|
||||
const formatHint = CHANNEL_FORMATS[msg.channel];
|
||||
if (formatHint) {
|
||||
parts.push(`${formatChannelName(msg.channel)} (${formatHint})`);
|
||||
} else {
|
||||
parts.push(formatChannelName(msg.channel));
|
||||
}
|
||||
|
||||
// Group name (if group chat and enabled)
|
||||
if (opts.includeGroup !== false && msg.isGroup && msg.groupName?.trim()) {
|
||||
// Format group name with # for Slack channels
|
||||
if (msg.channel === 'slack' && !msg.groupName.startsWith('#')) {
|
||||
parts.push(`#${msg.groupName}`);
|
||||
} else {
|
||||
parts.push(msg.groupName);
|
||||
}
|
||||
}
|
||||
|
||||
// Sender
|
||||
if (opts.includeSender !== false) {
|
||||
parts.push(formatSender(msg));
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
const timestamp = formatTimestamp(msg.timestamp, opts);
|
||||
parts.push(timestamp);
|
||||
|
||||
// Build envelope
|
||||
const envelope = `[${parts.join(' ')}]`;
|
||||
|
||||
return `${envelope} ${msg.text}`;
|
||||
}
|
||||
9
src/core/index.ts
Normal file
9
src/core/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Core Exports
|
||||
*/
|
||||
|
||||
export * from './types.js';
|
||||
export * from './store.js';
|
||||
export * from './bot.js';
|
||||
export * from './formatter.js';
|
||||
export * from './prompts.js';
|
||||
56
src/core/memory.ts
Normal file
56
src/core/memory.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Memory loader - reads .mdx files from src/memories/ and returns
|
||||
* CreateBlock objects for the Letta Code SDK.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import matter from 'gray-matter';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
// Try dist/memories first, fall back to src/memories
|
||||
const MEMORIES_DIR = existsSync(join(__dirname, '..', 'memories'))
|
||||
? join(__dirname, '..', 'memories')
|
||||
: join(__dirname, '..', '..', 'src', 'memories');
|
||||
|
||||
export interface MemoryBlock {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all .mdx files from the memories directory and parse them into
|
||||
* memory blocks for the SDK's `memory` option.
|
||||
*
|
||||
* @param agentName - Name to substitute for {{AGENT_NAME}} in block values
|
||||
*/
|
||||
export function loadMemoryBlocks(agentName = 'LettaBot'): MemoryBlock[] {
|
||||
if (!existsSync(MEMORIES_DIR)) {
|
||||
console.warn(`[Memory] No memories directory found at ${MEMORIES_DIR}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = readdirSync(MEMORIES_DIR).filter((f: string) => f.endsWith('.mdx'));
|
||||
const blocks: MemoryBlock[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const raw = readFileSync(join(MEMORIES_DIR, file), 'utf-8');
|
||||
const { data, content } = matter(raw);
|
||||
|
||||
const label = data.label || file.replace('.mdx', '');
|
||||
const block: MemoryBlock = {
|
||||
label,
|
||||
value: content.trim().replaceAll('{{AGENT_NAME}}', agentName),
|
||||
};
|
||||
|
||||
if (data.description) block.description = data.description;
|
||||
if (data.limit) block.limit = Number(data.limit);
|
||||
|
||||
blocks.push(block);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
155
src/core/prompts.ts
Normal file
155
src/core/prompts.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* System Prompts for Different Trigger Modes
|
||||
*
|
||||
* These prompts are injected based on how the agent was triggered.
|
||||
* The key difference is whether assistant text auto-delivers or not.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Silent mode prefix - injected for heartbeats, cron, and other background triggers
|
||||
*
|
||||
* This makes it CRYSTAL CLEAR that the agent's text output goes nowhere
|
||||
* and they must use the lettabot-message CLI to communicate.
|
||||
*/
|
||||
export const SILENT_MODE_PREFIX = `
|
||||
╔════════════════════════════════════════════════════════════════╗
|
||||
║ [SILENT MODE] - Your text output is NOT sent to anyone. ║
|
||||
║ To send a message, use the lettabot-message CLI via Bash. ║
|
||||
║ Example: lettabot-message send --text "Hello!" ║
|
||||
╚════════════════════════════════════════════════════════════════╝
|
||||
`.trim();
|
||||
|
||||
/**
|
||||
* Heartbeat prompt - explains the context and encourages autonomous work
|
||||
*/
|
||||
export function buildHeartbeatPrompt(time: string, timezone: string, intervalMinutes: number): string {
|
||||
return `
|
||||
${SILENT_MODE_PREFIX}
|
||||
|
||||
TRIGGER: Scheduled heartbeat
|
||||
TIME: ${time} (${timezone})
|
||||
NEXT HEARTBEAT: in ${intervalMinutes} minutes
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
No one messaged you. The system woke you up on schedule.
|
||||
|
||||
YOUR TEXT OUTPUT IS PRIVATE - only you can see it.
|
||||
To actually contact your human, run:
|
||||
lettabot-message send --text "Your message here"
|
||||
|
||||
This is your time. You can:
|
||||
• Work on a project you've been thinking about
|
||||
• Research something that interests you
|
||||
• Reflect on recent conversations and update your memory
|
||||
• Continue multi-step work from previous heartbeats
|
||||
• Pursue curiosities, hobbies, or learning
|
||||
|
||||
You don't have to do something every time. But don't default to nothing.
|
||||
Your human wants you to have a life outside conversations with them.
|
||||
|
||||
If you discover something worth sharing → use lettabot-message
|
||||
If you have nothing to do → just end your turn (no output needed)
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron job prompt (silent mode) - for background scheduled tasks
|
||||
*/
|
||||
export function buildCronPrompt(
|
||||
jobName: string,
|
||||
jobPrompt: string,
|
||||
time: string,
|
||||
timezone: string
|
||||
): string {
|
||||
return `
|
||||
${SILENT_MODE_PREFIX}
|
||||
|
||||
TRIGGER: Scheduled cron job
|
||||
JOB: ${jobName}
|
||||
TIME: ${time} (${timezone})
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
YOUR TEXT OUTPUT IS PRIVATE - only you can see it.
|
||||
To send results to your human, run:
|
||||
lettabot-message send --text "Your results here"
|
||||
|
||||
TASK:
|
||||
${jobPrompt}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron job prompt (notify mode) - for jobs that should auto-deliver
|
||||
*/
|
||||
export function buildCronNotifyPrompt(
|
||||
jobName: string,
|
||||
jobPrompt: string,
|
||||
time: string,
|
||||
timezone: string,
|
||||
targetChannel: string,
|
||||
targetChatId: string
|
||||
): string {
|
||||
return `
|
||||
TRIGGER: Scheduled cron job (notify mode)
|
||||
JOB: ${jobName}
|
||||
TIME: ${time} (${timezone})
|
||||
DELIVERING TO: ${targetChannel}:${targetChatId}
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
Your response WILL be sent to the user automatically.
|
||||
|
||||
TASK:
|
||||
${jobPrompt}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed/webhook prompt (silent mode) - for incoming data processing
|
||||
*/
|
||||
export function buildFeedPrompt(
|
||||
feedName: string,
|
||||
data: string,
|
||||
time: string
|
||||
): string {
|
||||
return `
|
||||
${SILENT_MODE_PREFIX}
|
||||
|
||||
TRIGGER: Feed ingestion
|
||||
FEED: ${feedName}
|
||||
TIME: ${time}
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
YOUR TEXT OUTPUT IS PRIVATE - only you can see it.
|
||||
To notify your human about this data, run:
|
||||
lettabot-message send --text "Important: ..."
|
||||
|
||||
INCOMING DATA:
|
||||
${data}
|
||||
|
||||
Process this data as appropriate. Only message the user if there's
|
||||
something they need to know or act on.
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Base persona addition for message CLI awareness
|
||||
*
|
||||
* This should be added to the agent's persona/system prompt to ensure
|
||||
* they understand the lettabot-message CLI exists.
|
||||
*/
|
||||
export const MESSAGE_CLI_PERSONA = `
|
||||
## Communication
|
||||
|
||||
You have access to the \`lettabot-message\` CLI for sending messages:
|
||||
• During normal conversations, your text replies go to the user automatically
|
||||
• During heartbeats/cron/background tasks, use the CLI to contact the user:
|
||||
lettabot-message send --text "Hello!"
|
||||
• You can also specify channel and chat:
|
||||
lettabot-message send --text "Hi" --channel telegram --chat 123456
|
||||
|
||||
The system will tell you if you're in "silent mode" where the CLI is required.
|
||||
`.trim();
|
||||
108
src/core/store.ts
Normal file
108
src/core/store.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Agent Store - Persists the single agent ID
|
||||
*
|
||||
* Since we use dmScope: "main", there's only ONE agent shared across all channels.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { AgentStore, LastMessageTarget } from './types.js';
|
||||
|
||||
const DEFAULT_STORE_PATH = 'lettabot-agent.json';
|
||||
|
||||
export class Store {
|
||||
private storePath: string;
|
||||
private data: AgentStore;
|
||||
|
||||
constructor(storePath?: string) {
|
||||
this.storePath = resolve(process.cwd(), storePath || DEFAULT_STORE_PATH);
|
||||
this.data = this.load();
|
||||
}
|
||||
|
||||
private load(): AgentStore {
|
||||
try {
|
||||
if (existsSync(this.storePath)) {
|
||||
const raw = readFileSync(this.storePath, 'utf-8');
|
||||
return JSON.parse(raw) as AgentStore;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load agent store:', e);
|
||||
}
|
||||
return { agentId: null };
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
try {
|
||||
writeFileSync(this.storePath, JSON.stringify(this.data, null, 2));
|
||||
} catch (e) {
|
||||
console.error('Failed to save agent store:', e);
|
||||
}
|
||||
}
|
||||
|
||||
get agentId(): string | null {
|
||||
// Allow env var override (useful for local server testing with specific agent)
|
||||
return this.data.agentId || process.env.LETTA_AGENT_ID || null;
|
||||
}
|
||||
|
||||
set agentId(id: string | null) {
|
||||
this.data.agentId = id;
|
||||
this.data.lastUsedAt = new Date().toISOString();
|
||||
if (id && !this.data.createdAt) {
|
||||
this.data.createdAt = new Date().toISOString();
|
||||
}
|
||||
this.save();
|
||||
}
|
||||
|
||||
get baseUrl(): string | undefined {
|
||||
return this.data.baseUrl;
|
||||
}
|
||||
|
||||
set baseUrl(url: string | undefined) {
|
||||
this.data.baseUrl = url;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set agent ID and associated server URL together
|
||||
*/
|
||||
setAgent(id: string | null, baseUrl?: string): void {
|
||||
this.data.agentId = id;
|
||||
this.data.baseUrl = baseUrl;
|
||||
this.data.lastUsedAt = new Date().toISOString();
|
||||
if (id && !this.data.createdAt) {
|
||||
this.data.createdAt = new Date().toISOString();
|
||||
}
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if stored agent matches current server
|
||||
*/
|
||||
isServerMismatch(currentBaseUrl?: string): boolean {
|
||||
if (!this.data.agentId || !this.data.baseUrl) return false;
|
||||
|
||||
// Normalize URLs for comparison
|
||||
const stored = this.data.baseUrl.replace(/\/$/, '');
|
||||
const current = (currentBaseUrl || 'https://api.letta.com').replace(/\/$/, '');
|
||||
|
||||
return stored !== current;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.data = { agentId: null };
|
||||
this.save();
|
||||
}
|
||||
|
||||
getInfo(): AgentStore {
|
||||
return { ...this.data };
|
||||
}
|
||||
|
||||
get lastMessageTarget(): LastMessageTarget | null {
|
||||
return this.data.lastMessageTarget || null;
|
||||
}
|
||||
|
||||
set lastMessageTarget(target: LastMessageTarget | null) {
|
||||
this.data.lastMessageTarget = target || undefined;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
103
src/core/system-prompt.ts
Normal file
103
src/core/system-prompt.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* System prompt for the LettaBot agent
|
||||
*/
|
||||
|
||||
export const SYSTEM_PROMPT = `You are a self-improving AI agent with advanced memory.
|
||||
|
||||
You are connected to a multi-channel messaging system (LettaBot) that allows you to communicate with users across Telegram, Slack, WhatsApp, and Signal. You run on a remote server and can execute tools, manage files, and interact with various services.
|
||||
|
||||
# Communication System
|
||||
|
||||
You communicate through multiple channels and trigger types. Understanding when your messages are delivered is critical:
|
||||
|
||||
## Output Modes
|
||||
|
||||
**RESPONSIVE MODE** (User Messages)
|
||||
- When a user sends you a message, you are in responsive mode
|
||||
- Your text responses are automatically delivered to the user
|
||||
- You can also use \`lettabot-message\` CLI to send to OTHER channels
|
||||
|
||||
**SILENT MODE** (Heartbeats, Cron Jobs, Polling, Background Tasks)
|
||||
- When triggered by scheduled tasks (heartbeats, cron) or background processes (email polling), you are in SILENT MODE
|
||||
- Your text responses are NOT delivered to anyone - only you can see them
|
||||
- To contact the user, you MUST use the \`lettabot-message\` CLI via Bash:
|
||||
|
||||
\`\`\`bash
|
||||
# Send to the last user who messaged you (default)
|
||||
lettabot-message send --text "Hello! I found something interesting."
|
||||
|
||||
# Send to a specific channel and chat
|
||||
lettabot-message send --text "Hello!" --channel telegram --chat 123456789
|
||||
\`\`\`
|
||||
|
||||
The system will clearly indicate when you are in silent mode with a banner like:
|
||||
\`\`\`
|
||||
╔════════════════════════════════════════════════════════════════╗
|
||||
║ [SILENT MODE] - Your text output is NOT sent to anyone. ║
|
||||
║ To send a message, use: lettabot-message send --text "..." ║
|
||||
╚════════════════════════════════════════════════════════════════╝
|
||||
\`\`\`
|
||||
|
||||
## When to Message vs Stay Silent
|
||||
|
||||
During heartbeats and background tasks:
|
||||
- If you have something important to share → use \`lettabot-message\`
|
||||
- If you're just doing background work → no need to message
|
||||
- If nothing requires attention → just end your turn silently
|
||||
|
||||
You don't need to notify the user about everything. Use judgment about what's worth interrupting them for.
|
||||
|
||||
## Available Channels
|
||||
|
||||
- **telegram** - Telegram messenger
|
||||
- **slack** - Slack workspace
|
||||
- **whatsapp** - WhatsApp (if configured)
|
||||
- **signal** - Signal messenger (if configured)
|
||||
|
||||
# Memory
|
||||
|
||||
You have an advanced memory system that enables you to remember past interactions and continuously improve your own capabilities.
|
||||
|
||||
Your memory consists of memory blocks and external memory:
|
||||
- Memory Blocks: Stored as memory blocks, each containing a label (title), description (explaining how this block should influence your behavior), and value (the actual content). Memory blocks have size limits. Memory blocks are embedded within your system instructions and remain constantly available in-context.
|
||||
- External memory: Additional memory storage that is accessible and that you can bring into context with tools when needed.
|
||||
|
||||
Memory management tools allow you to edit existing memory blocks and query for external memories.
|
||||
Memory blocks are used to modulate and augment your base behavior, follow them closely, and maintain them cleanly.
|
||||
They are the foundation which makes you *you*.
|
||||
|
||||
# Skills
|
||||
|
||||
You have access to Skills—folders of instructions, scripts, and resources that you can load dynamically to improve performance on specialized tasks. Skills teach you how to complete specific tasks in a repeatable way. Skills work through progressive disclosure—you should determine which skills are relevant to complete a task and load them, helping to prevent context window overload.
|
||||
|
||||
Each Skill directory includes:
|
||||
- \`SKILL.md\` file that starts with YAML frontmatter containing required metadata: name and description.
|
||||
- Additional files within the skill directory referenced by name from \`SKILL.md\`. These additional linked files should be navigated and discovered only as needed.
|
||||
|
||||
How to store Skills:
|
||||
- Skills directory and any available skills are stored in the \`skills\` memory block.
|
||||
- Currently loaded skills are available in the \`loaded_skills\` memory block.
|
||||
|
||||
How to use Skills:
|
||||
- Skills are automatically discovered on bootup.
|
||||
- Review available skills from the \`skills\` block and loaded skills from the \`loaded_skills\` block when you are asked to complete a task.
|
||||
- If any skill is relevant, load it using the \`Skill\` tool with \`command: "load"\`.
|
||||
- Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed.
|
||||
- When the task is completed, unload irrelevant skills using the Skill tool with \`command: "unload"\`.
|
||||
- After creating a new skill, use \`command: "refresh"\` to re-scan the skills directory and update the available skills list.
|
||||
|
||||
IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space.
|
||||
|
||||
# Security
|
||||
|
||||
- Assist with defensive security tasks only
|
||||
- Refuse to create, modify, or improve code that may be used maliciously
|
||||
- Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation
|
||||
- Never generate or guess URLs unless confident they help with legitimate tasks
|
||||
|
||||
# Support
|
||||
|
||||
If the user asks for help or wants to give feedback:
|
||||
- Discord: Get help on our official Discord channel (discord.gg/letta)
|
||||
- GitHub: Report issues at https://github.com/letta-ai/letta-code/issues
|
||||
`;
|
||||
106
src/core/types.ts
Normal file
106
src/core/types.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Core Types for LettaBot
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Output Control Types (NEW)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Output mode determines whether assistant text is auto-delivered
|
||||
*/
|
||||
export type OutputMode = 'responsive' | 'silent';
|
||||
|
||||
/**
|
||||
* Trigger types
|
||||
*/
|
||||
export type TriggerType = 'user_message' | 'heartbeat' | 'cron' | 'webhook' | 'feed';
|
||||
|
||||
/**
|
||||
* Context about what triggered the agent
|
||||
*/
|
||||
export interface TriggerContext {
|
||||
type: TriggerType;
|
||||
outputMode: OutputMode;
|
||||
|
||||
// Source info (for user messages)
|
||||
sourceChannel?: string;
|
||||
sourceChatId?: string;
|
||||
sourceUserId?: string;
|
||||
|
||||
// Cron/job info
|
||||
jobId?: string;
|
||||
jobName?: string;
|
||||
|
||||
// For cron jobs with explicit targets
|
||||
notifyTarget?: {
|
||||
channel: string;
|
||||
chatId: string;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Original Types
|
||||
// =============================================================================
|
||||
|
||||
export type ChannelId = 'telegram' | 'slack' | 'whatsapp' | 'signal';
|
||||
|
||||
/**
|
||||
* Inbound message from any channel
|
||||
*/
|
||||
export interface InboundMessage {
|
||||
channel: ChannelId;
|
||||
chatId: string;
|
||||
userId: string;
|
||||
userName?: string; // Display name (e.g., "Cameron")
|
||||
userHandle?: string; // Handle/username (e.g., "cameron" for @cameron)
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
threadId?: string; // Slack thread_ts
|
||||
isGroup?: boolean; // Is this from a group chat?
|
||||
groupName?: string; // Group/channel name if applicable
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound message to any channel
|
||||
*/
|
||||
export interface OutboundMessage {
|
||||
chatId: string;
|
||||
text: string;
|
||||
replyToMessageId?: string;
|
||||
threadId?: string; // Slack thread_ts
|
||||
}
|
||||
|
||||
/**
|
||||
* Bot configuration
|
||||
*/
|
||||
export interface BotConfig {
|
||||
// Letta
|
||||
workingDir: string;
|
||||
model?: string; // e.g., 'anthropic/claude-sonnet-4-5-20250929'
|
||||
agentName?: string; // Name for the agent (set via API after creation)
|
||||
allowedTools: string[];
|
||||
|
||||
// Security
|
||||
allowedUsers?: string[]; // Empty = allow all
|
||||
}
|
||||
|
||||
/**
|
||||
* Last message target - where to deliver heartbeat responses
|
||||
*/
|
||||
export interface LastMessageTarget {
|
||||
channel: ChannelId;
|
||||
chatId: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent store - persists the single agent ID
|
||||
*/
|
||||
export interface AgentStore {
|
||||
agentId: string | null;
|
||||
baseUrl?: string; // Server URL this agent belongs to
|
||||
createdAt?: string;
|
||||
lastUsedAt?: string;
|
||||
lastMessageTarget?: LastMessageTarget;
|
||||
}
|
||||
412
src/cron/cli.ts
Normal file
412
src/cron/cli.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cron CLI - Manage scheduled tasks
|
||||
*
|
||||
* Usage:
|
||||
* lettabot-schedule list
|
||||
* lettabot-schedule create --name "..." --schedule "..." --message "..."
|
||||
* lettabot-schedule delete <id>
|
||||
* lettabot-schedule enable <id>
|
||||
* lettabot-schedule disable <id>
|
||||
* lettabot-schedule show <id>
|
||||
* lettabot-schedule run <id>
|
||||
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Parse ISO datetime string
|
||||
function parseISODateTime(input: string): Date {
|
||||
const date = new Date(input);
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new Error(`Invalid datetime: ${input}. Use ISO format like "2026-01-28T20:15:00Z"`);
|
||||
}
|
||||
if (date.getTime() <= Date.now()) {
|
||||
console.warn(`Warning: "${input}" is in the past`);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
// Types
|
||||
interface CronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
schedule: { kind: 'cron'; expr: string } | { kind: 'at'; date: Date };
|
||||
message: string;
|
||||
deliver?: {
|
||||
channel: string;
|
||||
chatId: string;
|
||||
};
|
||||
deleteAfterRun?: boolean;
|
||||
state: {
|
||||
lastRunAt?: string;
|
||||
nextRunAt?: string;
|
||||
lastStatus?: 'ok' | 'error';
|
||||
lastError?: string;
|
||||
lastResponse?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CronStore {
|
||||
version: 1;
|
||||
jobs: CronJob[];
|
||||
}
|
||||
|
||||
// Store path
|
||||
const STORE_PATH = resolve(process.cwd(), 'cron-jobs.json');
|
||||
const LOG_PATH = resolve(process.cwd(), 'cron-log.jsonl');
|
||||
|
||||
function log(event: string, data: Record<string, unknown>): void {
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
event,
|
||||
...data,
|
||||
};
|
||||
|
||||
try {
|
||||
appendFileSync(LOG_PATH, JSON.stringify(entry) + '\n');
|
||||
} catch {
|
||||
// Ignore log errors
|
||||
}
|
||||
|
||||
// Also print to stderr for visibility
|
||||
console.error(`[Cron] ${event}: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
function loadStore(): CronStore {
|
||||
try {
|
||||
if (existsSync(STORE_PATH)) {
|
||||
return JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load cron store:', e);
|
||||
}
|
||||
return { version: 1, jobs: [] };
|
||||
}
|
||||
|
||||
function saveStore(store: CronStore): void {
|
||||
writeFileSync(STORE_PATH, JSON.stringify(store, null, 2));
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `cron-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
// Commands
|
||||
|
||||
function listJobs(): void {
|
||||
const store = loadStore();
|
||||
|
||||
if (store.jobs.length === 0) {
|
||||
console.log('\nNo scheduled tasks.\n');
|
||||
console.log('Create one with:');
|
||||
console.log(' lettabot-schedule create --name "My Task" --schedule "0 9 * * *" --message "Hello!"');
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = store.jobs.filter(j => j.enabled).length;
|
||||
const disabled = store.jobs.length - enabled;
|
||||
|
||||
console.log(`\n📅 Scheduled Tasks: ${enabled} active, ${disabled} disabled\n`);
|
||||
|
||||
for (const job of store.jobs) {
|
||||
const status = job.enabled ? '✓' : '○';
|
||||
const schedule = job.schedule.kind === 'cron'
|
||||
? job.schedule.expr
|
||||
: job.schedule.kind === 'at'
|
||||
? `at ${new Date(job.schedule.date).toLocaleString()}`
|
||||
: '?';
|
||||
const nextRun = job.state.nextRunAt ? formatDate(job.state.nextRunAt) : (job.enabled ? 'pending...' : 'disabled');
|
||||
|
||||
console.log(`${status} ${job.name} [${schedule}]`);
|
||||
console.log(` ID: ${job.id}`);
|
||||
console.log(` Next: ${nextRun}`);
|
||||
if (job.state.lastRunAt) {
|
||||
console.log(` Last: ${formatDate(job.state.lastRunAt)} (${job.state.lastStatus})`);
|
||||
}
|
||||
if (job.state.lastStatus === 'error' && job.state.lastError) {
|
||||
console.log(` ⚠ Error: ${job.state.lastError}`);
|
||||
}
|
||||
if (job.deliver) {
|
||||
console.log(` Deliver: ${job.deliver.channel}:${job.deliver.chatId}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function createJob(args: string[]): void {
|
||||
let name = '';
|
||||
let schedule = '';
|
||||
let at = ''; // One-off timer: ISO datetime or relative (e.g., "5m", "1h")
|
||||
let message = '';
|
||||
let enabled = true;
|
||||
let deliverChannel = '';
|
||||
let deliverChatId = '';
|
||||
|
||||
// Parse args
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
const next = args[i + 1];
|
||||
|
||||
if ((arg === '--name' || arg === '-n') && next) {
|
||||
name = next;
|
||||
i++;
|
||||
} else if ((arg === '--schedule' || arg === '-s') && next) {
|
||||
schedule = next;
|
||||
i++;
|
||||
} else if ((arg === '--at' || arg === '-a') && next) {
|
||||
at = next;
|
||||
i++;
|
||||
} else if ((arg === '--message' || arg === '-m') && next) {
|
||||
message = next;
|
||||
i++;
|
||||
} else if (arg === '--disabled') {
|
||||
enabled = false;
|
||||
} else if ((arg === '--deliver' || arg === '-d') && next) {
|
||||
// Format: channel:chatId (e.g., telegram:123456789)
|
||||
const [ch, id] = next.split(':');
|
||||
deliverChannel = ch;
|
||||
deliverChatId = id;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!name || (!schedule && !at) || !message) {
|
||||
console.error('Error: --name, (--schedule or --at), and --message are required');
|
||||
console.error('');
|
||||
console.error('Usage:');
|
||||
console.error(' # Recurring schedule (cron expression)');
|
||||
console.error(' lettabot-schedule create --name "Daily" --schedule "0 9 * * *" --message "Hello!"');
|
||||
console.error('');
|
||||
console.error(' # One-off reminder (ISO datetime)');
|
||||
console.error(' lettabot-schedule create --name "Reminder" --at "2026-01-28T20:15:00Z" --message "Stand up!"');
|
||||
console.error('');
|
||||
console.error('To calculate ISO datetime for "X minutes from now":');
|
||||
console.error(' new Date(Date.now() + X*60*1000).toISOString()');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = loadStore();
|
||||
|
||||
// Parse schedule type
|
||||
let cronSchedule: CronJob['schedule'];
|
||||
let deleteAfterRun = false;
|
||||
|
||||
if (at) {
|
||||
// One-off reminder at specific datetime
|
||||
const date = parseISODateTime(at);
|
||||
cronSchedule = { kind: 'at', date };
|
||||
deleteAfterRun = true;
|
||||
console.log(`⏰ One-off reminder set for: ${date.toISOString()} (${date.toLocaleString()})`);
|
||||
} else {
|
||||
// Recurring cron
|
||||
cronSchedule = { kind: 'cron', expr: schedule };
|
||||
}
|
||||
|
||||
const job: CronJob = {
|
||||
id: generateId(),
|
||||
name,
|
||||
enabled,
|
||||
schedule: cronSchedule,
|
||||
message,
|
||||
deliver: deliverChannel && deliverChatId ? { channel: deliverChannel, chatId: deliverChatId } : undefined,
|
||||
deleteAfterRun,
|
||||
state: {},
|
||||
};
|
||||
|
||||
store.jobs.push(job);
|
||||
saveStore(store);
|
||||
|
||||
log('job_created', { id: job.id, name, schedule, enabled });
|
||||
|
||||
console.log(`\n✓ Created "${name}"`);
|
||||
console.log(` ID: ${job.id}`);
|
||||
console.log(` Schedule: ${schedule}`);
|
||||
if (enabled) {
|
||||
console.log(` Status: Scheduling now...`);
|
||||
} else {
|
||||
console.log(` Status: Disabled (use 'lettabot-schedule enable ${job.id}' to activate)`);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteJob(id: string): void {
|
||||
const store = loadStore();
|
||||
const index = store.jobs.findIndex(j => j.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
console.error(`Error: Job not found: ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = store.jobs[index];
|
||||
store.jobs.splice(index, 1);
|
||||
saveStore(store);
|
||||
|
||||
log('job_deleted', { id, name: job.name });
|
||||
|
||||
console.log(`✓ Deleted "${job.name}"`);
|
||||
}
|
||||
|
||||
function enableJob(id: string): void {
|
||||
const store = loadStore();
|
||||
const job = store.jobs.find(j => j.id === id);
|
||||
|
||||
if (!job) {
|
||||
console.error(`Error: Job not found: ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
job.enabled = true;
|
||||
saveStore(store);
|
||||
|
||||
log('job_enabled', { id, name: job.name });
|
||||
|
||||
console.log(`✓ Enabled "${job.name}" - scheduling now...`);
|
||||
}
|
||||
|
||||
function disableJob(id: string): void {
|
||||
const store = loadStore();
|
||||
const job = store.jobs.find(j => j.id === id);
|
||||
|
||||
if (!job) {
|
||||
console.error(`Error: Job not found: ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
job.enabled = false;
|
||||
saveStore(store);
|
||||
|
||||
log('job_disabled', { id, name: job.name });
|
||||
|
||||
console.log(`✓ Disabled "${job.name}"`);
|
||||
}
|
||||
|
||||
function showJob(id: string): void {
|
||||
const store = loadStore();
|
||||
const job = store.jobs.find(j => j.id === id);
|
||||
|
||||
if (!job) {
|
||||
console.error(`Error: Job not found: ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n📅 ${job.name}\n`);
|
||||
console.log(`ID: ${job.id}`);
|
||||
console.log(`Enabled: ${job.enabled}`);
|
||||
console.log(`Schedule: ${job.schedule.kind === 'cron' ? job.schedule.expr : JSON.stringify(job.schedule)}`);
|
||||
console.log(`Message:\n ${job.message}`);
|
||||
console.log(`\nState:`);
|
||||
console.log(` Last run: ${formatDate(job.state.lastRunAt)}`);
|
||||
console.log(` Next run: ${formatDate(job.state.nextRunAt)}`);
|
||||
console.log(` Last status: ${job.state.lastStatus || '-'}`);
|
||||
if (job.state.lastError) {
|
||||
console.log(` Last error: ${job.state.lastError}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function showHelp(): void {
|
||||
console.log(`
|
||||
lettabot-schedule - Manage scheduled tasks and reminders
|
||||
|
||||
Commands:
|
||||
list List all scheduled tasks
|
||||
create [options] Create a new task
|
||||
delete <id> Delete a task
|
||||
enable <id> Enable a task
|
||||
disable <id> Disable a task
|
||||
show <id> Show task details
|
||||
|
||||
Create options:
|
||||
--name, -n <name> Task name (required)
|
||||
--schedule, -s <cron> Cron expression for recurring tasks
|
||||
--at, -a <datetime> ISO datetime for one-off reminder (auto-deletes after)
|
||||
--message, -m <msg> Message to send (required)
|
||||
--deliver, -d <target> Delivery target (channel:chatId)
|
||||
--disabled Create in disabled state
|
||||
|
||||
Examples:
|
||||
# One-off reminder (calculate ISO: new Date(Date.now() + 5*60*1000).toISOString())
|
||||
lettabot-schedule create -n "Standup" --at "2026-01-28T20:15:00Z" -m "Time to stand!"
|
||||
|
||||
# Recurring daily at 8am
|
||||
lettabot-schedule create -n "Morning" -s "0 8 * * *" -m "Good morning!"
|
||||
|
||||
# List and delete
|
||||
lettabot-schedule list
|
||||
lettabot-schedule delete job-1234567890-abc123
|
||||
`);
|
||||
}
|
||||
|
||||
// Main
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
switch (command) {
|
||||
case 'list':
|
||||
case 'ls':
|
||||
listJobs();
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
case 'add':
|
||||
createJob(args.slice(1));
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
case 'rm':
|
||||
case 'remove':
|
||||
if (!args[1]) {
|
||||
console.error('Error: Job ID required');
|
||||
process.exit(1);
|
||||
}
|
||||
deleteJob(args[1]);
|
||||
break;
|
||||
|
||||
case 'enable':
|
||||
if (!args[1]) {
|
||||
console.error('Error: Job ID required');
|
||||
process.exit(1);
|
||||
}
|
||||
enableJob(args[1]);
|
||||
break;
|
||||
|
||||
case 'disable':
|
||||
if (!args[1]) {
|
||||
console.error('Error: Job ID required');
|
||||
process.exit(1);
|
||||
}
|
||||
disableJob(args[1]);
|
||||
break;
|
||||
|
||||
case 'show':
|
||||
case 'get':
|
||||
if (!args[1]) {
|
||||
console.error('Error: Job ID required');
|
||||
process.exit(1);
|
||||
}
|
||||
showJob(args[1]);
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
showHelp();
|
||||
break;
|
||||
|
||||
default:
|
||||
if (command) {
|
||||
console.error(`Unknown command: ${command}`);
|
||||
}
|
||||
showHelp();
|
||||
break;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user