# Scrapwire Scripting Guide

Scrapwire supports user-defined scripts written in [Rhai](https://rhai.rs/) that
can react to events, send messages, and add custom commands to the Script Menu.

---

## Getting Started

### Script Directory

Scripts are loaded from your platform's config directory:

| Platform | Default Path |
|----------|-------------|
| Linux | `~/.config/scrapwire/scripts/` |
| macOS | `~/Library/Application Support/scrapwire/scripts/` |
| Windows | `%APPDATA%\scrapwire\scripts\` |

The directory follows `$XDG_CONFIG_HOME/scrapwire/scripts/` when set, falling
back to `~/.config/scrapwire/scripts/`.

### File Extension

All scripts must use the `.rhai` extension (e.g. `hello.rhai`). Files with
other extensions are ignored.

### Loading Behavior

- Scripts are **automatically loaded and compiled** on startup.
- The script name is the file stem (e.g. `hello.rhai` → `hello`).
- Syntax errors in one script do not prevent other scripts from loading.
- Use the **Reload Scripts** command (`F1` → "Reload Scripts") to re-scan the
  directory and pick up new or changed scripts without restarting.

---

## Available Functions

All functions live under the `scrapwire::` namespace. They are **not** available
as bare globals — you must always use the `scrapwire::` prefix.

| Category | Functions |
|----------|-----------|
| [Conversations](#conversations) | [`send_conversation`](#scrapwiresend_conversationconv_id-text), [`send_dm`](#scrapwiresend_dmname-text), [`send_whisper`](#scrapwiresend_whisperconv_id-member_id-text), [`conversations`](#scrapwireconversations), [`conversation_get`](#scrapwireconversation_getconv_id), [`conversation_by_name`](#scrapwireconversation_by_namename), [`info_get`](#scrapwireinfo_getconv_id), [`info_set`](#scrapwireinfo_setconv_id-text) |
| [People](#people) | [`members`](#scrapwiremembersconv_id), [`me`](#scrapwireme), [`conversation_me`](#scrapwireconversation_meconv_id) |
| [Storage](#storage) | [`store`](#scrapwirestorekey-value), [`load`](#scrapwireloadkey) |
| [Menus](#menus) | [`add_menu_item`](#scrapwireadd_menu_itemlabel-action_id) |
| [Utility](#utility) | [`send_local`](#scrapwiresend_localtext), [`rate_limit`](#scrapwirerate_limitkey-interval_secs) |

### Conversations

#### `scrapwire::send_conversation(conv_id, text)`

Send a text message to a conversation.

| Parameter | Type | Description |
|-----------|------|-------------|
| `conv_id` | `ConversationId` | Target conversation |
| `text` | `String` | Message body |

- Routes to DM or group send automatically based on conversation scope.
- Sending to a Publication conversation returns an error.
- **Implicit rate limit:** If no explicit `scrapwire::rate_limit()` call was
  made earlier in the current callback invocation, `send_conversation()` automatically
  applies an implicit 60-second rate limit keyed by the callback name
  (e.g. `"on_message"`). If the implicit check fails, execution halts with
  a runtime error.
- **Explicit rate limit bypass:** If `scrapwire::rate_limit()` was called
  (and passed) earlier in the same callback invocation, `send_conversation()` skips
  the implicit check entirely.
- A **hard safety cap** of 10 calls per second per script always applies
  regardless of explicit or implicit rate limits.

```rhai
fn on_message(conv_id, sender, text, is_group) {
    if text == "!ping" {
        scrapwire::send_conversation(conv_id, "pong!");
    }
}
```

#### `scrapwire::send_dm(name, text)`

Send a direct message to a contact by display name. The contact must
have an existing DM conversation. Raises an error if no DM exists with
that name, or if multiple contacts share the same name.

| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | `String` | Contact's display name |
| `text` | `String` | Message body |

```rhai
fn on_message(conv_id, sender, text, is_group) {
    if text == "!alert" {
        scrapwire::send_dm("Admin", "Alert triggered by " + sender);
    }
}
```

#### `scrapwire::send_whisper(conv_id, member_id, text)`

Send a private whisper to a group member by member ID. Whispers are
end-to-end encrypted between sender and recipient only -- other group
members cannot read them. Use `members()` or `conversation_me()` to
obtain a member's `id` field.

| Parameter | Type | Description |
|-----------|------|-------------|
| `conv_id` | `ConversationId` | Group conversation |
| `member_id` | `String` | Target member's ID (hex, from `User.id` or `GroupMember.member_id`) |
| `text` | `String` | Message body |

```rhai
fn on_message(conv_id, sender, text, is_group) {
    if text.starts_with("!whisper ") {
        let members = scrapwire::members(conv_id);
        for m in members {
            if m.display_name == sender {
                scrapwire::send_whisper(conv_id, m.user.id, "Secret reply!");
            }
        }
    }
}
```

#### `scrapwire::conversations()`

Returns an `Array` of all conversations as `Conversation` objects.

```rhai
let convos = scrapwire::conversations();
for c in convos {
    scrapwire::send_local(c.display_name + " (" + c.scope + ")");
}
```

#### `scrapwire::conversation_get(conv_id)`

Look up a single conversation by its ID.

| Parameter | Type | Description |
|-----------|------|-------------|
| `conv_id` | `ConversationId` | Conversation to look up |

Returns a `Conversation` if found, or `()` (unit) if not found.

```rhai
let conv = scrapwire::conversation_get(conv_id);
if conv != () {
    scrapwire::send_local("Name: " + conv.display_name);
}
```

#### `scrapwire::conversation_by_name(name)`

Look up a conversation by its display name.

| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | `String` | Display name of the conversation |

Returns a `ConversationId` if a conversation with the given name is found, or
raises a runtime error if not found.

```rhai
let cid = scrapwire::conversation_by_name("My Group");
let members = scrapwire::members(cid);
for m in members {
    scrapwire::send_local(m.display_name + " [" + m.role + "]");
}
```

#### `scrapwire::info_get(conv_id)`

Get the info text for a conversation.

| Parameter | Type | Description |
|-----------|------|-------------|
| `conv_id` | `ConversationId` | Conversation to query |

Returns the info text as a `String` (empty string if not set).

```rhai
let info = scrapwire::info_get(conv_id);
if info != "" {
    scrapwire::send_local("Info: " + info);
}
```

#### `scrapwire::info_set(conv_id, text)`

Set the info text for a conversation (max 1024 characters). For groups,
requires Admin or Owner role. For DMs, either party can set it.
For publications, only the publisher can set it.

| Parameter | Type | Description |
|-----------|------|-------------|
| `conv_id` | `ConversationId` | Target conversation |
| `text` | `String` | Info text (max 1024 chars) |

```rhai
scrapwire::info_set(conv_id, "Welcome to the channel! Check #rules first.");
```

### People

#### `scrapwire::members(conv_id)`

Returns an `Array` of `GroupMember` objects for a group conversation.

| Parameter | Type | Description |
|-----------|------|-------------|
| `conv_id` | `ConversationId` | Group conversation |

```rhai
let members = scrapwire::members(conv_id);
for m in members {
    scrapwire::send_local(m.display_name + " [" + m.role + "]");
}
```

#### `scrapwire::me()`

Returns the local user's display name as a `String`.

```rhai
let name = scrapwire::me();
scrapwire::send_local("I am " + name);
```

#### `scrapwire::conversation_me(conv_id)`

Returns a `User` for the local user within a specific conversation, including
their stable member ID for that group.

| Parameter | Type | Description |
|-----------|------|-------------|
| `conv_id` | `ConversationId` | Group conversation |

Returns a `User` object. Raises a runtime error if the conversation is not
found or is not a group.

```rhai
let me = scrapwire::conversation_me(conv_id);
scrapwire::send_local("My ID here: " + me.id);
```

### Storage

#### `scrapwire::store(key, value)`

Persist a string value under a key. Each script has its own isolated
key-value store backed by a JSON file. Data survives application restarts.

| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `String` | Storage key |
| `value` | `String` | Value to store |

```rhai
scrapwire::store("counter", "42");
```

#### `scrapwire::load(key)`

Load a previously stored value. Returns `()` (unit) if the key does not exist.

| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `String` | Storage key |

```rhai
let val = scrapwire::load("counter");
if val != () {
    scrapwire::send_local("Counter is " + val);
}
```

### Menus

#### `scrapwire::add_menu_item(label, action_id)`

Register a menu item for the Script Menu. **Only available inside
`create_user_menu(ctx)`** — calling it elsewhere returns an error.

| Parameter | Type | Description |
|-----------|------|-------------|
| `label` | `String` | Display text shown in the palette |
| `action_id` | `String` | Name of the callback function to invoke |

```rhai
fn create_user_menu(ctx) {
    scrapwire::add_menu_item("Say Hello", "say_hello");
}

fn say_hello(ctx) {
    // This runs when the user selects "Say Hello"
}
```

### Utility

#### `scrapwire::send_local(text)`

Print a message. In TUI mode, inserts a local-only system message in the
active conversation's message display. In headless and `--exec` modes, prints
to stdout.

| Parameter | Type | Description |
|-----------|------|-------------|
| `text` | `String` | Text to display |

```rhai
scrapwire::send_local("Processing complete: " + count + " items");
```

#### `scrapwire::rate_limit(key, interval_secs)`

Enforce a per-key rate limit within the current script. If the same `key` has
been invoked within the last `interval_secs` seconds, execution **halts** with
a Rhai runtime error. Different keys are tracked independently, and each
script has its own isolated set of limiters.

Calling `rate_limit()` successfully also sets an internal flag that tells
subsequent `scrapwire::send_conversation()` calls in the same callback invocation to
**skip** the implicit 60-second rate limit. This lets you replace the default
safety net with your own per-command intervals.

| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `String` | Named rate-limit bucket (e.g. `"ping_reply"`) |
| `interval_secs` | `f64` | Minimum seconds between allowed invocations |

```rhai
fn on_message(conv_id, sender, text, is_group) {
    if text == "!ping" {
        scrapwire::rate_limit("ping_reply", 5.0);  // max once per 5s
        scrapwire::send_conversation(conv_id, "pong!");          // skips implicit limit
    }
    if text == "!stats" {
        scrapwire::rate_limit("stats_cmd", 30.0);  // max once per 30s
        scrapwire::send_conversation(conv_id, "Here are the stats…");
    }
}
```

**Implicit send rate limit:** If a callback calls `scrapwire::send_conversation()` without
first calling `scrapwire::rate_limit()`, an implicit 60-second rate limit
keyed by the callback name (e.g. `"on_message"`) is automatically applied.
If the implicit check fails, execution halts with a runtime error. This acts
as a safety net against runaway scripts. Use explicit `rate_limit()` calls
for fine-grained control when you need higher or lower throughput.

---

## Event Handlers

Define these functions in your script to react to events. All handlers are
optional — only define the ones you need.

### `on_startup()`

Called once when scripts are loaded (on application start or after reload).
Takes no parameters.

```rhai
fn on_startup() {
    scrapwire::send_local("Script loaded!");
}
```

### `on_shutdown()`

Called when the application is shutting down (Ctrl-C or quit). Takes no
parameters. Use this for cleanup tasks like saving state.

```rhai
fn on_shutdown() {
    scrapwire::send_local("Goodbye!");
}
```

### `on_message(conv_id, sender, text, is_group)`

Called when a message is received (DM or group).

| Parameter | Type | Description |
|-----------|------|-------------|
| `conv_id` | `ConversationId` | Conversation the message arrived in |
| `sender` | `String` | Display name of the message author |
| `text` | `String` | Message body |
| `is_group` | `bool` | `true` for group messages, `false` for DMs |

```rhai
fn on_message(conv_id, sender, text, is_group) {
    if text.starts_with("!echo ") {
        let reply = text.sub_string(6);
        scrapwire::send_conversation(conv_id, reply);
    }
}
```

### `on_join(conv_id, member_name)`

Called when a member joins a group conversation.

| Parameter | Type | Description |
|-----------|------|-------------|
| `conv_id` | `ConversationId` | Group conversation |
| `member_name` | `String` | Display name of the new member |

```rhai
fn on_join(conv_id, member_name) {
    scrapwire::send_conversation(conv_id, "Welcome, " + member_name + "!");
}
```

---

## Script Menu

The Script Menu is a filterable command palette populated by your scripts.
Press **F2** to open it.

### How It Works

1. When you press **F2**, Scrapwire calls `create_user_menu(ctx)` on every
   loaded script that defines it.
2. Each script calls `scrapwire::add_menu_item(label, action_id)` to register
   items.
3. The collected items appear in the Script Menu overlay (titled "Script
   Commands").
4. Type to filter, use arrow keys to navigate, press **Enter** to select.
5. Selecting an item calls the callback function (identified by `action_id`)
   on the script that registered it.
6. Press **Esc** to dismiss without selecting.

### The `create_user_menu(ctx)` Pattern

```rhai
fn create_user_menu(ctx) {
    scrapwire::add_menu_item("Greet Channel", "do_greet");

    if ctx.scope == "Group" {
        scrapwire::add_menu_item("List Members", "list_members");
    }
}
```

### Context Properties

The `ctx` parameter is a `MenuContext` object with these properties:

| Property | Type | Description |
|----------|------|-------------|
| `ctx.conversation_id` | `ConversationId` or `()` | Active conversation ID, or unit if none |
| `ctx.scope` | `String` or `()` | `"Group"`, `"DM"`, or `"Publication"`, or unit if none |
| `ctx.selected_member` | `GroupMember` or `()` | Selected member from the member list, or unit if the member list is not open |

### Callback Functions

Each `action_id` you register corresponds to a function in the same script.
The function receives the same `ctx` context:

```rhai
fn do_greet(ctx) {
    if ctx.conversation_id != () {
        scrapwire::send_conversation(ctx.conversation_id, "Hello everyone!");
    }
}

fn list_members(ctx) {
    if ctx.conversation_id != () {
        let members = scrapwire::members(ctx.conversation_id);
        let names = "";
        for m in members {
            names += m.display_name + ", ";
        }
        scrapwire::send_conversation(ctx.conversation_id, "Members: " + names);
    }
}
```

---

## Example Scripts

### Auto-Greeter

Automatically welcomes new members to group conversations.

```rhai
// auto_greeter.rhai

fn on_join(conv_id, member_name) {
    let me = scrapwire::me();
    if member_name != me {
        scrapwire::send_conversation(conv_id, "Welcome to the group, " + member_name + "! 👋");
    }
}
```

### Context-Aware Menu with Member Actions

Shows different menu items based on the conversation type and selected member.

```rhai
// member_actions.rhai

fn create_user_menu(ctx) {
    if ctx.scope == "Group" {
        scrapwire::add_menu_item("Show Group Info", "show_info");

        if ctx.selected_member != () {
            scrapwire::add_menu_item(
                "Wave at " + ctx.selected_member.display_name,
                "wave_at"
            );
        }
    }

    if ctx.scope == "DM" {
        scrapwire::add_menu_item("Show DM Info", "show_info");
    }
}

fn show_info(ctx) {
    if ctx.conversation_id != () {
        let conv = scrapwire::conversation_get(ctx.conversation_id);
        if conv != () {
            let info = "📋 " + conv.display_name
                + " | Scope: " + conv.scope
                + " | Members: " + conv.member_count
                + " | Unread: " + conv.unread_count;
            scrapwire::send_conversation(ctx.conversation_id, info);
        }
    }
}

fn wave_at(ctx) {
    if ctx.conversation_id != () && ctx.selected_member != () {
        scrapwire::send_conversation(
            ctx.conversation_id,
            "👋 Hey " + ctx.selected_member.display_name + "!"
        );
    }
}
```

### Conversation Info Script

Stores and retrieves notes about conversations using persistent storage.

```rhai
// conv_notes.rhai

fn create_user_menu(ctx) {
    if ctx.conversation_id != () {
        scrapwire::add_menu_item("View Notes", "view_notes");
        scrapwire::add_menu_item("Save Note", "save_note");
    }
}

fn view_notes(ctx) {
    if ctx.conversation_id != () {
        let key = "note_" + ctx.conversation_id;
        let note = scrapwire::load(key);
        if note != () {
            scrapwire::send_conversation(ctx.conversation_id, "📝 Note: " + note);
        } else {
            scrapwire::send_conversation(ctx.conversation_id, "📝 No notes saved for this conversation.");
        }
    }
}

fn save_note(ctx) {
    if ctx.conversation_id != () {
        let key = "note_" + ctx.conversation_id;
        let conv = scrapwire::conversation_get(ctx.conversation_id);
        let note = "Conversation: " + conv.display_name + " (" + conv.scope + ")";
        scrapwire::store(key, note);
        scrapwire::send_conversation(ctx.conversation_id, "📝 Note saved!");
    }
}
```

---

## Type Reference

### `ConversationId`

An opaque conversation identifier. Displayed as a 64-character lowercase hex
string. Supports `==` and `!=` comparison and `to_string()`.

### `Conversation`

| Property | Type | Description |
|----------|------|-------------|
| `id` | `ConversationId` | Unique identifier |
| `display_name` | `String` | Human-readable name |
| `scope` | `String` | `"DM"`, `"Group"`, or `"Publication"` |
| `member_count` | `int` | Number of members |
| `unread_count` | `int` | Unread message count |

### `User`

| Property | Type | Description |
|----------|------|-------------|
| `display_name` | `String` | User's display name |
| `id` | `String` | Stable member identifier (lowercase hex, per-group) |

### `GroupMember`

| Property | Type | Description |
|----------|------|-------------|
| `user` | `User` | The member's user identity |
| `is_self` | `bool` | `true` if this member is you |
| `role` | `String` | `"Owner"`, `"Admin"`, or `"User"` |
| `display_name` | `String` | Convenience alias for `user.display_name` |
| `member_id` | `String` | Convenience alias for `user.id` |

### `Message`

Received in the `on_message` handler as individual parameters (not as a
`Message` object). The `Message` type is used internally and has these
properties when encountered:

| Property | Type | Description |
|----------|------|-------------|
| `id` | `MessageId` | Unique message identifier |
| `conversation_id` | `ConversationId` | Conversation this message belongs to |
| `author` | `String` | Author's display name |
| `text` | `String` | Message body |
| `timestamp` | `int` | Unix timestamp |
| `is_own` | `bool` | `true` if you sent this message |

### `MessageId`

An opaque message identifier. Displayed as a 64-character lowercase hex string.
Supports `==` and `!=` comparison and `to_string()`.

### `MenuContext`

Passed to `create_user_menu(ctx)` and callback functions.

| Property | Type | Description |
|----------|------|-------------|
| `conversation_id` | `ConversationId` or `()` | Active conversation, or unit |
| `scope` | `String` or `()` | `"Group"`, `"DM"`, `"Publication"`, or unit |
| `selected_member` | `GroupMember` or `()` | Selected member, or unit |

---

## Headless Mode

When running with `--headless`, there is no TUI — but all scripts are loaded
and event handlers fire normally. This makes `on_message` the natural place to
build your own message logger, filtered and formatted however you like.

`scrapwire::send_local()` writes to stdout in headless mode, so you can pipe or
redirect output as needed.

### Example: Message Logger

```rhai
fn on_message(conv_id, sender, text, is_group) {
    let conv = scrapwire::conversation_get(conv_id);
    if conv == () { return; }

    let prefix = if conv.scope == "Group" {
        "[G] [" + conv.display_name + "] "
    } else if conv.scope == "Publication" {
        "[P] [" + conv.display_name + "] "
    } else {
        "[D] "
    };

    scrapwire::send_local(prefix + sender + ": " + text);
}
```

```sh
./scrapwire --headless           # logs appear on stdout
./scrapwire --headless >> chat.log 2>/dev/null  # log to file, suppress tracing
```

Since the logic lives in your script, you can filter by conversation, keyword,
sender, or anything else without rebuilding.

---

## `--exec` Mode

Run a single script function from the command line and exit:

```sh
./scrapwire --exec path/to/script.rhai:function_name [args...]
```

Any arguments after the `script:function` are passed as string parameters to
the Rhai function. The number of arguments must match the function's parameter
count.

```rhai
// report.rhai
fn user_report(group_name) {
    let cid = scrapwire::conversation_by_name(group_name);
    let members = scrapwire::members(cid);
    for m in members {
        scrapwire::send_local(m.display_name + " [" + m.role + "]");
    }
}
```

```sh
./scrapwire --exec report.rhai:user_report "My Group"
```

Functions with no parameters are called with no arguments:

```sh
./scrapwire --exec script.rhai:list_all
```

---

## Sandbox Limits

Scripts run in a sandboxed environment with the following restrictions:

| Limit | Value | Description |
|-------|-------|-------------|
| `max_operations` | 1,000,000 | Maximum number of Rhai operations per call |
| `max_call_levels` | 64 | Maximum function call nesting depth |
| `max_string_size` | 1,048,576 (1 MiB) | Maximum size of any single string |

### Additional Restrictions

- **No imports**: `import` statements are blocked. Each script is self-contained.
- **Pure functions**: Rhai `fn` definitions cannot access top-level `let`/`const`
  variables. Use function parameters or `scrapwire::store`/`scrapwire::load` for
  shared state.
- **Rate limiting**: `scrapwire::send_conversation()` is limited to 10 calls per second per
  script. Query functions (`conversations`, `members`, `me`, `conversation_get`)
  are not rate-limited.
- **Error isolation**: A runtime error in one script does not affect other
  scripts. Errors in `create_user_menu` cause that script's items to be silently
  skipped.
