From 5ece3e9bde50fc8cc80516d4afc0669f15fb71aa Mon Sep 17 00:00:00 2001 From: HiLleywyn Date: Tue, 19 May 2026 07:15:22 +0000 Subject: [PATCH] Search with ripgrep and bundle only the coinflip plugin The agent shell tool now prefers rg for content searches, keeping grep, egrep and fgrep on the allowlist only as a fallback for when ripgrep cannot serve. The Docker image installs ripgrep so rg is on PATH. Only the coinflip worked example ships bundled now. The notes, tasks, events and groups productivity suite moves to install-only marketplace plugins, and the docs, help text and smoke tests follow. --- Dockerfile | 5 +- README.md | 70 ++---- ai/tools.py | 8 +- ai/workspace.py | 8 +- cogs/meta.py | 46 ++-- plugins/README.md | 10 +- plugins/events.lua | 462 ---------------------------------------- plugins/groups.lua | 443 -------------------------------------- plugins/notes.lua | 368 -------------------------------- plugins/tasks.lua | 505 -------------------------------------------- tests/test_smoke.py | 64 +++--- 11 files changed, 91 insertions(+), 1898 deletions(-) delete mode 100644 plugins/events.lua delete mode 100644 plugins/groups.lua delete mode 100644 plugins/notes.lua delete mode 100644 plugins/tasks.lua 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()