diff --git a/Dockerfile b/Dockerfile index a00d642..8684fea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,8 +14,11 @@ FROM python:3.12-slim-bookworm WORKDIR /app +# ripgrep backs the agent shell tool's content search; grep stays as a +# fallback. ca-certificates and libstdc++6 are runtime dependencies. RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates libstdc++6 \ + && apt-get install -y --no-install-recommends \ + ca-certificates libstdc++6 ripgrep \ && rm -rf /var/lib/apt/lists/* # The Node runtime, lifted from the build image, runs the agent sidecar. diff --git a/README.md b/README.md index f40502b..e26a564 100644 --- a/README.md +++ b/README.md @@ -36,19 +36,17 @@ runs the test suite. - **File workspace** -- the `files.*` and `shell.run` tools give the model a private scratch directory, one per server (per user in a DM). It is sandboxed: paths cannot escape it, files and total size are capped, and the - shell runs only an allowlist of read-only commands. Configured with the - `WORKSPACE_*` variables; turn it off entirely with `WORKSPACE_ENABLED`. + shell runs only an allowlist of read-only commands -- it searches with + ripgrep, falling back to grep only when ripgrep cannot serve. Configured + with the `WORKSPACE_*` variables; turn it off entirely with + `WORKSPACE_ENABLED`. - **Lua plugins** -- a full plugin system. A plugin is one `.lua` file that can register prefix commands, agent tools, background loops and event handlers, and reach out through an HTTP client, a Discord read/write API, - document and key/value stores, and JSON utilities. Plugins install from a - GitHub marketplace, survive restarts, and are managed live with - `.ai plugins`. -- **Productivity** -- private notes, tasks organised into to-do lists, and - calendar events with reminders, all delivered as bundled Lua plugins. - Personal items stay private (answered in your DMs); groups let members - share and collaborate, and any item can be shared, copied, moved or - transferred between users and groups. + document and key/value stores, and JSON utilities. The `coinflip` plugin + ships bundled as a worked example; more plugins -- a notes, tasks, events + and groups productivity suite among them -- install from a GitHub + marketplace, survive restarts, and are managed live with `.ai plugins`. - **Memory sidecar** -- long-term facts and episodes, passive learning in opted-in channels, and an append-only training corpus of every turn. - **Thread or inline replies** -- each member picks their style with @@ -73,43 +71,15 @@ commands require the Manage Server permission. | `.arch ctx [@user\|server\|clear]` | everyone | Inspect / wipe learned context. | | `.arch save` / `saved` / `unsave` | everyone | Bookmark Archimedes answers. | | `.arch optin` / `optout` | everyone | AI context tracking. | -| `.note` | everyone | Private notes (answered in your DMs). | -| `.task` | everyone | Tasks and to-do lists, with optional reminders. | -| `.event` | everyone | Calendar events with optional reminders. | -| `.group` | everyone | Create groups, invite members, share and transfer items. | -| `.coinflip` | everyone | Flip a coin (the example plugin). | +| `.coinflip` | everyone | Flip a coin (the bundled example plugin). | | `.ai` | Manage Server | The AI control surface (see `.ai help`). | | `.ai plugins` | Manage Server | Install, update, enable and disable Lua plugins. | | `/help` or `.help` | everyone | A menu of sections, every command with examples. | | `.ping` / `.about` | everyone | Latency and bot info. | -The `.note`, `.task`, `.event`, `.group` and `.coinflip` commands are not -built in -- they come from bundled Lua plugins (see **Lua plugins** below). - -### Productivity, privacy and groups - -Notes, tasks and events each have an owner. Personal items are yours alone: -the bot replies in your DMs and tidies the command message away, and personal -data follows you across every server. Use `.note share @user [edit]` to -let specific people see one of your items. - -A **group** is a shared space. Create one with `.group create `, invite -members with `.group invite @user`, and they accept with -`.group join `. Every member can see and edit the group's items, and group -responses post in the channel so members see them. You can be in many groups. - -Targeting and moving items: - -- `#` at the start of an `add` / `list` argument targets a group - (for example `.note add #5 Buy supplies`); no `#` means your personal space. -- `~` targets a task list (`.task add ~shopping milk`); the default - list is `general`. -- `.note copy ` and `.note move ` accept `me`, an - `@user`, or `#` as the destination. `.group duplicate ` clones - a whole group's items into a fresh group you own. -- Reminders: `.task remind in 2h` or `.event remind 2026-06-01 14:30`. - Times accept relative offsets (`in 30m`, `in 3d`, `in 1w`) or absolute - `YYYY-MM-DD [HH:MM]` in UTC; a one-minute loop DMs you when one falls due. +The `.coinflip` command is not built in -- it comes from the bundled +`coinflip` Lua plugin (see **Lua plugins** below). Productivity commands like +`.note`, `.task`, `.event` and `.group` install from the plugin marketplace. ## Setup @@ -161,15 +131,15 @@ A plugin is a single `.lua` file. It can register prefix commands (with nested subcommand groups), agent tools the model can call, and background loops -- with no Python. -Files in `plugins/` are **bundled** plugins, loaded on every boot: `notes`, -`tasks`, `events` and `groups` are the productivity suite, and `coinflip` is -a worked example. `plugins/README.md` documents the plugin contract and the -`arch` / `ctx` API; the per-plugin document store means a plugin never writes -SQL. +The `plugins/` directory holds the **bundled** plugins, loaded on every +boot. Only `coinflip` ships bundled -- a small, complete worked example. +`plugins/README.md` documents the plugin contract and the `arch` / `ctx` +API; the per-plugin document store means a plugin never writes SQL. More plugins install from a marketplace -- an ordinary GitHub repository -(`hilleywyn/archimedes-plugins` by default). Server moderators manage every -plugin with `.ai plugins`: +(`hilleywyn/archimedes-plugins` by default) -- among them a notes, tasks, +events and groups productivity suite. Server moderators manage every plugin +with `.ai plugins`: | Command | What | |---|---| @@ -260,7 +230,7 @@ ai/ model client, memory, traits, context, tools, safety cogs/ chat brain, .arch, .ai admin, sidecar, meta agent-sidecar/ the OpenRouter Agent SDK service (TypeScript / Node) database/schema.sql idempotent schema, applied on boot -plugins/ bundled Lua plugins (notes, tasks, events, groups, coinflip) +plugins/ bundled Lua plugins (coinflip, the worked example) tests/ offline smoke tests .github/workflows/ CI (lint + tests) Dockerfile container build diff --git a/ai/tools.py b/ai/tools.py index eeb6f5f..b58d224 100644 --- a/ai/tools.py +++ b/ai/tools.py @@ -677,8 +677,11 @@ def _register_workspace_tools(reg: ToolRegistry) -> None: reg.register(ToolSpec( "shell.run", "Run a single read-only shell command inside your sandboxed " - "workspace, for example ls, cat, grep, find, wc, head, tail or " - "sort. Commands take a file path as a direct argument -- " + "workspace, for example ls, cat, rg, find, wc, head, tail or " + "sort. To search file contents always use rg (ripgrep) -- it is " + "the preferred search tool; only fall back to grep, egrep or " + "fgrep if ripgrep fails or genuinely cannot do what you need. " + "Commands take a file path as a direct argument -- " "'sort -r data.txt', 'wc -l data.txt' -- so you do NOT need " "pipes or redirects, which are not supported. To save a " "command's output to a file, pass save_to instead of using '>'. " @@ -687,6 +690,7 @@ def _register_workspace_tools(reg: ToolRegistry) -> None: {"type": "object", "properties": { "command": {"type": "string", "description": "The command line to run, e.g. " + "'rg TODO src' or " "'sort -r data.txt'."}, "save_to": {"type": "string", "description": "Optional workspace-relative file " diff --git a/ai/workspace.py b/ai/workspace.py index 36ec528..dda90b4 100644 --- a/ai/workspace.py +++ b/ai/workspace.py @@ -371,10 +371,12 @@ def delete_file(ctx, path: str) -> dict: # ── allowlist shell tool ────────────────────────────────────────────────────── # Read-only commands only. The command is executed directly (no shell), so a # pipe or redirect is never interpreted -- it is just a literal argument. +# rg (ripgrep) is the preferred content search; grep, egrep and fgrep stay on +# the allowlist only as a fallback for the rare case ripgrep cannot serve. _SHELL_ALLOWLIST = frozenset({ - "ls", "cat", "head", "tail", "wc", "grep", "egrep", "fgrep", "find", - "pwd", "echo", "date", "stat", "sort", "uniq", "cut", "tr", "nl", - "basename", "dirname", "du", "diff", + "ls", "cat", "head", "tail", "wc", "rg", "grep", "egrep", "fgrep", + "find", "pwd", "echo", "date", "stat", "sort", "uniq", "cut", "tr", + "nl", "basename", "dirname", "du", "diff", }) # find actions (and their write-to-file siblings) run another program or # write outside a relative path -- never allowed, whatever the command is. diff --git a/cogs/meta.py b/cogs/meta.py index b610c0c..70d0270 100644 --- a/cogs/meta.py +++ b/cogs/meta.py @@ -54,7 +54,7 @@ def build_help_categories(p: str) -> dict[str, list[discord.Embed]]: f"**Chat** -- `@`mention Archimedes or reply to one of its " f"messages.\n" f"**Commands** -- prefix commands start with `{p}` " - f"(for example `{p}note`, `{p}task`).", + f"(for example `{p}help`, `{p}coinflip`).", ) .field( "Bot meta", @@ -64,9 +64,9 @@ def build_help_categories(p: str) -> dict[str, list[discord.Embed]]: ) .field( "Good to know", - "Personal notes, tasks and events are private: Archimedes " - "answers them in your DMs and tidies the command away. Group " - "items are shared and answered in the channel.", + "Archimedes can be extended with Lua plugins that add extra " + "commands and tools. Server moderators install them; see the " + "Plugins section below.", ), ], "Getting started") @@ -171,31 +171,25 @@ def build_help_categories(p: str) -> dict[str, list[discord.Embed]]: "Plugins", color=C_TEAL, description=( - "Most of what Archimedes does for you -- notes, tasks, " - "calendar events, shareable groups -- is delivered by Lua " - "plugins. The Notes, Tasks, Events and Groups sections of " - "this menu are generated live from the plugins installed " - "right now, so the help always matches what is loaded." + "Archimedes is extended with Lua plugins -- each one adds " + "prefix commands, agent tools the model can call, or both. " + "The `coinflip` plugin ships built in as a worked example; " + "this menu grows a live section for every plugin that is " + "loaded, so the help always matches what is installed." ), ) .field( - "Scope tokens", - f"`#` at the start of an `add` / `list` argument files " - f"the item in a group; no `#` means your personal space.\n" - f"`~` picks a task list (the default list is `general`).\n" - f"Example: `{p}task add #5 ~launch ship the build`", + "What a plugin adds", + "A plugin can register prefix commands you run yourself and " + "agent tools Archimedes calls for you mid-conversation. Every " + "loaded plugin gets its own section in this menu.", ) .field( - "Private vs shared", - "Personal notes, tasks and events are answered in your DMs and " - "the command message is tidied away. Group items are shared with " - "every member and answered in the channel.", - ) - .field( - "Time formats", - "Relative: `in 30m`, `in 2h`, `in 3d`, `in 1w`. " - "Absolute (UTC): `2026-06-01` or `2026-06-01 14:30`. " - "Tasks and events can carry a reminder that DMs you when due.", + "The marketplace", + f"Beyond the built-in `coinflip`, more plugins -- a notes, " + f"tasks, events and groups productivity suite among them -- " + f"install from the marketplace. Browse it with " + f"`{p}ai plugins search`.", ) .field( "Managing plugins", @@ -258,8 +252,8 @@ def _catalogue(self) -> dict[str, list[discord.Embed]]: """The static help plus a live section for every loaded plugin. Plugin sections slot in right after the static ``Plugins`` page so - the menu reads: built-in topics, then one section per installed - plugin (Notes, Tasks, ...), then the staff controls. + the menu reads: built-in topics, then one section per loaded plugin + (coinflip, plus anything installed), then the staff controls. """ prefix = Config.PREFIX static = build_help_categories(prefix) diff --git a/plugins/README.md b/plugins/README.md index 0fb8ae6..e8d9ac7 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -5,7 +5,8 @@ nested subcommands), agent tools the model can call, and background loops, without touching any Python. Files in this directory are **bundled** plugins: they ship with the bot and -are loaded on every boot. More plugins can be installed from the marketplace +are loaded on every boot. Only `coinflip` ships bundled -- it is the worked +example installed out of the box. More plugins install from the marketplace with `.ai plugins install `. ## The plugin contract @@ -303,6 +304,7 @@ picture can post it by replying with a card whose `image` is the result URL. ## Trying it -See `coinflip.lua` for a complete worked example, and the `notes`, `tasks`, -`events` and `groups` plugins for a full suite that shares one namespace. -After editing a bundled file, run `.ai plugins reload `. +See `coinflip.lua` for a complete worked example. After editing a bundled +file, run `.ai plugins reload `. More plugins -- including a notes, +tasks, events and groups productivity suite that shares one namespace -- +install from the marketplace with `.ai plugins install `. diff --git a/plugins/events.lua b/plugins/events.lua deleted file mode 100644 index 92aa0dc..0000000 --- a/plugins/events.lua +++ /dev/null @@ -1,462 +0,0 @@ --- events.lua -- the Events plugin for Archimedes. --- --- Calendar events backed by the shared `productivity` document store. Every --- event has a scheduled time; tasks and events can carry a reminder, and a --- one-minute loop DMs the owner (or every group member) when one falls due. --- --- Standalone plugin: registers the `.event` command group and a reminder --- loop. It shares the `productivity` namespace with notes, tasks and groups. - -local PAGE = 10 - -local M = {} - -M.manifest = { - id = "events", - name = "Events", - version = "1.0.0", - description = "Calendar events with reminders, sharing and groups.", - author = "HiLleywyn", - category = "Productivity", - storage = "productivity", -} - -local TIME_HINT = "Could not read that time. Try `in 2h`, `in 3d`, or " - .. "`2026-06-01 14:30`." - --- ── helpers ────────────────────────────────────────────────────────────────── -local function is_member(grp, user_id) - for _, uid in ipairs(grp.members or {}) do - if uid == user_id then return true end - end - return false -end - -local function scope_for(ctx, group_id) - if not group_id then - return "user", ctx.author_id - end - local grp = ctx.store.get("groups", group_id) - if not grp then - return nil, nil, "There is no group #" .. group_id .. "." - end - if not is_member(grp, ctx.author_id) then - return nil, nil, "You are not a member of group #" .. group_id .. "." - end - return "group", group_id -end - -local function require_event(ctx, item_id, need_edit) - if not item_id then - return nil, false, "Give the event id." - end - local item = ctx.store.get("items", item_id) - if not item or item.kind ~= "event" then - return nil, false, "There is no event #" .. tostring(item_id) .. "." - end - if item.owner_kind == "group" then - local grp = ctx.store.get("groups", item.owner_id) - if not grp or not is_member(grp, ctx.author_id) then - return nil, false, "That event belongs to a group you are not in." - end - return item, true - end - if item.owner_id == ctx.author_id then - return item, true - end - for _, share in ipairs(item.shares or {}) do - if share.user == ctx.author_id then - if need_edit and not share.can_edit then - return nil, false, "That event is shared with you as view-only." - end - return item, share.can_edit and true or false - end - end - return nil, false, "You do not have access to that event." -end - -local function split_title(text) - local title = text:match("^([^\n]*)") or "" - local body = text:match("^[^\n]*\n(.*)$") or "" - return title:sub(1, 300), body -end - -local function first_mention(ctx) - for _, m in ipairs(ctx.mentions) do - if not m.bot then return m end - end - return nil -end - -local function dest_label(ctx, kind, id) - if kind == "group" then return "group #" .. id end - if id == ctx.author_id then return "your personal space" end - return ctx.user_name(id) -end - -local function parse_dest(ctx, rest) - local first = (rest or ""):match("^%s*(%S+)") - if first then - local low = first:lower() - if low == "me" or low == "self" or low == "mine" then - return "user", ctx.author_id - end - local gid = first:match("^#(%d+)$") - if gid then - local kind, oid, err = scope_for(ctx, gid) - if err then return nil, nil, err end - return kind, oid - end - end - local mention = first_mention(ctx) - if mention then return "user", mention.id end - return nil, nil, "Destination must be `me`, an @mention, or `#`." -end - -local function by_time(a, b) - return (a.due_at or 0) < (b.due_at or 0) -end - -local function list_pages(title, items, empty) - if #items == 0 then - return { { title = title, description = empty, color = arch.colors.neutral } } - end - table.sort(items, by_time) - local pages = {} - for start = 1, #items, PAGE do - local lines = {} - for i = start, math.min(start + PAGE - 1, #items) do - local it = items[i] - lines[#lines + 1] = "`#" .. it.id .. "` " .. arch.clip(it.title, 70) - .. " -- " .. arch.fmt_time(it.due_at) - end - pages[#pages + 1] = { - title = title, color = arch.colors.gold, - description = table.concat(lines, "\n"), - footer = #items .. " event(s)", - } - end - return pages -end - --- ── command handlers ───────────────────────────────────────────────────────── -local function do_list(ctx) - local s = arch.sigils(ctx.args) - local owner_kind, owner_id, err = scope_for(ctx, s.group) - if err then ctx.error(err) return end - local items = ctx.store.query("items", { - kind = "event", owner_kind = owner_kind, owner_id = owner_id, - }) - local where = owner_kind == "user" and "Your events" - or ("Group #" .. owner_id .. " events") - ctx.deliver( - list_pages(where, items, - "No events here yet. Add one with `" .. ctx.prefix .. "event add`."), - { private = owner_kind ~= "group" }) -end - -local function do_add(ctx) - local s = arch.sigils(ctx.args) - local when_raw, title_raw = s.text:match("^(.-)|(.*)$") - if not when_raw or title_raw:gsub("%s", "") == "" then - ctx.error("Use `event add | `, for example " - .. "`event add in 2d | Team sync`.") - return - end - local epoch = arch.parse_time((when_raw:gsub("^%s+", ""):gsub("%s+$", ""))) - if not epoch then ctx.error(TIME_HINT) return end - local owner_kind, owner_id, err = scope_for(ctx, s.group) - if err then ctx.error(err) return end - local title, body = split_title((title_raw:gsub("^%s+", ""))) - local id = ctx.store.put("items", { - kind = "event", owner_kind = owner_kind, owner_id = owner_id, - title = title, body = body, due_at = epoch, shares = {}, - created_by = ctx.author_id, created_at = arch.now(), - }) - ctx.deliver({ - title = "Event added", color = arch.colors.success, - description = "Saved event `#" .. id .. "` for " .. arch.fmt_time(epoch) - .. ". Add a reminder with `" .. ctx.prefix .. "event remind " .. id - .. " <when>`.", - }, { private = owner_kind ~= "group" }) -end - -local function do_show(ctx) - local id = ctx.args:match("^%s*(%S+)") - local item, _, err = require_event(ctx, id, false) - if err then ctx.error(err) return end - local fields = {} - if item.body and item.body ~= "" then - fields[#fields + 1] = { name = "Details", value = arch.clip(item.body, 1024) } - end - fields[#fields + 1] = { name = "Event time", - value = arch.fmt_time(item.due_at), inline = true } - if item.remind_at then - fields[#fields + 1] = { - name = "Reminder", - value = arch.fmt_time(item.remind_at) - .. (item.reminded and " (sent)" or " (scheduled)"), - inline = true, - } - end - ctx.deliver({ - title = "Event #" .. item.id .. ": " .. arch.clip(item.title, 200), - color = arch.colors.gold, fields = fields, - footer = "event #" .. item.id, - }, { private = item.owner_kind ~= "group" }) -end - -local function do_when(ctx) - local id, when = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_event(ctx, id, true) - if err then ctx.error(err) return end - local epoch = arch.parse_time(when or "") - if not epoch then ctx.error(TIME_HINT) return end - item.due_at = epoch - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Rescheduled", color = arch.colors.success, - description = "Event `#" .. item.id .. "` is now " .. arch.fmt_time(epoch) - .. ".", - }, { private = item.owner_kind ~= "group" }) -end - -local function do_remind(ctx) - local id, when = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_event(ctx, id, true) - if err then ctx.error(err) return end - when = (when or ""):gsub("^%s+", ""):gsub("%s+$", "") - local low = when:lower() - if when == "" or low == "clear" or low == "off" or low == "none" then - item.remind_at = nil - item.reminded = false - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Reminder cleared", color = arch.colors.success, - description = "Cleared the reminder on event `#" .. item.id .. "`.", - }, { private = item.owner_kind ~= "group" }) - return - end - local epoch = arch.parse_time(when) - if not epoch then ctx.error(TIME_HINT) return end - item.remind_at = epoch - item.reminded = false - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Reminder set", color = arch.colors.success, - description = "I will remind about event `#" .. item.id .. "` at " - .. arch.fmt_time(epoch) .. ".", - }, { private = item.owner_kind ~= "group" }) -end - -local function do_edit(ctx) - local id, text = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_event(ctx, id, true) - if err then ctx.error(err) return end - if not text or text:gsub("%s", "") == "" then - ctx.error("Give the new text after the id.") - return - end - item.title, item.body = split_title(text) - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Updated", color = arch.colors.success, - description = "Edited event `#" .. item.id .. "`.", - }, { private = item.owner_kind ~= "group" }) -end - -local function do_del(ctx) - local id = ctx.args:match("^%s*(%S+)") - local item, _, err = require_event(ctx, id, true) - if err then ctx.error(err) return end - ctx.store.delete("items", item.id) - ctx.deliver({ - title = "Deleted", color = arch.colors.success, - description = "Removed event `#" .. item.id .. "`.", - }, { private = item.owner_kind ~= "group" }) -end - -local function do_share(ctx) - local id = ctx.args:match("^%s*(%S+)") - local item, _, err = require_event(ctx, id, false) - if err then ctx.error(err) return end - if item.owner_kind ~= "user" or item.owner_id ~= ctx.author_id then - ctx.error("You can only share your own personal events.") - return - end - local target = first_mention(ctx) - if not target then ctx.error("Mention the user to share it with.") return end - if target.id == ctx.author_id then - ctx.error("You already own that event.") - return - end - local can_edit = ctx.args:lower():find("edit", 1, true) ~= nil - item.shares = item.shares or {} - local found = false - for _, share in ipairs(item.shares) do - if share.user == target.id then - share.can_edit = can_edit - found = true - end - end - if not found then - item.shares[#item.shares + 1] = { user = target.id, can_edit = can_edit } - end - ctx.store.update("items", item.id, item) - local access = can_edit and "view and edit" or "view" - arch.dm(target.id, { - title = "An event was shared with you", color = arch.colors.info, - description = ctx.author_name .. " shared event `#" .. item.id .. "` (" - .. arch.clip(item.title, 120) .. ") with you (" .. access .. ").", - }) - ctx.deliver({ - title = "Shared", color = arch.colors.success, - description = "Event `#" .. item.id .. "` is now shared with " - .. target.name .. " (" .. access .. ").", - }, { private = true }) -end - -local function do_unshare(ctx) - local id = ctx.args:match("^%s*(%S+)") - local item, _, err = require_event(ctx, id, false) - if err then ctx.error(err) return end - if item.owner_kind ~= "user" or item.owner_id ~= ctx.author_id then - ctx.error("You can only unshare your own events.") - return - end - local target = first_mention(ctx) - if not target then - ctx.error("Mention the user to stop sharing with.") - return - end - local kept, removed = {}, false - for _, share in ipairs(item.shares or {}) do - if share.user == target.id then - removed = true - else - kept[#kept + 1] = share - end - end - if not removed then - ctx.error(target.name .. " did not have access to that event.") - return - end - item.shares = kept - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Unshared", color = arch.colors.success, - description = target.name .. " can no longer see event `#" .. item.id - .. "`.", - }, { private = true }) -end - -local function do_copy(ctx) - local id, rest = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_event(ctx, id, false) - if err then ctx.error(err) return end - local kind, oid, derr = parse_dest(ctx, rest) - if derr then ctx.error(derr) return end - local new_id = ctx.store.put("items", { - kind = "event", owner_kind = kind, owner_id = oid, - title = item.title, body = item.body or "", due_at = item.due_at, - shares = {}, created_by = ctx.author_id, created_at = arch.now(), - }) - ctx.deliver({ - title = "Copied", color = arch.colors.success, - description = "Event copied to " .. dest_label(ctx, kind, oid) - .. " as `#" .. new_id .. "`.", - }, { private = kind ~= "group" }) -end - -local function do_move(ctx) - local id, rest = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_event(ctx, id, true) - if err then ctx.error(err) return end - local kind, oid, derr = parse_dest(ctx, rest) - if derr then ctx.error(derr) return end - if kind == item.owner_kind and oid == item.owner_id then - ctx.error("That event is already there.") - return - end - item.owner_kind, item.owner_id, item.shares = kind, oid, {} - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Moved", color = arch.colors.success, - description = "Event `#" .. item.id .. "` moved to " - .. dest_label(ctx, kind, oid) .. ".", - }, { private = kind ~= "group" }) -end - --- ── reminder loop ──────────────────────────────────────────────────────────── -local function fire_reminders() - local now = arch.now() - for _, item in ipairs(arch.store.query("items", { kind = "event" })) do - if item.remind_at and not item.reminded and item.remind_at <= now then - local recipients - if item.owner_kind == "group" then - local grp = arch.store.get("groups", item.owner_id) - recipients = grp and (grp.members or {}) or {} - else - recipients = { item.owner_id } - end - local fields = {} - if item.body and item.body ~= "" then - fields[#fields + 1] = { name = "Details", value = arch.clip(item.body, 1024) } - end - if item.due_at then - fields[#fields + 1] = { name = "Scheduled", - value = arch.fmt_time(item.due_at), inline = true } - end - local note = { - title = "Event reminder", color = arch.colors.gold, - description = "**" .. arch.clip(item.title, 240) .. "**", - fields = fields, footer = "event #" .. item.id, - } - for _, uid in ipairs(recipients) do - arch.dm(uid, note) - end - item.reminded = true - arch.store.update("items", item.id, item) - end - end -end - --- ── command tree ───────────────────────────────────────────────────────────── -M.commands = { - { - name = "event", aliases = { "events", "cal", "calendar" }, - summary = "Calendar events with reminders.", - run = do_list, - subcommands = { - { name = "add", aliases = { "new", "create" }, - usage = "add [#group] <when> | <title>", - summary = "Add an event (time before the `|`).", run = do_add }, - { name = "list", aliases = { "ls", "all" }, usage = "list [#group]", - summary = "List your events, or a group's events.", run = do_list }, - { name = "show", aliases = { "view", "open" }, usage = "show <id>", - summary = "Open a single event.", run = do_show }, - { name = "when", aliases = { "reschedule" }, usage = "when <id> <when>", - summary = "Reschedule an event.", run = do_when }, - { name = "remind", usage = "remind <id> <when|clear>", - summary = "Set or clear an event reminder.", run = do_remind }, - { name = "edit", usage = "edit <id> <text>", - summary = "Replace an event's text.", run = do_edit }, - { name = "del", aliases = { "delete", "rm", "remove" }, usage = "del <id>", - summary = "Delete an event.", run = do_del }, - { name = "share", usage = "share <id> @user [edit]", - summary = "Share a personal event with a user.", run = do_share }, - { name = "unshare", usage = "unshare <id> @user", - summary = "Stop sharing an event with a user.", run = do_unshare }, - { name = "copy", usage = "copy <id> <me|@user|#group>", - summary = "Copy an event somewhere else.", run = do_copy }, - { name = "move", usage = "move <id> <me|@user|#group>", - summary = "Move an event somewhere else.", run = do_move }, - }, - }, -} - -M.loops = { - { name = "reminders", interval = 60, run = fire_reminders }, -} - -return M diff --git a/plugins/groups.lua b/plugins/groups.lua deleted file mode 100644 index 181d530..0000000 --- a/plugins/groups.lua +++ /dev/null @@ -1,443 +0,0 @@ --- groups.lua -- the Groups plugin for Archimedes. --- --- A group is a shared space: every member can see and edit the group's --- notes, tasks and events, and group replies post in the channel. Groups --- carry an owner, a member list and pending invitations, all stored as --- documents in the shared `productivity` namespace. --- --- Standalone plugin: registers the `.group` command group. The notes, tasks --- and events plugins read this plugin's `groups` collection to resolve a --- `#<groupid>` scope token, so the four plugins work as a suite. - -local M = {} - -M.manifest = { - id = "groups", - name = "Groups", - version = "1.0.0", - description = "Shared groups for notes, tasks and events.", - author = "HiLleywyn", - category = "Productivity", - storage = "productivity", -} - --- ── helpers ────────────────────────────────────────────────────────────────── -local function trim(s) - return (s or ""):gsub("^%s+", ""):gsub("%s+$", "") -end - -local function is_member(grp, user_id) - for _, uid in ipairs(grp.members or {}) do - if uid == user_id then return true end - end - return false -end - -local function first_mention(ctx) - for _, m in ipairs(ctx.mentions) do - if not m.bot then return m end - end - return nil -end - -local function owned_group(ctx, group_id) - if not group_id then - return nil, "Give the group id." - end - local grp = ctx.store.get("groups", group_id) - if not grp then - return nil, "There is no group #" .. tostring(group_id) .. "." - end - if grp.owner_id ~= ctx.author_id then - return nil, "Only the group owner can do that." - end - return grp -end - --- ── command handlers ───────────────────────────────────────────────────────── -local function do_list(ctx) - local groups = ctx.store.query("groups", { members = { ctx.author_id } }) - if #groups == 0 then - ctx.deliver({ - title = "Your groups", color = arch.colors.blurple, - description = "You are not in any groups yet. Create one with `" - .. ctx.prefix .. "group create <name>`.", - }, { private = true }) - return - end - local lines = {} - for _, g in ipairs(groups) do - local role = g.owner_id == ctx.author_id and "owner" or "member" - lines[#lines + 1] = "`#" .. g.id .. "` " .. g.name .. " -- " .. role - end - ctx.deliver({ - title = "Your groups", color = arch.colors.blurple, - description = table.concat(lines, "\n"), - footer = ctx.prefix .. "group show <id> for details " - .. ctx.prefix .. "group invites for pending invites", - }, { private = true }) -end - -local function do_create(ctx) - local name = trim(ctx.args) - if name == "" then ctx.error("Give the group a name.") return end - for _, g in ipairs(ctx.store.query("groups", { members = { ctx.author_id } })) do - if g.name:lower() == name:lower() then - ctx.error("You are already in a group called `" .. name .. "`.") - return - end - end - local id = ctx.store.put("groups", { - guild_id = ctx.guild_id, name = name:sub(1, 100), - owner_id = ctx.author_id, members = { ctx.author_id }, invites = {}, - created_at = arch.now(), - }) - ctx.deliver({ - title = "Group created", color = arch.colors.success, - description = "`" .. name .. "` is group `#" .. id .. "`. Invite members " - .. "with `" .. ctx.prefix .. "group invite " .. id .. " @user`.", - }, { private = true }) -end - -local function do_show(ctx) - local gid = ctx.args:match("^%s*(%S+)") - if not gid then ctx.error("Give the group id.") return end - local grp = ctx.store.get("groups", gid) - if not grp then ctx.error("There is no group #" .. gid .. ".") return end - if not is_member(grp, ctx.author_id) then - ctx.error("Only members can view that group.") - return - end - local counts = { note = 0, task = 0, event = 0 } - for _, it in ipairs(ctx.store.query("items", - { owner_kind = "group", owner_id = gid })) do - counts[it.kind] = (counts[it.kind] or 0) + 1 - end - local member_lines = {} - for _, uid in ipairs(grp.members or {}) do - local tag = uid == grp.owner_id and " (owner)" or "" - member_lines[#member_lines + 1] = "- " .. ctx.user_name(uid) .. tag - end - ctx.deliver({ - title = "Group " .. grp.name .. " (#" .. gid .. ")", - color = arch.colors.blurple, - fields = { - { name = "Members", value = table.concat(member_lines, "\n") }, - { name = "Notes", value = tostring(counts.note or 0), inline = true }, - { name = "Tasks", value = tostring(counts.task or 0), inline = true }, - { name = "Events", value = tostring(counts.event or 0), inline = true }, - }, - footer = ctx.prefix .. "note list #" .. gid .. " " - .. ctx.prefix .. "task list #" .. gid .. " " - .. ctx.prefix .. "event list #" .. gid, - }, { private = false }) -end - -local function do_invite(ctx) - local gid = ctx.args:match("^%s*(%S+)") - local grp, err = owned_group(ctx, gid) - if err then ctx.error(err) return end - local target = first_mention(ctx) - if not target then ctx.error("Mention the user to invite.") return end - if target.bot then ctx.error("You cannot invite a bot.") return end - if is_member(grp, target.id) then - ctx.error(target.name .. " is already in that group.") - return - end - grp.invites = grp.invites or {} - local found = false - for _, inv in ipairs(grp.invites) do - if inv.invitee == target.id then - inv.inviter = ctx.author_id - found = true - end - end - if not found then - grp.invites[#grp.invites + 1] = - { invitee = target.id, inviter = ctx.author_id } - end - ctx.store.update("groups", grp.id, grp) - arch.dm(target.id, { - title = "Group invitation", color = arch.colors.info, - description = ctx.author_name .. " invited you to the group `" .. grp.name - .. "` (#" .. grp.id .. "). Accept with `" .. ctx.prefix .. "group join " - .. grp.id .. "` or decline with `" .. ctx.prefix .. "group decline " - .. grp.id .. "`.", - }) - ctx.deliver({ - title = "Invite sent", color = arch.colors.success, - description = "Invited " .. target.name .. " to `" .. grp.name .. "`.", - }, { private = true }) -end - -local function do_invites(ctx) - local groups = ctx.store.query("groups", - { invites = { { invitee = ctx.author_id } } }) - if #groups == 0 then - ctx.deliver({ - title = "Your group invites", color = arch.colors.blurple, - description = "You have no pending group invitations.", - }, { private = true }) - return - end - local lines = {} - for _, g in ipairs(groups) do - local inviter = "someone" - for _, inv in ipairs(g.invites or {}) do - if inv.invitee == ctx.author_id then - inviter = ctx.user_name(inv.inviter) - end - end - lines[#lines + 1] = "`#" .. g.id .. "` " .. g.name .. " -- from " .. inviter - end - ctx.deliver({ - title = "Your group invites", color = arch.colors.blurple, - description = table.concat(lines, "\n"), - footer = ctx.prefix .. "group join <id> to accept", - }, { private = true }) -end - -local function do_join(ctx) - local gid = ctx.args:match("^%s*(%S+)") - if not gid then ctx.error("Give the group id.") return end - local grp = ctx.store.get("groups", gid) - if not grp then ctx.error("There is no group #" .. gid .. ".") return end - local kept, invited = {}, false - for _, inv in ipairs(grp.invites or {}) do - if inv.invitee == ctx.author_id then - invited = true - else - kept[#kept + 1] = inv - end - end - if not invited then - ctx.error("You have no invitation to group #" .. gid .. ".") - return - end - grp.invites = kept - grp.members = grp.members or {} - if not is_member(grp, ctx.author_id) then - grp.members[#grp.members + 1] = ctx.author_id - end - ctx.store.update("groups", grp.id, grp) - ctx.deliver({ - title = "Joined", color = arch.colors.success, - description = "You are now a member of `" .. grp.name .. "`.", - }, { private = true }) -end - -local function do_decline(ctx) - local gid = ctx.args:match("^%s*(%S+)") - if not gid then ctx.error("Give the group id.") return end - local grp = ctx.store.get("groups", gid) - if not grp then ctx.error("There is no group #" .. gid .. ".") return end - local kept, found = {}, false - for _, inv in ipairs(grp.invites or {}) do - if inv.invitee == ctx.author_id then - found = true - else - kept[#kept + 1] = inv - end - end - if not found then - ctx.error("You have no invitation to group #" .. gid .. ".") - return - end - grp.invites = kept - ctx.store.update("groups", grp.id, grp) - ctx.deliver({ - title = "Declined", color = arch.colors.success, - description = "Declined the invite to group #" .. gid .. ".", - }, { private = true }) -end - -local function remove_member(grp, user_id) - local kept, removed = {}, false - for _, uid in ipairs(grp.members or {}) do - if uid == user_id then - removed = true - else - kept[#kept + 1] = uid - end - end - grp.members = kept - return removed -end - -local function do_leave(ctx) - local gid = ctx.args:match("^%s*(%S+)") - if not gid then ctx.error("Give the group id.") return end - local grp = ctx.store.get("groups", gid) - if not grp then ctx.error("There is no group #" .. gid .. ".") return end - if not is_member(grp, ctx.author_id) then - ctx.error("You are not in that group.") - return - end - if grp.owner_id == ctx.author_id then - ctx.error("You own that group. Transfer it with `" .. ctx.prefix - .. "group transfer " .. gid .. " @user` or delete it with `" - .. ctx.prefix .. "group delete " .. gid .. "`.") - return - end - remove_member(grp, ctx.author_id) - ctx.store.update("groups", grp.id, grp) - ctx.deliver({ - title = "Left group", color = arch.colors.success, - description = "You left `" .. grp.name .. "`.", - }, { private = true }) -end - -local function do_kick(ctx) - local gid = ctx.args:match("^%s*(%S+)") - local grp, err = owned_group(ctx, gid) - if err then ctx.error(err) return end - local target = first_mention(ctx) - if not target then ctx.error("Mention the member to remove.") return end - if target.id == ctx.author_id then - ctx.error("You own the group. Use transfer or delete instead.") - return - end - if not remove_member(grp, target.id) then - ctx.error(target.name .. " is not in that group.") - return - end - ctx.store.update("groups", grp.id, grp) - ctx.deliver({ - title = "Member removed", color = arch.colors.success, - description = "Removed " .. target.name .. " from `" .. grp.name .. "`.", - }, { private = true }) -end - -local function do_rename(ctx) - local gid, name = ctx.args:match("^%s*(%S+)%s*(.*)$") - local grp, err = owned_group(ctx, gid) - if err then ctx.error(err) return end - name = trim(name) - if name == "" then ctx.error("Give the new group name.") return end - local old = grp.name - grp.name = name:sub(1, 100) - ctx.store.update("groups", grp.id, grp) - ctx.deliver({ - title = "Group renamed", color = arch.colors.success, - description = "`" .. old .. "` is now `" .. grp.name .. "`.", - }, { private = true }) -end - -local function do_transfer(ctx) - local gid = ctx.args:match("^%s*(%S+)") - local grp, err = owned_group(ctx, gid) - if err then ctx.error(err) return end - local target = first_mention(ctx) - if not target then - ctx.error("Mention the member to hand the group to.") - return - end - if not is_member(grp, target.id) then - ctx.error(target.name .. " must be a group member first.") - return - end - grp.owner_id = target.id - ctx.store.update("groups", grp.id, grp) - ctx.deliver({ - title = "Ownership transferred", color = arch.colors.success, - description = target.name .. " now owns `" .. grp.name .. "`.", - }, { private = true }) -end - -local function do_delete(ctx) - local gid = ctx.args:match("^%s*(%S+)") - local grp, err = owned_group(ctx, gid) - if err then ctx.error(err) return end - if not ctx.confirm("Delete group `" .. grp.name .. "` and all of its notes, " - .. "tasks and events? This cannot be undone.") then - ctx.error("Group deletion cancelled.") - return - end - local items = ctx.store.query("items", - { owner_kind = "group", owner_id = gid }) - for _, it in ipairs(items) do - ctx.store.delete("items", it.id) - end - ctx.store.delete("groups", gid) - ctx.deliver({ - title = "Group deleted", color = arch.colors.success, - description = "`" .. grp.name .. "` and its " .. #items - .. " item(s) are gone.", - }, { private = true }) -end - -local function do_duplicate(ctx) - local gid = ctx.args:match("^%s*(%S+)") - if not gid then ctx.error("Give the group id.") return end - local grp = ctx.store.get("groups", gid) - if not grp then ctx.error("There is no group #" .. gid .. ".") return end - if not is_member(grp, ctx.author_id) then - ctx.error("Only members can duplicate that group.") - return - end - local new_id = ctx.store.put("groups", { - guild_id = grp.guild_id, name = arch.clip(grp.name .. " (copy)", 100), - owner_id = ctx.author_id, members = { ctx.author_id }, invites = {}, - created_at = arch.now(), - }) - local items = ctx.store.query("items", - { owner_kind = "group", owner_id = gid }) - for _, it in ipairs(items) do - ctx.store.put("items", { - kind = it.kind, owner_kind = "group", owner_id = new_id, - title = it.title, body = it.body or "", - list_name = it.list_name or "general", - done = it.done and true or false, due_at = it.due_at, - remind_at = it.remind_at, reminded = false, shares = {}, - created_by = ctx.author_id, created_at = arch.now(), - }) - end - ctx.deliver({ - title = "Group duplicated", color = arch.colors.success, - description = "Copied " .. #items .. " item(s) into new group `#" - .. new_id .. "` (" .. grp.name .. " (copy)). You are the owner.", - }, { private = true }) -end - --- ── command tree ───────────────────────────────────────────────────────────── -M.commands = { - { - name = "group", aliases = { "groups" }, - summary = "Shared groups for notes, tasks and events.", - run = do_list, - subcommands = { - { name = "create", aliases = { "new" }, usage = "create <name>", - summary = "Create a group in this server.", guild_only = true, - run = do_create }, - { name = "list", aliases = { "ls", "mine" }, usage = "list", - summary = "List the groups you belong to.", run = do_list }, - { name = "show", aliases = { "view", "info" }, usage = "show <id>", - summary = "Show a group's members and item counts.", run = do_show }, - { name = "invite", usage = "invite <id> @user", - summary = "Invite a user to a group you own.", run = do_invite }, - { name = "invites", aliases = { "pending" }, usage = "invites", - summary = "List group invitations waiting for you.", run = do_invites }, - { name = "join", aliases = { "accept" }, usage = "join <id>", - summary = "Accept a pending group invitation.", run = do_join }, - { name = "decline", aliases = { "reject" }, usage = "decline <id>", - summary = "Decline a pending group invitation.", run = do_decline }, - { name = "leave", usage = "leave <id>", - summary = "Leave a group you belong to.", run = do_leave }, - { name = "kick", aliases = { "remove" }, usage = "kick <id> @user", - summary = "Remove a member from a group you own.", run = do_kick }, - { name = "rename", usage = "rename <id> <name>", - summary = "Rename a group you own.", run = do_rename }, - { name = "transfer", usage = "transfer <id> @user", - summary = "Hand group ownership to another member.", - run = do_transfer }, - { name = "delete", aliases = { "disband" }, usage = "delete <id>", - summary = "Delete a group you own and everything in it.", - run = do_delete }, - { name = "duplicate", aliases = { "clone" }, usage = "duplicate <id>", - summary = "Clone a group's items into a fresh group.", - run = do_duplicate }, - }, - }, -} - -return M diff --git a/plugins/notes.lua b/plugins/notes.lua deleted file mode 100644 index 15e8887..0000000 --- a/plugins/notes.lua +++ /dev/null @@ -1,368 +0,0 @@ --- notes.lua -- the Notes plugin for Archimedes. --- --- Private and shareable notes backed by the shared `productivity` document --- store. A personal note lives in your DMs and follows you across servers; a --- note filed in a group is shared with every member and answered in channel. --- --- This file is a standalone plugin: it registers the `.note` command group --- and nothing else. Notes, tasks, events and groups all read and write the --- same `productivity` storage namespace, so they interoperate cleanly. - -local PAGE = 10 - -local M = {} - -M.manifest = { - id = "notes", - name = "Notes", - version = "1.0.0", - description = "Private and shared notes, with sharing and groups.", - author = "HiLleywyn", - category = "Productivity", - storage = "productivity", -} - --- ── helpers ────────────────────────────────────────────────────────────────── -local function is_member(grp, user_id) - for _, uid in ipairs(grp.members or {}) do - if uid == user_id then return true end - end - return false -end - -local function scope_for(ctx, group_id) - -- Resolve a `#group` sigil to (owner_kind, owner_id) or an error string. - if not group_id then - return "user", ctx.author_id - end - local grp = ctx.store.get("groups", group_id) - if not grp then - return nil, nil, "There is no group #" .. group_id .. "." - end - if not is_member(grp, ctx.author_id) then - return nil, nil, "You are not a member of group #" .. group_id .. "." - end - return "group", group_id -end - -local function require_note(ctx, item_id, need_edit) - -- Fetch a note the caller may touch, or return (nil, false, error). - if not item_id then - return nil, false, "Give the note id." - end - local item = ctx.store.get("items", item_id) - if not item or item.kind ~= "note" then - return nil, false, "There is no note #" .. tostring(item_id) .. "." - end - if item.owner_kind == "group" then - local grp = ctx.store.get("groups", item.owner_id) - if not grp or not is_member(grp, ctx.author_id) then - return nil, false, "That note belongs to a group you are not in." - end - return item, true - end - if item.owner_id == ctx.author_id then - return item, true - end - for _, share in ipairs(item.shares or {}) do - if share.user == ctx.author_id then - if need_edit and not share.can_edit then - return nil, false, "That note is shared with you as view-only." - end - return item, share.can_edit and true or false - end - end - return nil, false, "You do not have access to that note." -end - -local function split_title(text) - local title = text:match("^([^\n]*)") or "" - local body = text:match("^[^\n]*\n(.*)$") or "" - return title:sub(1, 300), body -end - -local function first_mention(ctx) - for _, m in ipairs(ctx.mentions) do - if not m.bot then return m end - end - return nil -end - -local function owner_label(ctx, item) - if item.owner_kind == "group" then - local grp = ctx.store.get("groups", item.owner_id) - if grp then - return "group " .. grp.name .. " (#" .. item.owner_id .. ")" - end - return "group #" .. item.owner_id - end - if item.owner_id == ctx.author_id then return "you" end - return "shared with you" -end - -local function dest_label(ctx, kind, id) - if kind == "group" then return "group #" .. id end - if id == ctx.author_id then return "your personal space" end - return ctx.user_name(id) -end - -local function parse_dest(ctx, rest) - local first = (rest or ""):match("^%s*(%S+)") - if first then - local low = first:lower() - if low == "me" or low == "self" or low == "mine" then - return "user", ctx.author_id - end - local gid = first:match("^#(%d+)$") - if gid then - local kind, oid, err = scope_for(ctx, gid) - if err then return nil, nil, err end - return kind, oid - end - end - local mention = first_mention(ctx) - if mention then return "user", mention.id end - return nil, nil, "Destination must be `me`, an @mention, or `#<groupid>`." -end - -local function list_pages(title, items, empty) - if #items == 0 then - return { { title = title, description = empty, color = arch.colors.neutral } } - end - local pages = {} - for start = 1, #items, PAGE do - local lines = {} - for i = start, math.min(start + PAGE - 1, #items) do - lines[#lines + 1] = "`#" .. items[i].id .. "` " - .. arch.clip(items[i].title, 80) - end - pages[#pages + 1] = { - title = title, color = arch.colors.info, - description = table.concat(lines, "\n"), - footer = #items .. " note(s)", - } - end - return pages -end - --- ── command handlers ───────────────────────────────────────────────────────── -local function do_list(ctx) - local s = arch.sigils(ctx.args) - local owner_kind, owner_id, err = scope_for(ctx, s.group) - if err then ctx.error(err) return end - local items = ctx.store.query("items", { - kind = "note", owner_kind = owner_kind, owner_id = owner_id, - }) - local where = owner_kind == "user" and "Your notes" - or ("Group #" .. owner_id .. " notes") - ctx.deliver( - list_pages(where, items, - "No notes here yet. Add one with `" .. ctx.prefix .. "note add`."), - { private = owner_kind ~= "group" }) -end - -local function do_add(ctx) - local s = arch.sigils(ctx.args) - if s.text == "" then - ctx.error("Give the note text. The first line becomes the title.") - return - end - local owner_kind, owner_id, err = scope_for(ctx, s.group) - if err then ctx.error(err) return end - local title, body = split_title(s.text) - local id = ctx.store.put("items", { - kind = "note", owner_kind = owner_kind, owner_id = owner_id, - title = title, body = body, shares = {}, - created_by = ctx.author_id, created_at = arch.now(), - }) - ctx.deliver({ - title = "Note added", color = arch.colors.success, - description = "Saved as note `#" .. id .. "`.", - }, { private = owner_kind ~= "group" }) -end - -local function do_show(ctx) - local id = ctx.args:match("^%s*(%S+)") - local item, _, err = require_note(ctx, id, false) - if err then ctx.error(err) return end - local fields = {} - if item.body and item.body ~= "" then - fields[#fields + 1] = { name = "Details", value = arch.clip(item.body, 1024) } - end - fields[#fields + 1] = { name = "Owner", value = owner_label(ctx, item), - inline = true } - ctx.deliver({ - title = "Note #" .. item.id .. ": " .. arch.clip(item.title, 200), - color = arch.colors.info, fields = fields, - footer = "note #" .. item.id, - }, { private = item.owner_kind ~= "group" }) -end - -local function do_edit(ctx) - local id, text = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_note(ctx, id, true) - if err then ctx.error(err) return end - if not text or text:gsub("%s", "") == "" then - ctx.error("Give the new text after the id.") - return - end - item.title, item.body = split_title(text) - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Updated", color = arch.colors.success, - description = "Edited note `#" .. item.id .. "`.", - }, { private = item.owner_kind ~= "group" }) -end - -local function do_del(ctx) - local id = ctx.args:match("^%s*(%S+)") - local item, _, err = require_note(ctx, id, true) - if err then ctx.error(err) return end - ctx.store.delete("items", item.id) - ctx.deliver({ - title = "Deleted", color = arch.colors.success, - description = "Removed note `#" .. item.id .. "`.", - }, { private = item.owner_kind ~= "group" }) -end - -local function do_share(ctx) - local id = ctx.args:match("^%s*(%S+)") - local item, _, err = require_note(ctx, id, false) - if err then ctx.error(err) return end - if item.owner_kind ~= "user" or item.owner_id ~= ctx.author_id then - ctx.error("You can only share your own personal notes. Group notes are " - .. "already shared with every member.") - return - end - local target = first_mention(ctx) - if not target then ctx.error("Mention the user to share it with.") return end - if target.id == ctx.author_id then - ctx.error("You already own that note.") - return - end - local can_edit = ctx.args:lower():find("edit", 1, true) ~= nil - item.shares = item.shares or {} - local found = false - for _, share in ipairs(item.shares) do - if share.user == target.id then - share.can_edit = can_edit - found = true - end - end - if not found then - item.shares[#item.shares + 1] = { user = target.id, can_edit = can_edit } - end - ctx.store.update("items", item.id, item) - local access = can_edit and "view and edit" or "view" - arch.dm(target.id, { - title = "A note was shared with you", color = arch.colors.info, - description = ctx.author_name .. " shared note `#" .. item.id .. "` (" - .. arch.clip(item.title, 120) .. ") with you (" .. access .. ").", - }) - ctx.deliver({ - title = "Shared", color = arch.colors.success, - description = "Note `#" .. item.id .. "` is now shared with " - .. target.name .. " (" .. access .. ").", - }, { private = true }) -end - -local function do_unshare(ctx) - local id = ctx.args:match("^%s*(%S+)") - local item, _, err = require_note(ctx, id, false) - if err then ctx.error(err) return end - if item.owner_kind ~= "user" or item.owner_id ~= ctx.author_id then - ctx.error("You can only unshare your own notes.") - return - end - local target = first_mention(ctx) - if not target then - ctx.error("Mention the user to stop sharing with.") - return - end - local kept, removed = {}, false - for _, share in ipairs(item.shares or {}) do - if share.user == target.id then - removed = true - else - kept[#kept + 1] = share - end - end - if not removed then - ctx.error(target.name .. " did not have access to that note.") - return - end - item.shares = kept - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Unshared", color = arch.colors.success, - description = target.name .. " can no longer see note `#" .. item.id .. "`.", - }, { private = true }) -end - -local function do_copy(ctx) - local id, rest = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_note(ctx, id, false) - if err then ctx.error(err) return end - local kind, oid, derr = parse_dest(ctx, rest) - if derr then ctx.error(derr) return end - local new_id = ctx.store.put("items", { - kind = "note", owner_kind = kind, owner_id = oid, - title = item.title, body = item.body or "", shares = {}, - created_by = ctx.author_id, created_at = arch.now(), - }) - ctx.deliver({ - title = "Copied", color = arch.colors.success, - description = "Note copied to " .. dest_label(ctx, kind, oid) - .. " as `#" .. new_id .. "`.", - }, { private = kind ~= "group" }) -end - -local function do_move(ctx) - local id, rest = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_note(ctx, id, true) - if err then ctx.error(err) return end - local kind, oid, derr = parse_dest(ctx, rest) - if derr then ctx.error(derr) return end - if kind == item.owner_kind and oid == item.owner_id then - ctx.error("That note is already there.") - return - end - item.owner_kind, item.owner_id, item.shares = kind, oid, {} - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Moved", color = arch.colors.success, - description = "Note `#" .. item.id .. "` moved to " - .. dest_label(ctx, kind, oid) .. ".", - }, { private = kind ~= "group" }) -end - --- ── command tree ───────────────────────────────────────────────────────────── -M.commands = { - { - name = "note", aliases = { "notes" }, - summary = "Private and shared notes.", - run = do_list, - subcommands = { - { name = "add", aliases = { "new", "create" }, usage = "add [#group] <text>", - summary = "Add a note (first line is the title).", run = do_add }, - { name = "list", aliases = { "ls", "all" }, usage = "list [#group]", - summary = "List your notes, or a group's notes.", run = do_list }, - { name = "show", aliases = { "view", "open" }, usage = "show <id>", - summary = "Open a single note.", run = do_show }, - { name = "edit", usage = "edit <id> <text>", - summary = "Replace a note's text.", run = do_edit }, - { name = "del", aliases = { "delete", "rm", "remove" }, usage = "del <id>", - summary = "Delete a note.", run = do_del }, - { name = "share", usage = "share <id> @user [edit]", - summary = "Share a personal note with a user.", run = do_share }, - { name = "unshare", usage = "unshare <id> @user", - summary = "Stop sharing a note with a user.", run = do_unshare }, - { name = "copy", usage = "copy <id> <me|@user|#group>", - summary = "Copy a note somewhere else.", run = do_copy }, - { name = "move", usage = "move <id> <me|@user|#group>", - summary = "Move a note somewhere else.", run = do_move }, - }, - }, -} - -return M diff --git a/plugins/tasks.lua b/plugins/tasks.lua deleted file mode 100644 index 874ebcf..0000000 --- a/plugins/tasks.lua +++ /dev/null @@ -1,505 +0,0 @@ --- tasks.lua -- the Tasks plugin for Archimedes. --- --- Tasks organised into named lists, backed by the shared `productivity` --- document store. Tasks can carry a due date and a reminder; a one-minute --- loop DMs the owner (or every group member) when a reminder falls due. --- --- Standalone plugin: registers the `.task` command group and a reminder --- loop. It shares the `productivity` namespace with notes, events and groups. - -local PAGE = 10 - -local M = {} - -M.manifest = { - id = "tasks", - name = "Tasks", - version = "1.0.0", - description = "Tasks and to-do lists, with reminders, sharing and groups.", - author = "HiLleywyn", - category = "Productivity", - storage = "productivity", -} - -local TIME_HINT = "Could not read that time. Try `in 2h`, `in 3d`, or " - .. "`2026-06-01 14:30`." - --- ── helpers ────────────────────────────────────────────────────────────────── -local function trim(s) - return (s or ""):gsub("^%s+", ""):gsub("%s+$", "") -end - -local function is_member(grp, user_id) - for _, uid in ipairs(grp.members or {}) do - if uid == user_id then return true end - end - return false -end - -local function scope_for(ctx, group_id) - if not group_id then - return "user", ctx.author_id - end - local grp = ctx.store.get("groups", group_id) - if not grp then - return nil, nil, "There is no group #" .. group_id .. "." - end - if not is_member(grp, ctx.author_id) then - return nil, nil, "You are not a member of group #" .. group_id .. "." - end - return "group", group_id -end - -local function require_task(ctx, item_id, need_edit) - if not item_id then - return nil, false, "Give the task id." - end - local item = ctx.store.get("items", item_id) - if not item or item.kind ~= "task" then - return nil, false, "There is no task #" .. tostring(item_id) .. "." - end - if item.owner_kind == "group" then - local grp = ctx.store.get("groups", item.owner_id) - if not grp or not is_member(grp, ctx.author_id) then - return nil, false, "That task belongs to a group you are not in." - end - return item, true - end - if item.owner_id == ctx.author_id then - return item, true - end - for _, share in ipairs(item.shares or {}) do - if share.user == ctx.author_id then - if need_edit and not share.can_edit then - return nil, false, "That task is shared with you as view-only." - end - return item, share.can_edit and true or false - end - end - return nil, false, "You do not have access to that task." -end - -local function first_mention(ctx) - for _, m in ipairs(ctx.mentions) do - if not m.bot then return m end - end - return nil -end - -local function dest_label(ctx, kind, id) - if kind == "group" then return "group #" .. id end - if id == ctx.author_id then return "your personal space" end - return ctx.user_name(id) -end - -local function parse_dest(ctx, rest) - local first = (rest or ""):match("^%s*(%S+)") - if first then - local low = first:lower() - if low == "me" or low == "self" or low == "mine" then - return "user", ctx.author_id - end - local gid = first:match("^#(%d+)$") - if gid then - local kind, oid, err = scope_for(ctx, gid) - if err then return nil, nil, err end - return kind, oid - end - end - local mention = first_mention(ctx) - if mention then return "user", mention.id end - return nil, nil, "Destination must be `me`, an @mention, or `#<groupid>`." -end - -local function rank(item) - return item.done and 1 or 0 -end - -local function task_order(a, b) - if rank(a) ~= rank(b) then return rank(a) < rank(b) end - local ad = a.due_at or math.huge - local bd = b.due_at or math.huge - if ad ~= bd then return ad < bd end - return (tonumber(a.id) or 0) < (tonumber(b.id) or 0) -end - -local function task_line(it) - local box = it.done and "[x]" or "[ ]" - local due = it.due_at and (" due " .. arch.fmt_time(it.due_at)) or "" - return "`#" .. it.id .. "` " .. box .. " " .. arch.clip(it.title, 70) .. due -end - -local function list_pages(title, items, empty) - if #items == 0 then - return { { title = title, description = empty, color = arch.colors.neutral } } - end - table.sort(items, task_order) - local pages = {} - for start = 1, #items, PAGE do - local lines = {} - for i = start, math.min(start + PAGE - 1, #items) do - lines[#lines + 1] = task_line(items[i]) - end - pages[#pages + 1] = { - title = title, color = arch.colors.teal, - description = table.concat(lines, "\n"), - footer = #items .. " task(s)", - } - end - return pages -end - --- ── command handlers ───────────────────────────────────────────────────────── -local function do_list(ctx) - local s = arch.sigils(ctx.args) - local owner_kind, owner_id, err = scope_for(ctx, s.group) - if err then ctx.error(err) return end - local list_name = s.list - if not list_name and s.text ~= "" then - local first = s.text:match("^(%S+)") - if first then list_name = first:lower() end - end - local filter = { kind = "task", owner_kind = owner_kind, owner_id = owner_id } - if list_name then filter.list_name = list_name end - local items = ctx.store.query("items", filter) - local where = owner_kind == "user" and "Your tasks" - or ("Group #" .. owner_id .. " tasks") - if list_name then where = where .. " -- ~" .. list_name end - ctx.deliver( - list_pages(where, items, - "No tasks here yet. Add one with `" .. ctx.prefix .. "task add`."), - { private = owner_kind ~= "group" }) -end - -local function do_add(ctx) - local s = arch.sigils(ctx.args) - if s.text == "" then ctx.error("Give the task text.") return end - local owner_kind, owner_id, err = scope_for(ctx, s.group) - if err then ctx.error(err) return end - local list_name = s.list or "general" - local id = ctx.store.put("items", { - kind = "task", owner_kind = owner_kind, owner_id = owner_id, - title = s.text:sub(1, 300), body = "", list_name = list_name, - done = false, shares = {}, - created_by = ctx.author_id, created_at = arch.now(), - }) - ctx.deliver({ - title = "Task added", color = arch.colors.success, - description = "Saved as task `#" .. id .. "` in list `~" .. list_name - .. "`. Set a reminder with `" .. ctx.prefix .. "task remind " .. id - .. " <when>`.", - }, { private = owner_kind ~= "group" }) -end - -local function do_lists(ctx) - local s = arch.sigils(ctx.args) - local owner_kind, owner_id, err = scope_for(ctx, s.group) - if err then ctx.error(err) return end - local items = ctx.store.query("items", { - kind = "task", owner_kind = owner_kind, owner_id = owner_id, - }) - local buckets = {} - for _, it in ipairs(items) do - local name = it.list_name or "general" - buckets[name] = buckets[name] or { open = 0, total = 0 } - buckets[name].total = buckets[name].total + 1 - if not it.done then buckets[name].open = buckets[name].open + 1 end - end - local names = {} - for name in pairs(buckets) do names[#names + 1] = name end - table.sort(names) - local lines = {} - for _, name in ipairs(names) do - lines[#lines + 1] = "`~" .. name .. "` -- " .. buckets[name].open - .. " open / " .. buckets[name].total .. " total" - end - local where = owner_kind == "user" and "Your task lists" - or ("Group #" .. owner_id .. " task lists") - ctx.deliver({ - title = where, color = arch.colors.teal, - description = #lines > 0 and table.concat(lines, "\n") - or "No task lists yet.", - }, { private = owner_kind ~= "group" }) -end - -local function set_done(ctx, value) - local id = ctx.args:match("^%s*(%S+)") - local item, _, err = require_task(ctx, id, true) - if err then ctx.error(err) return end - item.done = value - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = value and "Task done" or "Task reopened", - color = arch.colors.success, - description = "Task `#" .. item.id .. "` " - .. (value and "is marked done." or "is open again."), - }, { private = item.owner_kind ~= "group" }) -end - -local function do_due(ctx) - local id, when = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_task(ctx, id, true) - if err then ctx.error(err) return end - when = trim(when) - local low = when:lower() - if when == "" or low == "clear" or low == "none" or low == "off" then - item.due_at = nil - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Due date cleared", color = arch.colors.success, - description = "Cleared the due date on task `#" .. item.id .. "`.", - }, { private = item.owner_kind ~= "group" }) - return - end - local epoch = arch.parse_time(when) - if not epoch then ctx.error(TIME_HINT) return end - item.due_at = epoch - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Due date set", color = arch.colors.success, - description = "Task `#" .. item.id .. "` is due " .. arch.fmt_time(epoch) - .. ".", - }, { private = item.owner_kind ~= "group" }) -end - -local function do_remind(ctx) - local id, when = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_task(ctx, id, true) - if err then ctx.error(err) return end - when = trim(when) - local low = when:lower() - if when == "" or low == "clear" or low == "none" or low == "off" then - item.remind_at = nil - item.reminded = false - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Reminder cleared", color = arch.colors.success, - description = "Cleared the reminder on task `#" .. item.id .. "`.", - }, { private = item.owner_kind ~= "group" }) - return - end - local epoch = arch.parse_time(when) - if not epoch then ctx.error(TIME_HINT) return end - item.remind_at = epoch - item.reminded = false - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Reminder set", color = arch.colors.success, - description = "I will remind about task `#" .. item.id .. "` at " - .. arch.fmt_time(epoch) .. ".", - }, { private = item.owner_kind ~= "group" }) -end - -local function do_edit(ctx) - local id, text = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_task(ctx, id, true) - if err then ctx.error(err) return end - text = trim(text) - if text == "" then ctx.error("Give the new text after the id.") return end - item.title = text:sub(1, 300) - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Updated", color = arch.colors.success, - description = "Edited task `#" .. item.id .. "`.", - }, { private = item.owner_kind ~= "group" }) -end - -local function do_del(ctx) - local id = ctx.args:match("^%s*(%S+)") - local item, _, err = require_task(ctx, id, true) - if err then ctx.error(err) return end - ctx.store.delete("items", item.id) - ctx.deliver({ - title = "Deleted", color = arch.colors.success, - description = "Removed task `#" .. item.id .. "`.", - }, { private = item.owner_kind ~= "group" }) -end - -local function do_share(ctx) - local id = ctx.args:match("^%s*(%S+)") - local item, _, err = require_task(ctx, id, false) - if err then ctx.error(err) return end - if item.owner_kind ~= "user" or item.owner_id ~= ctx.author_id then - ctx.error("You can only share your own personal tasks.") - return - end - local target = first_mention(ctx) - if not target then ctx.error("Mention the user to share it with.") return end - if target.id == ctx.author_id then - ctx.error("You already own that task.") - return - end - local can_edit = ctx.args:lower():find("edit", 1, true) ~= nil - item.shares = item.shares or {} - local found = false - for _, share in ipairs(item.shares) do - if share.user == target.id then - share.can_edit = can_edit - found = true - end - end - if not found then - item.shares[#item.shares + 1] = { user = target.id, can_edit = can_edit } - end - ctx.store.update("items", item.id, item) - local access = can_edit and "view and edit" or "view" - arch.dm(target.id, { - title = "A task was shared with you", color = arch.colors.info, - description = ctx.author_name .. " shared task `#" .. item.id .. "` (" - .. arch.clip(item.title, 120) .. ") with you (" .. access .. ").", - }) - ctx.deliver({ - title = "Shared", color = arch.colors.success, - description = "Task `#" .. item.id .. "` is now shared with " - .. target.name .. " (" .. access .. ").", - }, { private = true }) -end - -local function do_unshare(ctx) - local id = ctx.args:match("^%s*(%S+)") - local item, _, err = require_task(ctx, id, false) - if err then ctx.error(err) return end - if item.owner_kind ~= "user" or item.owner_id ~= ctx.author_id then - ctx.error("You can only unshare your own tasks.") - return - end - local target = first_mention(ctx) - if not target then - ctx.error("Mention the user to stop sharing with.") - return - end - local kept, removed = {}, false - for _, share in ipairs(item.shares or {}) do - if share.user == target.id then - removed = true - else - kept[#kept + 1] = share - end - end - if not removed then - ctx.error(target.name .. " did not have access to that task.") - return - end - item.shares = kept - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Unshared", color = arch.colors.success, - description = target.name .. " can no longer see task `#" .. item.id .. "`.", - }, { private = true }) -end - -local function do_copy(ctx) - local id, rest = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_task(ctx, id, false) - if err then ctx.error(err) return end - local kind, oid, derr = parse_dest(ctx, rest) - if derr then ctx.error(derr) return end - local new_id = ctx.store.put("items", { - kind = "task", owner_kind = kind, owner_id = oid, - title = item.title, body = item.body or "", - list_name = item.list_name or "general", done = item.done and true or false, - due_at = item.due_at, shares = {}, - created_by = ctx.author_id, created_at = arch.now(), - }) - ctx.deliver({ - title = "Copied", color = arch.colors.success, - description = "Task copied to " .. dest_label(ctx, kind, oid) - .. " as `#" .. new_id .. "`.", - }, { private = kind ~= "group" }) -end - -local function do_move(ctx) - local id, rest = ctx.args:match("^%s*(%S+)%s*(.*)$") - local item, _, err = require_task(ctx, id, true) - if err then ctx.error(err) return end - local kind, oid, derr = parse_dest(ctx, rest) - if derr then ctx.error(derr) return end - if kind == item.owner_kind and oid == item.owner_id then - ctx.error("That task is already there.") - return - end - item.owner_kind, item.owner_id, item.shares = kind, oid, {} - ctx.store.update("items", item.id, item) - ctx.deliver({ - title = "Moved", color = arch.colors.success, - description = "Task `#" .. item.id .. "` moved to " - .. dest_label(ctx, kind, oid) .. ".", - }, { private = kind ~= "group" }) -end - --- ── reminder loop ──────────────────────────────────────────────────────────── -local function fire_reminders() - local now = arch.now() - for _, item in ipairs(arch.store.query("items", { kind = "task" })) do - if item.remind_at and not item.reminded and item.remind_at <= now then - local recipients - if item.owner_kind == "group" then - local grp = arch.store.get("groups", item.owner_id) - recipients = grp and (grp.members or {}) or {} - else - recipients = { item.owner_id } - end - local fields = {} - if item.due_at then - fields[#fields + 1] = { name = "Due", - value = arch.fmt_time(item.due_at), inline = true } - end - local note = { - title = "Task reminder", color = arch.colors.gold, - description = "**" .. arch.clip(item.title, 240) .. "**", - fields = fields, footer = "task #" .. item.id, - } - for _, uid in ipairs(recipients) do - arch.dm(uid, note) - end - item.reminded = true - arch.store.update("items", item.id, item) - end - end -end - --- ── command tree ───────────────────────────────────────────────────────────── -M.commands = { - { - name = "task", aliases = { "tasks", "todo" }, - summary = "Tasks and to-do lists.", - run = do_list, - subcommands = { - { name = "add", aliases = { "new", "create" }, - usage = "add [#group] [~list] <text>", - summary = "Add a task to a list.", run = do_add }, - { name = "list", aliases = { "ls", "all" }, usage = "list [#group] [~list]", - summary = "List your tasks, filtered by list.", run = do_list }, - { name = "lists", usage = "lists [#group]", - summary = "Show every task list and its open count.", run = do_lists }, - { name = "done", aliases = { "complete", "check" }, usage = "done <id>", - summary = "Mark a task done.", - run = function(ctx) set_done(ctx, true) end }, - { name = "undone", aliases = { "uncheck", "reopen" }, usage = "undone <id>", - summary = "Reopen a completed task.", - run = function(ctx) set_done(ctx, false) end }, - { name = "due", usage = "due <id> <when|clear>", - summary = "Set or clear a task's due date.", run = do_due }, - { name = "remind", usage = "remind <id> <when|clear>", - summary = "Set or clear a task reminder.", run = do_remind }, - { name = "edit", usage = "edit <id> <text>", - summary = "Replace a task's text.", run = do_edit }, - { name = "del", aliases = { "delete", "rm", "remove" }, usage = "del <id>", - summary = "Delete a task.", run = do_del }, - { name = "share", usage = "share <id> @user [edit]", - summary = "Share a personal task with a user.", run = do_share }, - { name = "unshare", usage = "unshare <id> @user", - summary = "Stop sharing a task with a user.", run = do_unshare }, - { name = "copy", usage = "copy <id> <me|@user|#group>", - summary = "Copy a task somewhere else.", run = do_copy }, - { name = "move", usage = "move <id> <me|@user|#group>", - summary = "Move a task somewhere else.", run = do_move }, - }, - }, -} - -M.loops = { - { name = "reminders", interval = 60, run = fire_reminders }, -} - -return M diff --git a/tests/test_smoke.py b/tests/test_smoke.py index d6c1919..a847294 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -29,7 +29,7 @@ ] # The plugin files that ship in plugins/ and load on every boot. -_BUNDLED_PLUGINS = ["coinflip", "events", "groups", "notes", "tasks"] +_BUNDLED_PLUGINS = ["coinflip"] @pytest.mark.parametrize("module", _MODULES) @@ -311,37 +311,36 @@ def test_bundled_plugins_compile() -> None: assert plugin.manifest.version -def test_productivity_plugins_share_a_namespace() -> None: - pytest.importorskip("lupa") - import os - - from framework.plugins.runtime import compile_plugin - - plugin_dir = os.path.join(os.path.dirname(__file__), "..", "plugins") - for plugin_id in ("notes", "tasks", "events", "groups"): - path = os.path.join(plugin_dir, f"{plugin_id}.lua") - with open(path, "r", encoding="utf-8") as fh: - plugin = compile_plugin(fh.read(), expected_id=plugin_id) - assert plugin.manifest.storage == "productivity" - - def test_plugin_builds_a_command_tree() -> None: pytest.importorskip("lupa") - import os from framework.plugins.api import LuaApi, build_commands from framework.plugins.runtime import compile_plugin - path = os.path.join(os.path.dirname(__file__), "..", "plugins", "notes.lua") - with open(path, "r", encoding="utf-8") as fh: - plugin = compile_plugin(fh.read(), expected_id="notes") + src = """ + local M = {} + M.manifest = { id = "tree", name = "Tree", version = "1.0.0" } + M.commands = { + { + name = "tree", + summary = "A command group.", + run = function(ctx) end, + subcommands = { + { name = "add", summary = "Add.", run = function(ctx) end }, + { name = "list", summary = "List.", run = function(ctx) end }, + }, + }, + } + return M + """ + plugin = compile_plugin(src, expected_id="tree") api = LuaApi(plugin, db=None, bot=None, loop=None) commands = build_commands(api, plugin) assert len(commands) == 1 - note = commands[0] - assert note.name == "note" - sub_names = {c.name for c in note.commands} - assert {"add", "list", "show", "share", "move"} <= sub_names + root = commands[0] + assert root.name == "tree" + sub_names = {c.name for c in root.commands} + assert {"add", "list"} <= sub_names class _FakeDB: @@ -402,18 +401,15 @@ async def test_plugin_manager_loads_bundled_plugins() -> None: assert bot.plugins.loaded_count == len(_BUNDLED_PLUGINS) # The single gateway-event dispatcher cog is registered on startup. assert bot.get_cog("PluginEventDispatcher") is not None - # Productivity command groups are now plugin-provided. - for name in ("note", "task", "event", "group"): - assert bot.get_command(name) is not None - assert bot.get_command("task add") is not None - assert bot.get_command("group invite") is not None - # A plugin can register an agent tool too. + # The bundled coinflip plugin registers a prefix command... + assert bot.get_command("coinflip") is not None + # ...and an agent tool the model can call. assert bot.tools.get("fun.coinflip") is not None - # Disabling a plugin tears its commands back out. - await bot.plugins.disable("notes") - assert bot.get_command("note") is None - await bot.plugins.enable("notes") - assert bot.get_command("note") is not None + # Disabling a plugin tears its command back out. + await bot.plugins.disable("coinflip") + assert bot.get_command("coinflip") is None + await bot.plugins.enable("coinflip") + assert bot.get_command("coinflip") is not None finally: await bot.plugins.shutdown() await bot.close()