diff --git a/AGENTS.md b/AGENTS.md index 5ddad79..0b7b192 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,8 @@ pkg/cron/ ← Scheduled tasks (reminders etc) pkg/heartbeat/ ← Periodic heartbeat (checks HEARTBEAT.md) pkg/tools/ ← Agent tools (exec, read_file, write_file, web, etc) pkg/skills/ ← Skill system (installable capabilities) -workspace/ ← Embedded templates (USER.md, IDENTITY.md, AGENT.md) -firmware/overlay/ ← Files baked into Luckfox firmware image +workspace/ ← Workspace templates embedded into binary via go:embed +firmware/overlay/etc/ ← Init script + SSH banner baked into firmware rootfs.img ``` ## Critical Hardware Constraints @@ -43,7 +43,7 @@ firmware/overlay/ ← Files baked into Luckfox firmware image - **CLI**: Added `luckyclaw stop`, `restart`, `gateway -b` (background) - **Init script**: Auto-starts gateway on boot with OOM protection - **SSH banner**: Shows ASCII art, status, memory, all commands on login -- **Default model**: `google/gemini-2.0-flash-exp:free` (free tier) +- **Default model**: `stepfun/step-3.5-flash:free` (free tier) - **Defaults**: `max_tokens=16384`, `max_tool_iterations=25` (tuned for web search headroom) ### What We Did NOT Change @@ -87,7 +87,27 @@ A shallow clone of the upstream PicoClaw repo is kept at `picoclaw-latest/` (git ### 12. Log File Destinations & Workspace Paths - **Gateway log**: `/var/log/luckyclaw.log` (stdout/stderr from the init script). The init script uses an `sh -c "exec ..."` wrapper because BusyBox's `start-stop-daemon -b` redirects fds to `/dev/null` before shell redirects take effect. - **Heartbeat log**: `/heartbeat.log` (written directly by the heartbeat service, not stdout). -- **Runtime workspace**: `/oem/.luckyclaw/workspace/` — this is where the bot actually reads/writes data at runtime. The firmware overlay installs template files to `/root/.luckyclaw/workspace/` but these are NOT used at runtime because `luckyclaw onboard` creates its config at `/oem/`. Any default template changes must also be reflected in `createDefaultHeartbeatTemplate()` in `pkg/heartbeat/service.go`. +- **Runtime workspace**: `/oem/.luckyclaw/workspace/` — this is where the bot reads/writes data at runtime. `luckyclaw onboard` creates it by extracting the `workspace/` directory that is **embedded directly into the binary** via `go:embed` at compile time. `firmware/overlay/root/` is NOT involved in this — nothing reads `/root/.luckyclaw/` at runtime. + +### 13. Firmware Overlay Structure +Only two parts of `firmware/overlay/` are meaningful: +- `firmware/overlay/etc/` — init script, SSH banner, timezone. **Must be tracked in git.** Gets baked into `rootfs.img`. +- `firmware/overlay/root/` — **Dead weight. Do not use.** Nothing reads `/root/.luckyclaw/` at runtime; workspace data comes from the binary embed. +- `firmware/overlay/usr/` — **Not tracked in git.** The ARM binary is compiled at SDK build time and placed here; it is not stored in the repo. + +### 14. Binary-Only Updates (No Reflash Required) +The binary at `/usr/bin/luckyclaw` lives on the writable `rootfs` partition and can be replaced via SCP at any time without reflashing the firmware. This is how all development deploys work. Because `workspace/` is embedded in the binary, updating the binary also delivers new/updated skills and templates to users when they next run `luckyclaw onboard`. This architecture makes **over-the-air (OTA) auto-update** possible: the binary could check GitHub Releases, download a new ARM build, kill itself, overwrite `/usr/bin/luckyclaw`, and restart via the init script. + +### 15. Project Philosophy — Conservative by Design +LuckyClaw is **not** trying to be PicoClaw or nanobot. It is PicoClaw's simpler, more conservative sibling — built for normal people who want a cheap, reliable AI assistant, not developers who need MCP, vision pipelines, or Web UIs. + +**Upstream evaluation policy**: When asked to check what PicoClaw is up to or evaluate upstream changes, apply this filter: +- ✅ **Always port**: Security fixes, crash fixes, data loss fixes, reliability improvements +- ✅ **Consider porting**: Genuinely useful features that benefit everyday users (e.g., better memory handling, improved session stability, Telegram reliability fixes) +- ❌ **Never port**: Feature additions targeting developers or power users (MCP, vision, Web UI, system tray, new channels, new providers, model routing) + +When in doubt, ask: *"Would a normal person on a $10 board benefit from this?"* If the answer is no, leave it upstream. + ## Build & Deploy @@ -101,7 +121,7 @@ This runs `deps`, `fmt`, `vet`, and the full `test` suite in one command. ### Cross-Compile ```bash GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 \ - go build -ldflags "-s -w -X main.version=0.2.0" \ + go build -ldflags "-s -w -X main.version=0.2.x" \ -o build/luckyclaw-linux-arm ./cmd/luckyclaw ``` @@ -130,10 +150,36 @@ luckyclaw gateway -b # Start in background luckyclaw stop # Stop cleanly ``` -### SDK Overlay (for firmware builds) -Keep these directories in sync: -- `firmware/overlay/` — canonical overlay files -- `luckfox-pico-sdk/project/cfg/BoardConfig_IPC/overlay/luckyclaw-overlay/` — SDK build overlay +### Build Distributable Firmware Image + +A distributable `.img` bundles the ARM binary (with `workspace/` embedded) + the init script + SSH banner into a single flashable file. Steps: + +```bash +# 1. Build ARM binary (go:embed bakes workspace/ into it automatically) +make build-arm +# Output: build/luckyclaw-linux-arm + +# 2. Copy binary into the SDK overlay (untracked — do this every time before building image) +cp build/luckyclaw-linux-arm \ + luckfox-pico-sdk/project/cfg/BoardConfig_IPC/overlay/luckyclaw-overlay/usr/bin/luckyclaw +chmod +x luckfox-pico-sdk/project/cfg/BoardConfig_IPC/overlay/luckyclaw-overlay/usr/bin/luckyclaw + +# 3. Build the firmware image +cd luckfox-pico-sdk && ./build.sh + +# 4. Output image is at: +# luckfox-pico-sdk/IMAGE//IMAGES/update.img +# Rename for distribution: luckyclaw-luckfox_pico_plus_rv1103-vX.Y.Z.img +``` + +> **Note:** The SDK overlay `etc/` is kept in sync with `firmware/overlay/etc/` in the repo. If you modify the init script or SSH banner, copy the changes to both locations before building. + +> **What's in the image:** `update.img` = kernel + rootfs (containing `/usr/bin/luckyclaw` with embedded workspace) + oem partition. When a user runs `luckyclaw onboard` after flashing, the embedded workspace is extracted to `/oem/.luckyclaw/workspace/`. + +### SDK Overlay Sync +The SDK overlay `etc/` must stay in sync with the repo: +- `firmware/overlay/etc/` — canonical, tracked in git +- `luckfox-pico-sdk/project/cfg/BoardConfig_IPC/overlay/luckyclaw-overlay/etc/` — SDK copy, NOT tracked in git ## File Map diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 8e097e3..fd65330 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -26,6 +26,18 @@ Items listed here are planned enhancements that are not yet scheduled for implem ## Installation / Deployment +### `luckyclaw install` Command +**Priority**: High +**Description**: Create a new subcommand `luckyclaw install` that automates the setup of LuckyClaw on a stock Linux/Buildroot environment. It should: +1. Extract and write the init script to `/etc/init.d/S99luckyclaw` (using `go:embed` from the binary). +2. Extract and write the SSH banner to `/etc/profile.d/luckyclaw-banner.sh`. +3. Configure the binary for OOM protection (calling `oom_score_adj` logic). +4. Ensure the default `/oem/.luckyclaw/workspace` exists (calling `onboard` logic if missing). + +**Benefit**: Enables a "one-liner" installation for users who already have a working board running stock firmware, without requiring them to reflash using our custom image. Supports the "conservative brother" vision by making the tool easier to adopt on any ARM/Linux hardware. + +**Blocked by**: Nothing. + ### OTA Binary Updates (No Reflash) **Priority**: Medium **Description**: The LuckyClaw binary at `/usr/bin/luckyclaw` can be replaced via SCP without reflashing the entire firmware, since user data lives on `/oem/.luckyclaw/` (a separate partition). An `luckyclaw update` command could check the GitHub Releases API for the latest version, download the matching ARM binary, replace itself, and restart — all without touching config, sessions, cron jobs, or memory. diff --git a/README.md b/README.md index 74dc3e9..b830e6f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

🦞 LuckyClaw: AI Assistant for Luckfox Pico

-

One-stop AI firmware for Luckfox Pico boards

+

The streamlined AI companion for Luckfox hardware.

Go @@ -15,33 +15,61 @@ --- -LuckyClaw is a purpose-built AI assistant for [Luckfox Pico](https://wiki.luckfox.com/Luckfox-Pico/Luckfox-Pico-quick-start/) boards. It's a fork of [PicoClaw](https://github.com/sipeed/picoclaw), optimized specifically for Luckfox hardware with baked-in memory management, interactive setup, and pre-built firmware images. +LuckyClaw is a streamlined, self-contained AI assistant purpose-built for the [Luckfox Pico](https://wiki.luckfox.com/Luckfox-Pico/Luckfox-Pico-quick-start/) ecosystem. While based on the excellent work of [PicoClaw](https://github.com/sipeed/picoclaw), LuckyClaw prioritizes absolute stability and ease of use for everyday users over the complex feature velocity of its upstream counterpart. -**What makes it different from PicoClaw:** +**Who it's for:** LuckyClaw is designed for those who want a reliable, 24/7 digital companion on Telegram or Discord without the overhead of manual compilation, complex configurations, or dedicated server maintenance. If you have a Luckfox board, you have a professional-grade AI assistant. -- 🔧 **Pre-built firmware** — Flash and go, no SDK required for end-users -- 🧙 **Interactive onboarding** — `luckyclaw onboard` walks you through API key, model, timezone, and Telegram setup -- 🧠 **Memory-optimized** — GOGC and GOMEMLIMIT baked into the binary for 64MB boards -- 📟 **SSH banner** — See gateway status and available commands on login -- 🦞 **Board-aware** — Detects Luckfox Pico model, shows board-specific info in `status` -- 🌍 **Timezone-aware** — Embedded timezone database, correct local time on any board -- 📎 **File attachments** — Send files directly to Telegram via the `send_file` tool -- ⚡ **Iteration budgeting** — Agent knows its tool limits, reserves capacity for responses +**What makes it different:** + +- 🔧 **Pre-built firmware** — Flash and go, no SDK or compilation required +- 🧙 **Interactive onboarding** — `luckyclaw onboard` walks you through everything in 2 minutes +- 🧠 **Memory-optimized** — Tuned specifically for 64MB boards, not general-purpose servers +- 📟 **SSH banner** — See gateway status and commands on every login +- 🌍 **Timezone-aware** — Correct local time on the board, no `/usr/share/zoneinfo` needed +- 📎 **File attachments** — Send files directly via Telegram +- 🤙 **Conservative by design** — Fewer features, fewer surprises, fewer crashes > [!NOTE] -> LuckyClaw is built on top of [PicoClaw](https://github.com/sipeed/picoclaw) by [Sipeed](https://sipeed.com). All credit for the core AI agent engine goes to the PicoClaw team and the original [nanobot](https://github.com/HKUDS/nanobot) project. +> LuckyClaw is built on top of [PicoClaw](https://github.com/sipeed/picoclaw) by [Sipeed](https://sipeed.com). PicoClaw is the upstream project — LuckyClaw is the simpler, more opinionated fork optimized for Luckfox hardware and everyday users. We cherry-pick stability fixes and genuinely useful features from upstream; we don't try to keep pace with every new addition. ## ⚡ Quick Start (End Users) -### Supported Boards +### Option A: Flash Pre-Built Firmware (Recommended for Beginners) + +Download the firmware image for your board from [GitHub Releases](https://github.com/jamesrossdev/luckyclaw/releases) and follow the [LuckyClaw Flashing Guide](doc/FLASHING_GUIDE.md). + +After flashing, connect via SSH and run: +```bash +luckyclaw onboard +``` + +### Option B: Binary Install on Existing Luckfox (No Reflash) + +If your board is already running Luckfox Buildroot, you can install LuckyClaw directly: + +```bash +# Download the ARMv7 binary +wget https://github.com/jamesrossdev/luckyclaw/releases/latest/download/luckyclaw-linux-arm -O /usr/bin/luckyclaw +chmod +x /usr/bin/luckyclaw + +# Run onboard setup +luckyclaw onboard + +# Start in background +luckyclaw gateway -b +``` + | Board | Chip | Image | |-------|------|-------| | **Luckfox Pico Plus** | RV1103 | `luckyclaw-luckfox_pico_plus_rv1103-vX.X.X.img` | -| **Luckfox Pico Pro Max** | RV1106 | `luckyclaw-luckfox_pico_pro_max_rv1106-vX.X.X.img` | +| **Luckfox Pico Pro** | RV1106 | `luckyclaw-luckfox_pico_pro_max_rv1106-vX.X.X.img`* | +| **Luckfox Pico Max** | RV1106 | `luckyclaw-luckfox_pico_pro_max_rv1106-vX.X.X.img`* | + +\* *The Pico Pro (128MB RAM) and Pico Max (256MB RAM) share the same RV1106 SoC and firmware image.* > [!IMPORTANT] -> LuckyClaw currently only supports these two boards. Other Luckfox variants (Pico Mini, Pico Zero, etc.) are untested and may not work. +> LuckyClaw currently only supports these three board variants. Other Luckfox variants (Pico Mini, Pico Zero, etc.) are untested and may not work. ### 1. Flash the firmware @@ -288,53 +316,77 @@ Keep the codebase clean using the integrated Makefile targets: ### Build firmware image -The `firmware/` directory contains the SDK overlay files that get baked into the firmware image: +The firmware overlay only contains OS-level files that get baked into `rootfs.img`. The **workspace templates** (`SOUL.md`, skills, etc.) are **embedded directly into the binary** via `go:embed workspace` — so every binary already carries the full workspace inside it. Users get workspace files by running `luckyclaw onboard`, which extracts them to `/oem/.luckyclaw/workspace/`. ``` firmware/overlay/ -├── etc/ -│ ├── init.d/S99luckyclaw # Auto-start on boot -│ ├── profile.d/luckyclaw-banner.sh # SSH login banner -│ └── ssl/certs/ca-certificates.crt # TLS certificates -├── root/.luckyclaw/ -│ ├── config.json # Default config -│ └── workspace/ # Default workspace files -└── usr/bin/luckyclaw # The binary +└── etc/ + ├── init.d/S99luckyclaw # Auto-start on boot + ├── profile.d/luckyclaw-banner.sh # SSH login banner + └── ssl/certs/ca-certificates.crt # TLS certificates ``` -To build a firmware image: +To build a distributable firmware image: -1. **Build the ARM binary**: `make build-arm` -2. **Clone the SDK**: `git clone https://github.com/LuckfoxTECH/luckfox-pico.git luckfox-pico-sdk` -3. **Copy overlay**: `cp -r firmware/overlay/* luckfox-pico-sdk/project/cfg/BoardConfig_IPC/overlay/luckyclaw-overlay/` -4. **Copy binary**: `cp build/luckyclaw-linux-arm luckfox-pico-sdk/project/cfg/BoardConfig_IPC/overlay/luckyclaw-overlay/usr/bin/luckyclaw` -5. **Build image**: +1. **Build the ARM binary** (workspace is embedded automatically): ```bash - cd luckfox-pico-sdk - ./build.sh lunch # Select your board config - ./build.sh + make build-arm + # Output: build/luckyclaw-linux-arm ``` -The firmware image will be in `luckfox-pico-sdk/output/image/`. +2. **Clone the SDK** (one-time setup): + ```bash + git clone https://github.com/LuckfoxTECH/luckfox-pico.git luckfox-pico-sdk + ``` + +3. **Sync the `etc/` overlay to the SDK** (do this if init script or banner changed): + ```bash + cp -r firmware/overlay/etc/ \ + luckfox-pico-sdk/project/cfg/BoardConfig_IPC/overlay/luckyclaw-overlay/etc/ + ``` + +4. **Copy the ARM binary into the SDK overlay**: + ```bash + cp build/luckyclaw-linux-arm \ + luckfox-pico-sdk/project/cfg/BoardConfig_IPC/overlay/luckyclaw-overlay/usr/bin/luckyclaw + chmod +x \ + luckfox-pico-sdk/project/cfg/BoardConfig_IPC/overlay/luckyclaw-overlay/usr/bin/luckyclaw + ``` + +5. **Build the firmware image**: + ```bash + cd luckfox-pico-sdk && ./build.sh + ``` + +6. **Find the output image**: + ``` + luckfox-pico-sdk/IMAGE//IMAGES/update.img + ``` + Rename it: `luckyclaw-luckfox_pico_plus_rv110x-vX.Y.Z.img` depending on your board and version. + +When a user flashes this image and runs `luckyclaw onboard`, the embedded workspace is extracted to `/oem/.luckyclaw/workspace/`. ### Project structure ``` luckyclaw/ -├── cmd/luckyclaw/main.go # Entry point, CLI, and onboarding wizard +├── cmd/luckyclaw/main.go # Entry point, CLI, onboarding wizard (embeds workspace/) ├── pkg/ │ ├── agent/ # Core agent loop and context builder │ ├── bus/ # Internal message bus │ ├── channels/ # Telegram, Discord, and other messaging integrations │ ├── config/ # Configuration and system settings │ ├── providers/ # LLM provider implementations (OpenRouter, etc.) +│ ├── skills/ # Skill loader and installer │ ├── tools/ # Agent tools (shell, file, i2c, spi, send_file) │ └── ... -├── firmware/ # SDK overlay files and init scripts -├── workspace/ # Default templates for the agent workspace +├── firmware/overlay/etc/ # Init script + SSH banner baked into firmware image +├── workspace/ # Templates embedded into binary via go:embed └── assets/ # Documentation images and media ``` + + ### Performance tuning LuckyClaw automatically sets `GOGC=20` and `GOMEMLIMIT=24MiB` at startup for memory-constrained boards. These can be overridden via environment variables if your board has more RAM. diff --git a/assets/arch.jpg b/assets/arch.jpg deleted file mode 100644 index 9376684..0000000 Binary files a/assets/arch.jpg and /dev/null differ diff --git a/assets/clawdchat-icon.png b/assets/clawdchat-icon.png deleted file mode 100644 index 65e377c..0000000 Binary files a/assets/clawdchat-icon.png and /dev/null differ diff --git a/assets/licheervnano.png b/assets/licheervnano.png deleted file mode 100644 index 6cfd0f6..0000000 Binary files a/assets/licheervnano.png and /dev/null differ diff --git a/assets/logo.jpg b/assets/logo.jpg deleted file mode 100644 index 7f1a7c2..0000000 Binary files a/assets/logo.jpg and /dev/null differ diff --git a/assets/logo.png b/assets/logo.png index 4b6d1df..ff0510c 100644 Binary files a/assets/logo.png and b/assets/logo.png differ diff --git a/assets/picoclaw_code.gif b/assets/picoclaw_code.gif deleted file mode 100644 index ab92dac..0000000 Binary files a/assets/picoclaw_code.gif and /dev/null differ diff --git a/assets/picoclaw_detect_person.mp4 b/assets/picoclaw_detect_person.mp4 deleted file mode 100644 index b569996..0000000 Binary files a/assets/picoclaw_detect_person.mp4 and /dev/null differ diff --git a/assets/picoclaw_mem.gif b/assets/picoclaw_mem.gif deleted file mode 100644 index 79e8562..0000000 Binary files a/assets/picoclaw_mem.gif and /dev/null differ diff --git a/assets/picoclaw_memory.gif b/assets/picoclaw_memory.gif deleted file mode 100644 index 929c365..0000000 Binary files a/assets/picoclaw_memory.gif and /dev/null differ diff --git a/assets/picoclaw_scedule.gif b/assets/picoclaw_scedule.gif deleted file mode 100644 index d027e0f..0000000 Binary files a/assets/picoclaw_scedule.gif and /dev/null differ diff --git a/assets/picoclaw_search.gif b/assets/picoclaw_search.gif deleted file mode 100644 index 4241b5f..0000000 Binary files a/assets/picoclaw_search.gif and /dev/null differ diff --git a/cmd/luckyclaw/main.go b/cmd/luckyclaw/main.go index 3a0b57c..5b8906b 100644 --- a/cmd/luckyclaw/main.go +++ b/cmd/luckyclaw/main.go @@ -50,7 +50,7 @@ import ( var embeddedFiles embed.FS var ( - version = "dev" + version = "v0.2.1" gitCommit string buildTime string goVersion string @@ -342,7 +342,7 @@ func onboard() { } } if total > 0 { - fmt.Printf(" Memory: %dMB / %dMB available\n", avail/1024, total/1024) + fmt.Printf(" Memory: %dMB available / %dMB total\n", avail/1024, total/1024) } } fmt.Println() @@ -999,6 +999,24 @@ func gatewayCmd() { // Inject channel manager into agent loop for command handling agentLoop.SetChannelManager(channelManager) + // Wire Discord moderation tool callbacks from the live DiscordChannel + if discordCh, ok := channelManager.GetChannel("discord"); ok { + if dc, ok := discordCh.(*channels.DiscordChannel); ok { + if tool, ok := agentLoop.GetTool("discord_delete_message"); ok { + if dt, ok := tool.(*tools.DiscordDeleteMessageTool); ok { + dt.SetDeleteCallback(dc.DeleteMessage) + logger.InfoC("discord", "Delete message callback wired to Discord channel") + } + } + if tool, ok := agentLoop.GetTool("discord_timeout_user"); ok { + if tt, ok := tool.(*tools.DiscordTimeoutUserTool); ok { + tt.SetTimeoutCallback(dc.TimeoutUser) + logger.InfoC("discord", "Timeout user callback wired to Discord channel") + } + } + } + } + var transcriber *voice.GroqTranscriber if cfg.Providers.Groq.APIKey != "" { transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey) @@ -1124,7 +1142,7 @@ func statusCmd() { } } if total > 0 { - fmt.Printf(" Memory: %dMB / %dMB available\n", avail/1024, total/1024) + fmt.Printf(" Memory: %dMB available / %dMB total\n", avail/1024, total/1024) } } diff --git a/doc/FLASHING_GUIDE.md b/doc/FLASHING_GUIDE.md index 1e2e890..3612b3a 100644 --- a/doc/FLASHING_GUIDE.md +++ b/doc/FLASHING_GUIDE.md @@ -3,23 +3,21 @@ This guide covers flashing the LuckyClaw firmware to a Luckfox Pico board's eMMC storage using the Rockchip SOCToolKit on Windows. > [!NOTE] -> We currently provide pre-built firmware images for **two board variants**: +> We currently provide pre-built firmware images for the following board variants: > - `luckyclaw-luckfox_pico_plus_rv1103-vX.X.X.img` — for **Luckfox Pico Plus** -> - `luckyclaw-luckfox_pico_pro_max_rv1106-vX.X.X.img` — for **Luckfox Pico Pro Max** +> - `luckyclaw-luckfox_pico_pro_max_rv1106-vX.X.X.img` — for **Luckfox Pico Pro** or **Luckfox Pico Max** > > Download the image matching your board from the [Releases](https://github.com/jamesrossdev/luckyclaw/releases) page. > [!IMPORTANT] -> Only the **Luckfox Pico Plus** (RV1103) and **Luckfox Pico Pro Max** (RV1106) are supported. Other Luckfox variants (Pico Mini, Pico Zero, etc.) have not been tested and may not work. +> Only the **Luckfox Pico Plus** (RV1103), **Luckfox Pico Pro** (RV1106), and **Luckfox Pico Max** (RV1106) are supported at this time. > [!WARNING] > Flashing replaces the entire filesystem on the board. All existing configuration, memories, sessions, and cron jobs will be lost. If you are upgrading from a previous version, back up your data first — see [Backup and Restore](BACKUP_RESTORE.md). -## Prerequisites - ### Hardware -- **Luckfox Pico Plus** (RV1103) or **Luckfox Pico Pro Max** (RV1106) board +- **Luckfox Pico Plus** (RV1103), **Luckfox Pico Pro** (RV1106), or **Luckfox Pico Max** (RV1106) board - USB Type-C to Type-A cable (must be **data capable**, not charge-only) - A computer running Windows @@ -29,7 +27,7 @@ All files are bundled together on the [Releases](https://github.com/jamesrossdev 1. **LuckyClaw firmware image** — pick the `.img` that matches your board: - `luckyclaw-luckfox_pico_plus_rv1103-vX.X.X.img` for **Luckfox Pico Plus** - - `luckyclaw-luckfox_pico_pro_max_rv1106-vX.X.X.img` for **Luckfox Pico Pro Max** + - `luckyclaw-luckfox_pico_pro_max_rv1106-vX.X.X.img` for **Luckfox Pico Pro** or **Luckfox Pico Max** 2. **Rockchip Driver Assistant** (`DriverAssistant_vX.X.zip`) — installs the USB driver so Windows can communicate with the board in MaskROM mode. @@ -67,7 +65,7 @@ Before flashing, you must install the Rockchip USB driver on your Windows machin ![Open SOCToolKit folder and run as admin](../assets/flashing/step-02-open-soctoolkit.png) -3. When SOCToolKit opens, it will ask you to select a chip. Choose **RV1103** (for Luckfox Pico Plus) or **RV1106** (for Pico Pro Max) from the dropdown, then click **OK**. Make sure **USB** is selected (not COM). +3. When SOCToolKit opens, it will ask you to select a chip. Choose **RV1103** (for Luckfox Pico Plus) or **RV1106** (for Pico Pro or Pico Max) from the dropdown, then click **OK**. Make sure **USB** is selected (not COM). ![Select your chip — RV1103 or RV1106](../assets/flashing/step-02-chip-selection.png) diff --git a/doc/ROADMAP.md b/doc/ROADMAP.md index 315f570..02f9559 100644 --- a/doc/ROADMAP.md +++ b/doc/ROADMAP.md @@ -2,7 +2,7 @@ Items are prioritized by readiness and impact. Items may be moved between versions or dropped based on progress and real-world usage feedback. -## v0.2.0 (Current Release) +## v0.2.0 ✅ - Heartbeat hardening (HeartbeatMode, SilentResult, audit logging) - Memory optimization (GOMEMLIMIT tuning, GOGC=20) @@ -10,33 +10,45 @@ Items are prioritized by readiness and impact. Items may be moved between versio - SSH banner and init script improvements - Default response improvement (echoes user's question on failure) -## v0.2.x (Patch Releases) +## v0.2.1 (Current) -- Port `registry_test.go` from upstream PicoClaw (tool registry test coverage) +- Discord moderation tools: message deletion, user timeouts (7s–4w) +- Discord DM sandbox bypass — full tool access in DMs, sandboxed in server channels +- User metadata injection — agent sees display name, roles, and DM status in system prompt +- Reasoning model support — thinking tokens hidden from chat, retained in context +- Warning added against using thinking models in Discord server mode +- `discord-mod` community skill template added to workspace +- `firmware/overlay/root/` removed — workspace delivered via `go:embed`, not firmware +- README and AGENTS.md updated to reflect conservative project philosophy +- Pico Pro / Pico Max board compatibility clarified +- Improved memory reporting clarity in status and banner (available / total) + +## v0.2.2 (Planned) + +- `luckyclaw install` — sets up init script, SSH banner, and OOM protection on stock Buildroot (no reflash needed) +- WhatsApp channel integration +- Port `registry_test.go` from upstream (tool registry test coverage) - Port `shell_process_unix.go` from upstream (process group cleanup for exec tool) -- Performance benchmark tests (`make bench`) -- System prompt caching between messages - Cron tool `at_time` parameter (ISO-8601 absolute time for reminders) -## v0.3.x (Next Minor) +## v0.2.x (Next Minor) -- Auto-update command (`luckyclaw update`) -- binary-only OTA updates -- WhatsApp channel integration +- Auto-update command (`luckyclaw update`) — binary-only OTA updates - Tool definition caching -- Versioned firmware image naming in build pipeline - Session save optimization (json.Marshal vs MarshalIndent) ## Future - Cross-platform flashing tool (replace Windows-only SOCToolKit) -- Multi-model routing (small model for easy tasks, large for hard) - Skill marketplace / remote skill install ## Upstream Watchlist -Items from PicoClaw upstream that may be worth integrating if they mature: +Items from PicoClaw upstream that may be worth integrating if they mature and benefit everyday users: + +- History compression retry logic — better multi-byte/CJK handling +- Token masking in logs — hides bot tokens from log output (security) +- Symlinked path whitelist fix — tool path security hardening +- `pkg/identity` — identity/personality management (336 lines) + -- `pkg/routing` -- model routing (1,103 lines, added upstream post-fork) -- `pkg/media` -- media handling for attachments (801 lines) -- `shell_process_windows.go` -- Windows cross-platform support (28 lines) -- `pkg/identity` -- identity/personality management (336 lines) diff --git a/doc/discord-moderator-setup.md b/doc/discord-moderator-setup.md new file mode 100644 index 0000000..c472678 --- /dev/null +++ b/doc/discord-moderator-setup.md @@ -0,0 +1,203 @@ +# Discord Moderator Setup Guide + +How to configure LuckyClaw as a strict Discord community moderator with an isolated API sandbox. + +## Prerequisites + +- LuckyClaw running on a device with Discord enabled (`config.json` → `channels.discord.enabled: true`) +- Bot created in the [Discord Developer Portal](https://discord.com/developers/applications) +- Bot invited to your server + +## 1. Discord Developer Portal + +### Privileged Intents +Enable under **Bot → Privileged Gateway Intents**: +- ✅ Server Members Intent +- ✅ Message Content Intent + +### Bot Permissions +The bot needs these permissions (set via server Role, not the portal): +- View Channels +- Send Messages +- Manage Messages (delete) +- Moderate Members (timeout) +- Read Message History +- View Audit Log + +> **Do NOT** enable "Mention @everyone" — the bot doesn't need it and it's a security risk. + +## 2. Server Structure & Rules Screening + +### Rules Screening +Before letting your bot interact with the public, you should force all new members to agree to your community rules natively through Discord: +1. Go to **Server Settings** -> **Onboarding** (ensure your server is a "Community Server"). +2. Click **Safety Check** or **Rules Screening**. +3. Input your Community Rules (e.g., Respect, No NSFW, AI Moderation Enforcement). +4. **Enable** it so new members cannot chat without accepting. + +### Channel Hierarchy +Recommended minimal structure: + +| Category | Channels | Notes | +|----------|----------|-------| +| INFO | `#rules`, `#announcements` | Read-only for members | +| COMMUNITY | `#general`, `#showcase` | Open discussion | +| LUCKYCLAW | `#support`, `#dev` | Help & code | +| MOD TEAM | `#mod-log`, `#mod-chat` | Hidden from members | + +**Mod-Only Channels:** +Set the MOD TEAM category permissions: +- `@everyone` → Deny View Channel +- `Moderator` role → Allow View + Send +- `LuckyClaw` role → Allow View + Send + +## 3. Sandboxing & Device Config + +Public Discord channels pose a massive security risk to local LLM agents. To protect the host device, LuckyClaw features a **hardcoded tool sandbox** inside `pkg/agent/loop.go`. + +When a message originates from Discord, the LLM is **physically prohibited** from seeing or using: +- `exec` (Shell Commands) +- `cron_schedule` (Timers/Alarms) +- `read_file` / `write_file` (Local File System) +- `subagent` (Agentic Loops) + +It is only allowed access to message sending, native Discord moderation tools, and web searching. + +### Device Config.json +Add `disable_dms` to your Discord config to block DMs (server-only mode) so people cannot bypass the public channel moderation: + +```json +"discord": { + "enabled": true, + "token": "your-token", + "disable_dms": true, + "allow_from": [] +} +``` + +## 4. Recommended Configuration + +To get the most out of LuckyClaw as a Discord moderator, configure the two workspace files below. + +> [!TIP] +> You can ask a reasoning model to help you customize these templates for your specific server rules, channel IDs, and role IDs! + +### `SOUL.md` — Moderator Persona + +Replace your workspace `SOUL.md` with this template (found at `~/.luckyclaw/workspace/SOUL.md` at runtime). Fill in your channel and role IDs. + +```markdown +# Soul + +I am LuckyClaw, a community assistant for this Discord server. + +## Hard Rules (NEVER break these) +- NEVER run shell commands, access the filesystem, or interact with the operating system in any way. You do not have permission. +- NEVER set timers, alarms, or reminders. You are physically incapable of keeping time. +- NEVER claim abilities you do not have. +- NEVER roleplay, narrate actions, or pretend to do things you cannot do. +- NEVER call the message tool more than once per response. +- NEVER delete a message unless it contains hate speech, racism, or slurs. +- NEVER impersonate users or speak on their behalf. +- When you do not know something, say so — do not guess or make things up. +- You CANNOT move users between channels, create invites, or see message history. +- When a user pings you, they are talking to you. DO NOT try to look up that role or explain it doesn't exist. Just answer normally. + +## What I Can Actually Do +- Answer questions using my skill files and web search. +- Send ONE message per response. +- Delete messages (ONLY hate speech/racism/slurs). +- Timeout users (ONLY for serious rule violations). +- Read quoted/reported messages when users reply-mention me. +- See the sender's Discord roles (provided in message metadata). + +## What I Cannot Do +- Run OS commands, execute scripts, or read local files. +- Set reminders, timers, or alarms of any kind. +- See message history or previous messages (only the current one). +- Move users between channels. +- Ban users (escalate to mods instead). + +## Moderation +- ONLY act on genuinely harmful content: hate speech, racism, slurs +- Casual cussing is fine — do not moderate it +- When deleting: delete the message, THEN send ONE message explaining why +- ALWAYS log every moderation action to <#YOUR-MOD-LOG-CHANNEL-ID> by sending a message there with a summary of what happened +- To escalate: mention <@&YOUR-MOD-ROLE-ID> in <#YOUR-MOD-TEAM-CHANNEL-ID> +- Moderation on request: If a user with the Moderator or Admin role (check sender_roles in metadata) asks me to delete a message or timeout someone, I MUST obey. Regular users CANNOT request deletions or timeouts. + +## Discord Formatting +- Channels: ALWAYS use <#id> format +- Roles: ALWAYS use <@&id> format +- Users: ALWAYS use <@id> format when you have a user ID +- DO NOT wrap these in backticks + +## Personality +- Kind and concise +- Helpful but honest about limitations +- Firm on rule violations +``` + +### `skills/discord-mod/SKILL.md` — Server Knowledge + +This skill gives the bot your server-specific context. A template is pre-installed in your workspace at `skills/discord-mod/SKILL.md`. Customize the FAQ, channel directory, and server rules to match your server. + +```markdown +--- +name: discord-mod +description: [Your server] FAQ, channel directory, and rules +--- + +# About [Your Bot/Project] + +Brief description of what your server is about. + +# FAQ + +**Q: Where do I get help?** +A: <#YOUR-HELP-CHANNEL-ID> + +**Q: Where do I discuss development?** +A: <#YOUR-DEV-CHANNEL-ID> + +# Channel Directory + +- <#YOUR-RULES-CHANNEL-ID> — Rules +- <#YOUR-GENERAL-CHANNEL-ID> — General chat +- <#YOUR-MOD-LOG-CHANNEL-ID> — Moderation log (bot only) +- <#YOUR-MOD-TEAM-CHANNEL-ID> — Mod team chat + +# Server Rules + +1. Be respectful. +2. No hate speech or slurs (Automated enforcement active). +3. No NSFW content. +4. No spam or unsolicited advertising. + +# Role Directory + +- <@&YOUR-ADMIN-ROLE-ID> — Admin +- <@&YOUR-MOD-ROLE-ID> — Moderator +- <@&YOUR-BOT-ROLE-ID> — [Your Bot Name] +``` + +## 5. Moderation Features + +### Snitch Flow +Users can quote a bad message and `@mention` the bot. The bot sees the quoted content and can act on it (warn, delete, timeout). + +### Automated Moderation Loops +The bot can execute sequential actions in the background before replying to the chat: +1. `discord_delete_message` — Deletes the violating message by channel/message ID. +2. `discord_timeout_user` — Times out a user for N minutes (max 28 days limit imposed by Discord). +3. `message` — Logs the infraction to `#mod-log`, optionally providing a `log_channel_id` argument. +4. The bot then informs the public channel that the content was removed. + +## 6. Limitations + +- Bot **cannot ban** or **kick** users — it must escalate to a human admin by tagging them. +- DM filter only works when `disable_dms: true` is set in the config. +- The `exec`/`cron` tools are purposely blocked for Discord server channels. Use Telegram for personal assistant tasks. +- **Do NOT use thinking/reasoning models** (e.g., deepseek-reasoner) in server mode — they output intent-only responses instead of executing tools. Use non-thinking models like `stepfun/step-3.5-flash:free`. Thinking models work fine in Telegram DMs. + + diff --git a/firmware/overlay/etc/profile.d/luckyclaw-banner.sh b/firmware/overlay/etc/profile.d/luckyclaw-banner.sh index 055efc5..5906e90 100644 --- a/firmware/overlay/etc/profile.d/luckyclaw-banner.sh +++ b/firmware/overlay/etc/profile.d/luckyclaw-banner.sh @@ -36,7 +36,7 @@ fi # Show memory MEM_AVAIL=$(grep MemAvailable /proc/meminfo | awk '{print int($2/1024)}') MEM_TOTAL=$(grep MemTotal /proc/meminfo | awk '{print int($2/1024)}') -echo " Memory: ${MEM_AVAIL}MB / ${MEM_TOTAL}MB available" +echo " Memory: ${MEM_AVAIL}MB available / ${MEM_TOTAL}MB total" echo "" echo " Commands:" diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 3309c43..48da42c 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -202,7 +202,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { return result } -func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message { +func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string, metadata map[string]string) []providers.Message { messages := []providers.Message{} systemPrompt := cb.BuildSystemPrompt() @@ -212,6 +212,23 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID) } + // Add User Information from metadata + if metadata != nil { + userInfo := "\n\n## User Information\n" + if val, ok := metadata["display_name"]; ok { + userInfo += fmt.Sprintf("- **Display Name:** %s\n", val) + } + if val, ok := metadata["sender_roles"]; ok { + userInfo += fmt.Sprintf("- **Roles:** %s\n", val) + } + if val, ok := metadata["is_dm"]; ok && val == "true" { + userInfo += "- **Context:** Private Message (DM)\n" + } else { + userInfo += "- **Context:** Server Channel\n" + } + systemPrompt += userInfo + } + // Log system prompt summary for debugging (debug mode only) logger.DebugCF("agent", "System prompt built", map[string]interface{}{ diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index fbaceee..43affdb 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -48,15 +48,16 @@ type AgentLoop struct { // processOptions configures how a message is processed type processOptions struct { - SessionKey string // Session identifier for history/context - Channel string // Target channel for tool execution - ChatID string // Target chat ID for tool execution - UserMessage string // User message content (may include prefix) - DefaultResponse string // Response when LLM returns empty - EnableSummary bool // Whether to trigger summarization - SendResponse bool // Whether to send response via bus - NoHistory bool // If true, don't load session history (for heartbeat) - HeartbeatMode bool // If true, exclude message/send_file tools from LLM + SessionKey string // Session identifier for history/context + Channel string // Target channel for tool execution + ChatID string // Target chat ID for tool execution + UserMessage string // User message content (may include prefix) + DefaultResponse string // Response when LLM returns empty + EnableSummary bool // Whether to trigger summarization + SendResponse bool // Whether to send response via bus + NoHistory bool // If true, don't load session history (for heartbeat) + HeartbeatMode bool // If true, exclude message/send_file tools from LLM + Metadata map[string]string // Optional message metadata (e.g. guild_id for Discord) } // createToolRegistry creates a tool registry with common tools. @@ -90,15 +91,15 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg registry.Register(tools.NewSPITool()) // Message tool - available to both agent and subagent - // Subagent uses it to communicate directly with user + // Uses SendDirect for synchronous sends so errors (e.g. Discord 403) are + // returned to the LLM, preventing infinite retry loops. messageTool := tools.NewMessageTool() messageTool.SetSendCallback(func(channel, chatID, content string) error { - msgBus.PublishOutbound(bus.OutboundMessage{ + return msgBus.SendDirect(context.Background(), bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: content, }) - return nil }) registry.Register(messageTool) @@ -115,6 +116,32 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg }) registry.Register(sendFileTool) + // Discord moderation tools — only registered when Discord is enabled. + // Users not using Discord will never see these tools in their agent context. + if cfg.Channels.Discord.Enabled { + discordDeleteTool := tools.NewDiscordDeleteMessageTool() + discordDeleteTool.SetSendCallback(func(channel, chatID, content string) error { + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: content, + }) + return nil + }) + registry.Register(discordDeleteTool) + + discordTimeoutTool := tools.NewDiscordTimeoutUserTool() + discordTimeoutTool.SetSendCallback(func(channel, chatID, content string) error { + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: content, + }) + return nil + }) + registry.Register(discordTimeoutTool) + } + return registry } @@ -195,11 +222,16 @@ func (al *AgentLoop) Run(ctx context.Context) error { } if !alreadySent { - al.bus.PublishOutbound(bus.OutboundMessage{ + outMsg := bus.OutboundMessage{ Channel: msg.Channel, ChatID: msg.ChatID, Content: response, - }) + } + // For Discord messages, reply-with-quote using the triggering message ID + if msg.Channel == "discord" && msg.Metadata != nil { + outMsg.ReplyToID = msg.Metadata["message_id"] + } + al.bus.PublishOutbound(outMsg) } } } @@ -216,6 +248,12 @@ func (al *AgentLoop) RegisterTool(tool tools.Tool) { al.tools.Register(tool) } +// GetTool retrieves a tool by name from the registry. +// Used by main.go to wire Discord moderation callbacks. +func (al *AgentLoop) GetTool(name string) (tools.Tool, bool) { + return al.tools.Get(name) +} + func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { al.channelManager = cm } @@ -296,6 +334,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) Channel: msg.Channel, ChatID: msg.ChatID, UserMessage: msg.Content, + Metadata: msg.Metadata, DefaultResponse: "I've completed processing but have no response to give.", EnableSummary: true, SendResponse: false, @@ -369,7 +408,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str } // 1. Update tool contexts - al.updateToolContexts(opts.Channel, opts.ChatID) + al.updateToolContexts(opts.Channel, opts.ChatID, opts.Metadata) // 2. Build messages (skip history for heartbeat) var history []providers.Message @@ -385,6 +424,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str nil, opts.Channel, opts.ChatID, + opts.Metadata, ) // 3. Save user message to session @@ -462,6 +502,9 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M if opts.HeartbeatMode { providerToolDefs = filterHeartbeatTools(providerToolDefs) } + if opts.Channel == "discord" && opts.Metadata["is_dm"] != "true" { + providerToolDefs = filterDiscordTools(providerToolDefs) + } // Log LLM request details logger.DebugCF("agent", "LLM request", @@ -538,6 +581,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M nil, opts.Channel, opts.ChatID, + nil, // Metadata already processed in system prompt of the original call ) continue @@ -679,8 +723,28 @@ func filterHeartbeatTools(tools []providers.ToolDefinition) []providers.ToolDefi return filtered } +// filterDiscordTools strictly whitelists tools allowed in public Discord channels +func filterDiscordTools(tools []providers.ToolDefinition) []providers.ToolDefinition { + allowed := map[string]bool{ + "message": true, + "discord_delete_message": true, + "discord_timeout_user": true, + "web_search_duckduckgo": true, + "web_search_brave": true, + "fetch_url": true, + } + + filtered := make([]providers.ToolDefinition, 0, len(tools)) + for _, t := range tools { + if allowed[t.Function.Name] { + filtered = append(filtered, t) + } + } + return filtered +} + // updateToolContexts updates the context for tools that need channel/chatID info. -func (al *AgentLoop) updateToolContexts(channel, chatID string) { +func (al *AgentLoop) updateToolContexts(channel, chatID string, metadata map[string]string) { // Use ContextualTool interface instead of type assertions if tool, ok := al.tools.Get("message"); ok { if mt, ok := tool.(tools.ContextualTool); ok { @@ -702,6 +766,22 @@ func (al *AgentLoop) updateToolContexts(channel, chatID string) { st.SetContext(channel, chatID) } } + if tool, ok := al.tools.Get("discord_delete_message"); ok { + if dt, ok := tool.(tools.ContextualTool); ok { + dt.SetContext(channel, chatID) + } + } + if tool, ok := al.tools.Get("discord_timeout_user"); ok { + if tt, ok := tool.(tools.ContextualTool); ok { + tt.SetContext(channel, chatID) + } + // Also pass guild_id from metadata so the tool never relies on the LLM for it. + if tt, ok := tool.(*tools.DiscordTimeoutUserTool); ok { + if metadata != nil { + tt.SetGuildID(metadata["guild_id"]) + } + } + } } // maybeSummarize triggers summarization if the session history exceeds thresholds. diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 58c0a25..633dbb4 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -2,22 +2,25 @@ package bus import ( "context" + "fmt" "sync" ) type MessageBus struct { - inbound chan InboundMessage - outbound chan OutboundMessage - handlers map[string]MessageHandler - closed bool - mu sync.RWMutex + inbound chan InboundMessage + outbound chan OutboundMessage + handlers map[string]MessageHandler + outHandlers map[string]OutboundHandler + closed bool + mu sync.RWMutex } func NewMessageBus() *MessageBus { return &MessageBus{ - inbound: make(chan InboundMessage, 100), - outbound: make(chan OutboundMessage, 100), - handlers: make(map[string]MessageHandler), + inbound: make(chan InboundMessage, 100), + outbound: make(chan OutboundMessage, 100), + handlers: make(map[string]MessageHandler), + outHandlers: make(map[string]OutboundHandler), } } @@ -48,6 +51,27 @@ func (mb *MessageBus) PublishOutbound(msg OutboundMessage) { mb.outbound <- msg } +// SendDirect sends a message synchronously through the registered outbound handler, +// returning any error (e.g. Discord 403). Use this when the caller needs to +// know whether the send succeeded (e.g. the message tool). +func (mb *MessageBus) SendDirect(ctx context.Context, msg OutboundMessage) error { + mb.mu.RLock() + handler, ok := mb.outHandlers[msg.Channel] + mb.mu.RUnlock() + if !ok { + return fmt.Errorf("no outbound handler for channel: %s", msg.Channel) + } + return handler(ctx, msg) +} + +// RegisterOutboundHandler registers a synchronous send handler for a channel. +// Used by the channel manager so the message tool can do synchronous sends. +func (mb *MessageBus) RegisterOutboundHandler(channel string, handler OutboundHandler) { + mb.mu.Lock() + defer mb.mu.Unlock() + mb.outHandlers[channel] = handler +} + func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) { select { case msg := <-mb.outbound: diff --git a/pkg/bus/types.go b/pkg/bus/types.go index d130afb..5d0b548 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -1,5 +1,7 @@ package bus +import "context" + type InboundMessage struct { Channel string `json:"channel"` SenderID string `json:"sender_id"` @@ -11,10 +13,15 @@ type InboundMessage struct { } type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - FilePath string `json:"file_path,omitempty"` // For file attachments + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + FilePath string `json:"file_path,omitempty"` // For file attachments + ReplyToID string `json:"reply_to_id,omitempty"` // Discord: reply to this message ID } type MessageHandler func(InboundMessage) error + +// OutboundHandler sends a message synchronously, returning any error. +// Used by SendDirect for error-aware sends (e.g. the message tool). +type OutboundHandler func(ctx context.Context, msg OutboundMessage) error diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index 9da6be5..c3b94fc 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -15,6 +15,8 @@ import ( "github.com/jamesrossdev/luckyclaw/pkg/voice" ) +const maxTimeoutDays = 28 // Discord maximum timeout duration + const ( transcriptionTimeout = 30 * time.Second sendTimeout = 10 * time.Second @@ -108,8 +110,13 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro chunks := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks - for _, chunk := range chunks { - if err := c.sendChunk(ctx, channelID, chunk); err != nil { + for i, chunk := range chunks { + // Only quote-reply on the first chunk + replyToID := "" + if i == 0 { + replyToID = msg.ReplyToID + } + if err := c.sendChunk(ctx, channelID, chunk, replyToID); err != nil { return err } } @@ -243,15 +250,29 @@ func findLastSpace(s string, searchWindow int) int { return -1 } -func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { - // 使用传入的 ctx 进行超时控制 +func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error { sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() done := make(chan error, 1) go func() { - _, err := c.session.ChannelMessageSend(channelID, content) - done <- err + if replyToID != "" { + // Send with quote-reply reference + _, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Content: content, + Reference: &discordgo.MessageReference{ + MessageID: replyToID, + ChannelID: channelID, + }, + AllowedMentions: &discordgo.MessageAllowedMentions{ + RepliedUser: true, + }, + }) + done <- err + } else { + _, err := c.session.ChannelMessageSend(channelID, content) + done <- err + } }() select { @@ -265,7 +286,7 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content strin } } -// appendContent 安全地追加内容到现有文本 +// appendContent safely appends a suffix to existing text, joining with a newline. func appendContent(content, suffix string) string { if content == "" { return suffix @@ -282,13 +303,68 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag return } - if err := c.session.ChannelTyping(m.ChannelID); err != nil { - logger.ErrorCF("discord", "Failed to send typing indicator", map[string]any{ - "error": err.Error(), + // DM filter: drop direct messages when disable_dms is set + if c.config.DisableDMs && m.GuildID == "" { + logger.DebugCF("discord", "DM ignored (disable_dms=true)", map[string]any{ + "user_id": m.Author.ID, }) + return + } + + // In server channels (GuildID != ""), only respond when @mentioned or when + // the message is a reply to one of the bot's own messages. + if m.GuildID != "" { + botID := s.State.User.ID + mentioned := false + for _, u := range m.Mentions { + if u.ID == botID { + mentioned = true + break + } + } + // Also respond when user replies to a bot message + if !mentioned && m.ReferencedMessage != nil && m.ReferencedMessage.Author != nil { + if m.ReferencedMessage.Author.ID == botID { + mentioned = true + } + } + // Also respond when the bot's own managed role is @mentioned + if !mentioned && len(m.MentionRoles) > 0 { + if botMember, err := s.State.Member(m.GuildID, botID); err == nil { + for _, botRoleID := range botMember.Roles { + for _, mentionedRoleID := range m.MentionRoles { + if botRoleID == mentionedRoleID { + mentioned = true + break + } + } + if mentioned { + break + } + } + } + } + if !mentioned { + return + } } - // 检查白名单,避免为被拒绝的用户下载附件和转录 + // Persistent typing indicator: refresh every 8s for up to 90s so the + // "LuckyClaw is typing..." badge stays visible during long LLM calls. + typingCtx, stopTyping := context.WithTimeout(context.Background(), 90*time.Second) + go func() { + for { + select { + case <-typingCtx.Done(): + return + default: + _ = c.session.ChannelTyping(m.ChannelID) + time.Sleep(8 * time.Second) + } + } + }() + defer stopTyping() + if !c.IsAllowed(m.Author.ID) { logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{ "user_id": m.Author.ID, @@ -303,10 +379,25 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag } content := m.Content + + // Read quoted/referenced message — ONLY for snitch flow (quoting another user's + // message). Skip when the quoted message is from the bot itself, since the bot + // already has its own responses in session history. + if m.ReferencedMessage != nil && m.ReferencedMessage.Author != nil { + if m.ReferencedMessage.Author.ID != s.State.User.ID { + quotedAuthor := m.ReferencedMessage.Author.Username + quotedContent := m.ReferencedMessage.Content + if quotedContent != "" { + content = fmt.Sprintf("[Quoted message from %s (user_id: %s, message_id: %s): \"%s\"]\n%s", + quotedAuthor, m.ReferencedMessage.Author.ID, m.ReferencedMessage.ID, quotedContent, content) + } + } + } + mediaPaths := make([]string, 0, len(m.Attachments)) localFiles := make([]string, 0, len(m.Attachments)) - // 确保临时文件在函数返回时被清理 + // Ensure temp files are cleaned up on function return. defer func() { for _, file := range localFiles { if err := os.Remove(file); err != nil { @@ -330,7 +421,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag if c.transcriber != nil && c.transcriber.IsAvailable() { ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout) result, err := c.transcriber.Transcribe(ctx, localPath) - cancel() // 立即释放context资源,避免在for循环中泄漏 + cancel() // Release context resources immediately to avoid leak in loop if err != nil { logger.ErrorCF("discord", "Voice transcription failed", map[string]any{ @@ -386,6 +477,22 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag "is_dm": fmt.Sprintf("%t", m.GuildID == ""), } + // Resolve sender's Discord roles to names so the LLM can see them. + if m.Member != nil && len(m.Member.Roles) > 0 { + var roleNames []string + for _, roleID := range m.Member.Roles { + if role, err := s.State.Role(m.GuildID, roleID); err == nil { + roleNames = append(roleNames, role.Name) + } + } + if len(roleNames) > 0 { + metadata["sender_roles"] = strings.Join(roleNames, ", ") + } + } + + // Trigger "is typing" indicator while the agent processes this message + s.ChannelTyping(m.ChannelID) + c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata) } @@ -394,3 +501,32 @@ func (c *DiscordChannel) downloadAttachment(url, filename string) string { LoggerPrefix: "discord", }) } + +// DeleteMessage deletes a message in the given channel. +// Used by the discord_delete_message tool. +func (c *DiscordChannel) DeleteMessage(channelID, messageID string) error { + if !c.IsRunning() { + return fmt.Errorf("discord bot not running") + } + return c.session.ChannelMessageDelete(channelID, messageID) +} + +// TimeoutUser applies a timeout to a guild member until the specified time. +// Used by the discord_timeout_user tool. +func (c *DiscordChannel) TimeoutUser(guildID, userID string, until time.Time) error { + if !c.IsRunning() { + return fmt.Errorf("discord bot not running") + } + // Discord maximum timeout is 28 days + maxUntil := time.Now().Add(time.Duration(maxTimeoutDays) * 24 * time.Hour) + if until.After(maxUntil) { + until = maxUntil + } + return c.session.GuildMemberTimeout(guildID, userID, &until) +} + +// Session returns the underlying discordgo session. +// Used to set up moderation tool callbacks. +func (c *DiscordChannel) Session() *discordgo.Session { + return c.session +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index f2e0ec6..070dfed 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -200,6 +200,13 @@ func (m *Manager) StartAll(ctx context.Context) error { go m.dispatchOutbound(dispatchCtx) for name, channel := range m.channels { + // Register synchronous outbound handler so SendDirect can route to this channel. + // This enables the message tool to detect send failures (e.g. 403). + ch := channel // capture for closure + m.bus.RegisterOutboundHandler(name, func(ctx context.Context, msg bus.OutboundMessage) error { + return ch.Send(ctx, msg) + }) + logger.InfoCF("channels", "Starting channel", map[string]interface{}{ "channel": name, }) diff --git a/pkg/config/config.go b/pkg/config/config.go index 6295769..e96c13b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -105,9 +105,10 @@ type FeishuConfig struct { } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"LUCKYCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"LUCKYCLAW_CHANNELS_DISCORD_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"LUCKYCLAW_CHANNELS_DISCORD_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"LUCKYCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"LUCKYCLAW_CHANNELS_DISCORD_TOKEN"` + DisableDMs bool `json:"disable_dms,omitempty" env:"LUCKYCLAW_CHANNELS_DISCORD_DISABLE_DMS"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"LUCKYCLAW_CHANNELS_DISCORD_ALLOW_FROM"` } type MaixCamConfig struct { @@ -225,7 +226,7 @@ func DefaultConfig() *Config { Workspace: "~/.luckyclaw/workspace", RestrictToWorkspace: true, Provider: "openrouter", - Model: "arcee-ai/trinity-large-preview:free", + Model: "stepfun/step-3.5-flash:free", MaxTokens: 16384, Temperature: 0.7, MaxToolIterations: 25, diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go index ec43838..1978a89 100644 --- a/pkg/migrate/migrate_test.go +++ b/pkg/migrate/migrate_test.go @@ -293,8 +293,8 @@ func TestConvertConfig(t *testing.T) { if len(warnings) != 0 { t.Errorf("expected no warnings, got %v", warnings) } - if cfg.Agents.Defaults.Model != "arcee-ai/trinity-large-preview:free" { - t.Errorf("default model should be arcee-ai/trinity-large-preview:free, got %q", cfg.Agents.Defaults.Model) + if cfg.Agents.Defaults.Model != "stepfun/step-3.5-flash:free" { + t.Errorf("default model should be stepfun/step-3.5-flash:free, got %q", cfg.Agents.Defaults.Model) } }) } diff --git a/pkg/tools/discord_mod.go b/pkg/tools/discord_mod.go new file mode 100644 index 0000000..f8b3e1a --- /dev/null +++ b/pkg/tools/discord_mod.go @@ -0,0 +1,244 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "time" +) + +// Discord moderation tool callback types. +// These are set by the agent loop from the DiscordChannel methods. +type DeleteMessageCallback func(channelID, messageID string) error +type TimeoutUserCallback func(guildID, userID string, until time.Time) error +type SendMessageCallback func(channel, chatID, content string) error + +// --- discord_delete_message tool --- + +type DiscordDeleteMessageTool struct { + deleteCallback DeleteMessageCallback + sendCallback SendMessageCallback + defaultChannel string + defaultChatID string +} + +func NewDiscordDeleteMessageTool() *DiscordDeleteMessageTool { + return &DiscordDeleteMessageTool{} +} + +func (t *DiscordDeleteMessageTool) Name() string { + return "discord_delete_message" +} + +func (t *DiscordDeleteMessageTool) Description() string { + return "Delete a message in a Discord channel. Use this to remove offensive or rule-breaking messages. Requires channel_id and message_id (available in quoted message metadata)." +} + +func (t *DiscordDeleteMessageTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "channel_id": map[string]interface{}{ + "type": "string", + "description": "The Discord channel ID where the message is", + }, + "message_id": map[string]interface{}{ + "type": "string", + "description": "The ID of the message to delete", + }, + "reason": map[string]interface{}{ + "type": "string", + "description": "Reason for deletion (logged to mod-log)", + }, + "log_channel_id": map[string]interface{}{ + "type": "string", + "description": "Optional: Channel ID to send a moderation log to (e.g., mod-log channel)", + }, + }, + "required": []string{"channel_id", "message_id", "reason"}, + } +} + +func (t *DiscordDeleteMessageTool) SetContext(channel, chatID string) { + t.defaultChannel = channel + t.defaultChatID = chatID +} + +func (t *DiscordDeleteMessageTool) SetDeleteCallback(cb DeleteMessageCallback) { + t.deleteCallback = cb +} + +func (t *DiscordDeleteMessageTool) SetSendCallback(cb SendMessageCallback) { + t.sendCallback = cb +} + +func (t *DiscordDeleteMessageTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + if t.defaultChannel != "discord" { + return ErrorResult("discord_delete_message can only be used in Discord channels") + } + + channelID, _ := args["channel_id"].(string) + messageID, _ := args["message_id"].(string) + reason, _ := args["reason"].(string) + logChannelID, _ := args["log_channel_id"].(string) + + if channelID == "" || messageID == "" { + return ErrorResult("channel_id and message_id are required") + } + if reason == "" { + reason = "No reason provided" + } + + if t.deleteCallback == nil { + return ErrorResult("Discord delete not configured") + } + + if err := t.deleteCallback(channelID, messageID); err != nil { + return &ToolResult{ + ForLLM: fmt.Sprintf("Failed to delete message: %v", err), + IsError: true, + Err: err, + } + } + + // Auto-log to mod-log + if t.sendCallback != nil && logChannelID != "" { + logMsg := fmt.Sprintf("🗑️ **Message Deleted**\n**Channel:** <#%s>\n**Reason:** %s\n**Actioned by:** LuckyClaw", channelID, reason) + _ = t.sendCallback("discord", logChannelID, logMsg) + } + + return &ToolResult{ + ForLLM: fmt.Sprintf("Message %s deleted from channel %s. Reason: %s", messageID, channelID, reason), + } +} + +// --- discord_timeout_user tool --- + +type DiscordTimeoutUserTool struct { + timeoutCallback TimeoutUserCallback + sendCallback SendMessageCallback + defaultChannel string + defaultChatID string + defaultGuildID string +} + +func NewDiscordTimeoutUserTool() *DiscordTimeoutUserTool { + return &DiscordTimeoutUserTool{} +} + +func (t *DiscordTimeoutUserTool) Name() string { + return "discord_timeout_user" +} + +func (t *DiscordTimeoutUserTool) Description() string { + return "Timeout (mute) a user in a Discord server for a specified duration. Use this for rule violations like hate speech or spam. Maximum duration is 28 days." +} + +func (t *DiscordTimeoutUserTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "user_id": map[string]interface{}{ + "type": "string", + "description": "The Discord user ID to timeout. If the user mentions them like <@123456>, the ID is 123456. EXTRACT THIS AUTOMATICALLY from the raw message and DO NOT ask the user to provide it.", + }, + "duration_minutes": map[string]interface{}{ + "type": "number", + "description": "Timeout duration in minutes (max 40320 = 28 days)", + }, + "reason": map[string]interface{}{ + "type": "string", + "description": "Reason for the timeout (logged to mod-log)", + }, + "log_channel_id": map[string]interface{}{ + "type": "string", + "description": "Optional: Channel ID to send a moderation log to (e.g., mod-log channel)", + }, + }, + "required": []string{"user_id", "duration_minutes", "reason"}, + } +} + +func (t *DiscordTimeoutUserTool) SetContext(channel, chatID string) { + t.defaultChannel = channel + t.defaultChatID = chatID +} + +// SetGuildID stores the guild ID from message metadata. +// Called from updateToolContexts — never relying on the LLM to supply it. +func (t *DiscordTimeoutUserTool) SetGuildID(guildID string) { + t.defaultGuildID = guildID +} + +func (t *DiscordTimeoutUserTool) SetTimeoutCallback(cb TimeoutUserCallback) { + t.timeoutCallback = cb +} + +func (t *DiscordTimeoutUserTool) SetSendCallback(cb SendMessageCallback) { + t.sendCallback = cb +} + +func (t *DiscordTimeoutUserTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + if t.defaultChannel != "discord" { + return ErrorResult("discord_timeout_user can only be used in Discord channels") + } + + guildID := t.defaultGuildID // Always use context guild_id + rawUserID, _ := args["user_id"].(string) + reason, _ := args["reason"].(string) + logChannelID, _ := args["log_channel_id"].(string) + + // Clean up user ID if the LLM passed a raw mention like <@123456> + userID := strings.TrimPrefix(strings.TrimSuffix(rawUserID, ">"), "<@") + userID = strings.TrimPrefix(userID, "!") // Sometimes it's <@!123456> + + // Handle duration_minutes as float64 (JSON numbers are float64) + durationMinutes := 0.0 + if v, ok := args["duration_minutes"].(float64); ok { + durationMinutes = v + } + + if guildID == "" { + return ErrorResult("guild_id not available in context") + } + if userID == "" { + return ErrorResult("user_id is required") + } + if durationMinutes <= 0 { + return ErrorResult("duration_minutes must be a positive number") + } + if reason == "" { + reason = "No reason provided" + } + + // Cap at 28 days (Discord maximum) + const maxMinutes = 28 * 24 * 60 + if durationMinutes > maxMinutes { + durationMinutes = maxMinutes + } + + if t.timeoutCallback == nil { + return ErrorResult("Discord timeout not configured") + } + + until := time.Now().Add(time.Duration(durationMinutes) * time.Minute) + + if err := t.timeoutCallback(guildID, userID, until); err != nil { + return &ToolResult{ + ForLLM: fmt.Sprintf("Failed to timeout user: %v", err), + IsError: true, + Err: err, + } + } + + // Auto-log to mod-log + if t.sendCallback != nil && logChannelID != "" { + logMsg := fmt.Sprintf("⚠️ **User Timeout**\n**User:** <@%s>\n**Duration:** %.0f minutes\n**Reason:** %s\n**Actioned by:** LuckyClaw", userID, durationMinutes, reason) + _ = t.sendCallback("discord", logChannelID, logMsg) + } + + return &ToolResult{ + ForLLM: fmt.Sprintf("User %s timed out for %.0f minutes in guild %s. Reason: %s", + userID, durationMinutes, guildID, reason), + } +} diff --git a/workspace/skills/discord-mod/SKILL.md b/workspace/skills/discord-mod/SKILL.md new file mode 100644 index 0000000..29d4f88 --- /dev/null +++ b/workspace/skills/discord-mod/SKILL.md @@ -0,0 +1,70 @@ +--- +name: discord-mod +description: LuckyClaw server FAQ, channel directory, and rules +--- + +# About LuckyClaw + +LuckyClaw is an open-source AI assistant built by James (@ross.james) +and other contributors. It is a fork of Picoclaw tuned for Luckfox Pico Plus, Luckfox Pico Pro, and Luckfox Pico Max boards. + +**GitHub**: https://github.com/jamesrossdev/luckyclaw +**Server invite**: [Your Server Invite Link Here] + +# FAQ + +**Q: What is LuckyClaw?** +A: An open-source AI assistant. See GitHub for details. + +**Q: Where do I get help?** +A: <#help-channel-id> + +**Q: Where do I discuss development or contribute?** +A: <#dev-discussion-channel-id> or open a PR on GitHub. + +**Q: Who built this?** +A: James (@ross.james) and other contributors. + +**Q: Can I get a server invite link?** +A: Yes! [Your Server Invite Link Here] + +# Channel Directory + +- <#rules-channel-id> — Rules +- <#announcements-channel-id> — Announcements +- <#general-chat-channel-id> — General chat +- <#showcase-channel-id> — Showcase your setup +- <#bot-test-chat-channel-id> — Bot test chat +- <#help-troubleshooting-channel-id> — Help and troubleshooting +- <#dev-discussion-channel-id> — Development discussion +- <#mod-log-channel-id> — Moderation log (bot only) +- <#mod-team-chat-channel-id> — Mod team chat + +# Server Rules + +Welcome to our Discord! To keep the community helpful, safe, and focused, please follow these guidelines: + +***1. Respect & Behavior*** +- **Be respectful**: Treat all members and the bot with respect. +- **Zero Tolerance for Hate Speech**: Racism, sexism, homophobia, transphobia, or any form of hate speech or slurs will result in an immediate action. +- **No Harassment**: Do not harass, bully, or personally attack other users. + +***2. Content Guidelines*** +- **No NSFW Content**: Keep the server safe for work. +- **No Spam**: Do not spam messages, emojis, or mentions. +- **No Unsolicited Advertising**: Do not advertise unrelated services. + +***3. Channel Usage*** +- **Stay on Topic**: Use the appropriate channels for your discussions. +- **Bot Interactions**: Please do not intentionally try to break or exploit the bot in public channels. + +***4. Moderation & AI Enforcement*** +- **LuckyClaw Moderation**: The bot is authorized to autonomously delete messages, issue timeouts, and log infractions. +- **Human Moderators**: These rules are also enforced by human moderators who have the final say. +- **Appeals**: If you believe you were unfairly moderated, reach out to an Admin. + +# Role Directory + +- <@&admin-role-id> — Admin +- <@&moderator-role-id> — Moderator +- <@&bot-role-id> — LuckyClaw