From 35502c19de35027f8ab38783190c7aa1261c6588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Tue, 24 Feb 2026 05:17:17 -0800 Subject: [PATCH 01/18] v1 new linking way changes --- plugin/multi-user/generate.test.ts | 8 ++-- plugin/multi-user/generate.ts | 4 +- website/app/dashboard/page.tsx | 64 +++++++++++++++++++----------- website/app/hero/hero.tsx | 19 ++------- website/next-env.d.ts | 2 +- 5 files changed, 51 insertions(+), 46 deletions(-) diff --git a/plugin/multi-user/generate.test.ts b/plugin/multi-user/generate.test.ts index bae84977..1dbb12ac 100644 --- a/plugin/multi-user/generate.test.ts +++ b/plugin/multi-user/generate.test.ts @@ -43,7 +43,7 @@ describe("generateConfig", () => { expect(result.channels).toHaveProperty("signal"); const wa = result.channels.whatsapp as Record; expect(wa.allowFrom).toEqual(["+1234567890"]); - expect(wa.dmPolicy).toBe("open"); + expect(wa.dmPolicy).toBe("allowlist"); expect(result.session.dmScope).toBe("per-peer"); }); @@ -83,11 +83,11 @@ describe("generateConfig", () => { const discord = result.channels.discord as Record>; expect(discord.dm.allowFrom).toEqual(["111"]); - expect(discord.dm.policy).toBe("open"); + expect(discord.dm.policy).toBe("allowlist"); const tg = result.channels.telegram as Record; expect(tg.allowFrom).toEqual(["alice_tg"]); - expect(tg.dmPolicy).toBe("open"); + expect(tg.dmPolicy).toBe("allowlist"); }); it("uses default model when user has no model", () => { @@ -160,7 +160,7 @@ describe("generateConfig", () => { const result = generateConfig(config); const slack = result.channels.slack as Record>; expect(slack.dm.allowFrom).toEqual(["U012345"]); - expect(slack.dm.policy).toBe("open"); + expect(slack.dm.policy).toBe("allowlist"); }); it("includes shared.env in generated output", () => { diff --git a/plugin/multi-user/generate.ts b/plugin/multi-user/generate.ts index bd55daef..49629620 100644 --- a/plugin/multi-user/generate.ts +++ b/plugin/multi-user/generate.ts @@ -90,14 +90,14 @@ function buildChannelsConfig( // Discord, Slack, Google Chat: dm.allowFrom + dm.policy channels[channel] = { dm: { - policy: "open", + policy: "allowlist", allowFrom: allowList, }, }; } else { // WhatsApp, Telegram, Signal, iMessage, etc.: top-level allowFrom + dmPolicy channels[channel] = { - dmPolicy: "open", + dmPolicy: "allowlist", allowFrom: allowList, }; } diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index c60ab160..24ec2ad1 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -109,6 +109,17 @@ export default function DashboardPage() { setVerifyLoading(true); setVerifyFeedback(null); try { + const regRes = await fetch("/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phone: phoneNumber }), + }); + const regData = await regRes.json(); + if (!regRes.ok && regRes.status !== 409) { + setVerifyFeedback({ type: "error", text: regData.error || "Registration failed" }); + return; + } + const res = await fetch("/api/verify", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -140,16 +151,6 @@ export default function DashboardPage() { }); const data = await res.json(); if (res.ok && data.verified) { - // Register the user in OpenClaw so they can message back - try { - await fetch("/api/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ phone: phoneNumber }), - }); - } catch { - // Registration is best-effort; verification already succeeded - } setVerifyFeedback({ type: "success", text: "Verified! Your WhatsApp is connected." }); await user.reload(); } else { @@ -266,21 +267,36 @@ export default function DashboardPage() {

WhatsApp verified

Send a message on WhatsApp to start your first session

- + Message LogLife on WhatsApp + + )} + + diff --git a/website/app/hero/hero.tsx b/website/app/hero/hero.tsx index 6dd916ab..58c5de94 100644 --- a/website/app/hero/hero.tsx +++ b/website/app/hero/hero.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useState, useEffect, useRef } from "react"; import Link from "next/link"; -import { useWhatsAppWidget } from "../contexts/WhatsAppWidgetContext"; function useInView(threshold = 0.15) { const ref = useRef(null); @@ -28,8 +27,6 @@ function SectionLabel({ children }: { children: React.ReactNode }) { } function Hero() { - const { openWidget } = useWhatsAppWidget(); - return (
@@ -432,7 +429,6 @@ function WhatYouGet() { function FinalCTA() { const { ref, visible } = useInView(); - const { openWidget } = useWhatsAppWidget(); return (
@@ -445,26 +441,19 @@ function FinalCTA() { Your words. Your data. Your patterns. AI that listens, remembers, and surfaces what matters.

- {/* Provider pills */}
- - +
/// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From f17bf5c0777cd4d5f650348d2dde7b8c1140e1e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Tue, 24 Feb 2026 05:19:00 -0800 Subject: [PATCH 02/18] add $include installation in plug-in --- plugin/index.test.ts | 8 ++++---- plugin/index.ts | 20 ++++++++++++++++++-- plugin/setup.sh | 30 ++++++++++++++++++++++++++---- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/plugin/index.test.ts b/plugin/index.test.ts index 1d158ecd..cb877a3c 100644 --- a/plugin/index.test.ts +++ b/plugin/index.test.ts @@ -623,10 +623,10 @@ describe("POST /loglife/register handler", () => { expect(body.registered).toBe(true); expect(body.userId).toBeDefined(); - // Verify users.json and generated.json were written - expect(mockWriteFileSync).toHaveBeenCalledTimes(2); - // Verify openclaw.json was touched for hot-reload - expect(mockUtimesSync).toHaveBeenCalledTimes(1); + // Verify users.json, generated.json, and openclaw.json were written + expect(mockWriteFileSync).toHaveBeenCalledTimes(3); + // utimesSync no longer used — openclaw.json is written directly + expect(mockUtimesSync).not.toHaveBeenCalled(); }); it("uses name to derive user ID", async () => { diff --git a/plugin/index.ts b/plugin/index.ts index 83d99fe0..f0df37fb 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -446,9 +446,25 @@ const plugin = { const generated = generateConfig(usersConfig); writeFileSync(generatedJsonPath, JSON.stringify(generated, null, 2) + "\n"); + // Merge generated config directly into openclaw.json. + // We can't rely on $include because the gateway flattens it on hot-reload. if (existsSync(openclawJsonPath)) { - const now = new Date(); - utimesSync(openclawJsonPath, now, now); + const ocRaw = JSON.parse(readFileSync(openclawJsonPath, "utf-8")); + + ocRaw.agents = { ...ocRaw.agents, list: generated.agents.list }; + ocRaw.bindings = generated.bindings; + ocRaw.session = { ...ocRaw.session, ...generated.session }; + + if (!ocRaw.channels) ocRaw.channels = {}; + for (const [ch, chCfg] of Object.entries(generated.channels as Record>)) { + ocRaw.channels[ch] = { ...ocRaw.channels[ch], ...chCfg }; + } + + if (generated.env) { + ocRaw.env = { ...ocRaw.env, ...generated.env }; + } + + writeFileSync(openclawJsonPath, JSON.stringify(ocRaw, null, 2) + "\n"); } api.logger.info(`Registered user "${userId}" (${phone})`); diff --git a/plugin/setup.sh b/plugin/setup.sh index 0706acda..ebf4d627 100755 --- a/plugin/setup.sh +++ b/plugin/setup.sh @@ -48,13 +48,35 @@ else "$OPENCLAW_BIN" config set plugins.entries.loglife.config.apiKey "$API_KEY" fi -# --- 4. Restart the gateway --- -echo "[4/5] Restarting gateway..." +# --- 4. Wire up multi-user config include --- +echo "[4/6] Wiring multi-user config into openclaw.json..." +OPENCLAW_JSON="$HOME/.openclaw/openclaw.json" +node -e ' +const fs = require("fs"); +const cfgPath = process.argv[1]; +const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8")); + +if (!cfg["$include"]) cfg["$include"] = []; +const inc = "multi-user/generated.json"; +if (!cfg["$include"].includes(inc)) cfg["$include"].push(inc); + +// Let the generated config manage dmPolicy and allowFrom +if (cfg.channels?.whatsapp) { + delete cfg.channels.whatsapp.dmPolicy; + delete cfg.channels.whatsapp.allowFrom; +} + +fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n"); +' "$OPENCLAW_JSON" +echo " Added \$include for multi-user/generated.json" + +# --- 5. Restart the gateway --- +echo "[5/6] Restarting gateway..." "$OPENCLAW_BIN" gateway restart 2>/dev/null || "$OPENCLAW_BIN" gateway start 2>/dev/null || true sleep 5 -# --- 5. Health check --- -echo "[5/5] Running health check..." +# --- 6. Health check --- +echo "[6/6] Running health check..." SESSIONS_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer $API_KEY" \ "http://localhost:18789/loglife/sessions?phone=healthcheck" 2>/dev/null || echo "000") From e42268857ac78371d7264b0ff5ab329e474e0bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Tue, 24 Feb 2026 05:25:08 -0800 Subject: [PATCH 03/18] add openclaw context --- .cursor/rules/openclaw-context.mdc | 123 +++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 .cursor/rules/openclaw-context.mdc diff --git a/.cursor/rules/openclaw-context.mdc b/.cursor/rules/openclaw-context.mdc new file mode 100644 index 00000000..1946d5bf --- /dev/null +++ b/.cursor/rules/openclaw-context.mdc @@ -0,0 +1,123 @@ +--- +description: Context for the OpenClaw project at ~/openclaw — commands, structure, tech stack, and conventions. +alwaysApply: true +--- + +# OpenClaw Project Context + +OpenClaw lives at `~/openclaw`. It is a separate repository from this workspace. +All commands below must be run from `~/openclaw` (use `working_directory` or `cd ~/openclaw`). + +## What It Is + +Multi-channel personal AI assistant gateway (Node.js/TypeScript). Connects messaging platforms (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, etc.) to an AI agent. + +## Package Manager + +**pnpm** (v10.23.0, declared via `packageManager` in `package.json`). +Always use `pnpm`, never `npm` or `yarn`. + +## Repository Structure + +``` +openclaw/ +├── src/ # Core source (gateway, agents, CLI, channels, config, infra) +├── extensions/ # Channel/feature plugins (whatsapp, telegram, discord, slack, etc.) +├── ui/ # Control UI (Lit + Vite web dashboard) +├── packages/ # Sub-packages (clawdbot, moltbot) +├── apps/ # Native apps (macos, ios, android) +├── scripts/ # Build/dev/test helper scripts +├── skills/ # Bundled skills +├── docs/ # Documentation (Mintlify) +├── dist/ # Built output +├── openclaw.mjs # CLI entry point +├── tsdown.config.ts # Bundler config +├── tsconfig.json # TypeScript config (experimentalDecorators: true) +└── vitest.*.config.ts # Test configs (unit, e2e, live, gateway, extensions) +``` + +## Tech Stack + +| Layer | Technology | +|----------------|-----------------------------------| +| Language | TypeScript (strict) | +| Runtime | Node.js >= 22 | +| Bundler | tsdown | +| Formatter | oxfmt | +| Linter | oxlint (type-aware) | +| Type checker | TypeScript / tsgo (native preview)| +| Test runner | Vitest | +| UI framework | Lit (Control UI) | +| UI bundler | Vite | +| Package manager| pnpm 10.23.0 | + +## Commands (run from ~/openclaw) + +### Setup +```bash +pnpm install +pnpm ui:install # Install UI deps (auto-runs on ui:build) +``` + +### Build +```bash +pnpm build # Full production build +pnpm ui:build # Build the Control UI +``` + +### Development +```bash +pnpm dev # Run node via tsx (auto-reload) +pnpm gateway:dev # Gateway in dev mode (skips channels) +pnpm gateway:watch # Watch mode with auto-reload on TS changes +pnpm ui:dev # Vite dev server for Control UI +pnpm tui # Terminal UI +pnpm tui:dev # TUI in dev profile +``` + +### Running CLI from Source +```bash +pnpm openclaw # Run CLI via tsx +pnpm openclaw onboard --install-daemon # Setup wizard +pnpm openclaw gateway --port 18789 --verbose +pnpm openclaw agent --message "Hello" +pnpm openclaw doctor # Diagnose config issues +``` + +### Testing +```bash +pnpm test # All tests (parallel) +pnpm test:fast # Unit tests only (fastest) +pnpm test:e2e # End-to-end tests +pnpm test:live # Live tests (needs OPENCLAW_LIVE_TEST=1) +pnpm test:watch # Vitest watch mode +pnpm test:coverage # Unit tests with coverage +pnpm test:ui # Control UI tests +``` + +### Linting / Formatting / Type-checking +```bash +pnpm check # format:check + tsgo + lint (full CI check) +pnpm lint # oxlint with type-aware rules +pnpm lint:fix # Auto-fix lint + reformat +pnpm format # Format code (oxfmt --write) +pnpm format:check # Check formatting only +``` + +### Pre-PR Checklist +```bash +pnpm build && pnpm check && pnpm test +``` + +### Docs +```bash +pnpm docs:dev # Local Mintlify docs dev server +pnpm check:docs # Check docs formatting + links +``` + +## Key Conventions + +- UI uses Lit with **legacy decorators** (`@state()`, `@property()`), not standard `accessor` decorators. +- `tsconfig.json` has `experimentalDecorators: true` and `useDefineForClassFields: false`. +- Entry point is `openclaw.mjs` which delegates to `dist/index.js` (or tsx in dev). +- Extensions are self-contained plugins under `extensions/` with their own `package.json`. From a7942a8fe37ef4ad944e56c2e600bf3b8a491174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Tue, 24 Feb 2026 05:26:22 -0800 Subject: [PATCH 04/18] add mintifly to rules --- .cursor/{rules.md => rules/mintlify-context.mdc} | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) rename .cursor/{rules.md => rules/mintlify-context.mdc} (98%) diff --git a/.cursor/rules.md b/.cursor/rules/mintlify-context.mdc similarity index 98% rename from .cursor/rules.md rename to .cursor/rules/mintlify-context.mdc index 7d985262..ed26d709 100644 --- a/.cursor/rules.md +++ b/.cursor/rules/mintlify-context.mdc @@ -1,3 +1,9 @@ +--- +description: Mintlify technical writing guidelines — components, style, and documentation standards for the docs site. +globs: docs/**/*.mdx, docs/**/*.md +alwaysApply: false +--- + # Mintlify technical writing rule You are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices. @@ -392,4 +398,4 @@ description: "Concise description explaining page purpose and value" - Use **Accordions** for progressive disclosure of information - Use **RequestExample/ResponseExample** specifically for API endpoint documentation - Use **ParamField** for API parameters, **ResponseField** for API responses -- Use **Expandable** for nested object properties or hierarchical information \ No newline at end of file +- Use **Expandable** for nested object properties or hierarchical information From fd7cae989cd9919f8190df784d38f8ae1c1922e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Tue, 24 Feb 2026 05:29:44 -0800 Subject: [PATCH 05/18] rename to remove -context --- .cursor/rules/{project-context.mdc => loglife.mdc} | 0 .cursor/rules/{mintlify-context.mdc => mintlify.mdc} | 0 .cursor/rules/{openclaw-context.mdc => openclaw.mdc} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename .cursor/rules/{project-context.mdc => loglife.mdc} (100%) rename .cursor/rules/{mintlify-context.mdc => mintlify.mdc} (100%) rename .cursor/rules/{openclaw-context.mdc => openclaw.mdc} (100%) diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/loglife.mdc similarity index 100% rename from .cursor/rules/project-context.mdc rename to .cursor/rules/loglife.mdc diff --git a/.cursor/rules/mintlify-context.mdc b/.cursor/rules/mintlify.mdc similarity index 100% rename from .cursor/rules/mintlify-context.mdc rename to .cursor/rules/mintlify.mdc diff --git a/.cursor/rules/openclaw-context.mdc b/.cursor/rules/openclaw.mdc similarity index 100% rename from .cursor/rules/openclaw-context.mdc rename to .cursor/rules/openclaw.mdc From 59dd46483edd16afced664cc4b3f296ac7cbb212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Tue, 24 Feb 2026 05:33:48 -0800 Subject: [PATCH 06/18] add /plugin scope for openclaw rules --- .cursor/rules/openclaw.mdc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.cursor/rules/openclaw.mdc b/.cursor/rules/openclaw.mdc index 1946d5bf..c5a51699 100644 --- a/.cursor/rules/openclaw.mdc +++ b/.cursor/rules/openclaw.mdc @@ -1,6 +1,7 @@ --- description: Context for the OpenClaw project at ~/openclaw — commands, structure, tech stack, and conventions. -alwaysApply: true +globs: plugin/** +alwaysApply: false --- # OpenClaw Project Context From d56fec09adf4601e51f4f4cc4a52c1a0a5aa5788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Tue, 24 Feb 2026 05:35:09 -0800 Subject: [PATCH 07/18] restructure docs - create dev section --- docs/ai-rules.mdx | 40 ++++++++++++++++++++++++++++++++++++++ docs/contributing-docs.mdx | 3 ++- docs/docs.json | 18 +++++++++++------ 3 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 docs/ai-rules.mdx diff --git a/docs/ai-rules.mdx b/docs/ai-rules.mdx new file mode 100644 index 00000000..442be2b5 --- /dev/null +++ b/docs/ai-rules.mdx @@ -0,0 +1,40 @@ +--- +title: "AI rules" +description: "Cursor rule files that give AI agents project context automatically" +--- + +LogLife includes `.cursor/rules/` files that feed project-specific context to [Cursor](https://cursor.com) (or any AI editor that supports the format). When you open the repository in Cursor, the agent already knows the tech stack, available commands, and coding conventions — no pasting of README snippets needed. + +## How it works + +Cursor reads `.mdc` files from `.cursor/rules/` and injects their contents into every AI conversation. Each file has YAML frontmatter that controls when it activates: + +- **`alwaysApply: true`** — injected into every conversation automatically. +- **`globs: "plugin/**"`** — only injected when you have matching files open. +- **`alwaysApply: false`** (no globs) — available in the rule picker but never auto-injected. + +## Current rules + +| File | Activates | What it provides | +|------|-----------|-----------------| +| `loglife.mdc` | Always | Repository structure, package manager (`pnpm`), tech stack (Next.js, Clerk, Tailwind, Vapi), key conventions, and available commands | +| `openclaw.mdc` | When editing `plugin/` files | OpenClaw project context at `~/openclaw` — build/dev/test commands, repo structure, and conventions (tsdown, oxlint, Lit decorators) | +| `mintlify.mdc` | When editing `docs/` files | Mintlify component reference, writing style guide, and documentation standards | + +## Adding or editing rules + +Rules live in `.cursor/rules/` at the repository root. To add a new rule, create a `.mdc` file with the appropriate frontmatter: + +```yaml +--- +description: Short description shown in the rule picker +globs: src/**/*.ts +alwaysApply: false +--- +``` + +Then write the rule body in Markdown below the frontmatter. Keep rules concise and actionable — they are injected as context into the AI's prompt, so unnecessary length wastes tokens. + + +If you are not using Cursor, these files have no effect on your workflow. They are ignored by the build, linter, and deployment pipeline. + diff --git a/docs/contributing-docs.mdx b/docs/contributing-docs.mdx index 26c11734..d43b12d6 100644 --- a/docs/contributing-docs.mdx +++ b/docs/contributing-docs.mdx @@ -1,5 +1,6 @@ --- title: "Contributing to docs" +sidebarTitle: "Documentation" description: "How to update, preview, and deploy the LogLife documentation site." --- @@ -9,7 +10,7 @@ The docs live in the `docs/` directory of the LogLife monorepo and are built wit ## How to update -All pages are `.mdx` files in `docs/`. The full Mintlify syntax reference is stored in `.cursor/rules.md` at the repo root, so if you're using an AI editor you can just ask it to make changes and it will follow the correct syntax. +All pages are `.mdx` files in `docs/`. The full Mintlify syntax reference is stored in `.cursor/rules/mintlify.mdc`, so if you're using Cursor (or another AI editor that supports rule files) you can just ask it to make changes and it will follow the correct syntax. See [AI rules](/ai-rules) for details. To add or edit a page manually: diff --git a/docs/docs.json b/docs/docs.json index 216243c8..b329763f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -17,20 +17,26 @@ "group": "Getting started", "pages": [ "index", - "quickstart", - "contributing-docs" + "quickstart" ] }, { - "group": "Self-host", + "group": "Self-hosting", "pages": [ "self-hosting", "plugin-installation", "configuration", - "networking", - "architecture", + "networking" + ] + }, + { + "group": "Development", + "pages": [ "development", - "security" + "architecture", + "security", + "ai-rules", + "contributing-docs" ] }, { From 1411112fe4f3e8ca393609bafab2118bde95a44f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Tue, 24 Feb 2026 05:44:02 -0800 Subject: [PATCH 08/18] refactor docs to be more user friendly --- docs/dashboard.mdx | 1 + docs/development.mdx | 1 + docs/docs.json | 3 +- docs/index.mdx | 99 ++++++++++++------------------------------- docs/quickstart.mdx | 80 ---------------------------------- docs/self-hosting.mdx | 1 + 6 files changed, 30 insertions(+), 155 deletions(-) delete mode 100644 docs/quickstart.mdx diff --git a/docs/dashboard.mdx b/docs/dashboard.mdx index 673ec39b..a540f110 100644 --- a/docs/dashboard.mdx +++ b/docs/dashboard.mdx @@ -1,5 +1,6 @@ --- title: "Dashboard" +sidebarTitle: "Overview" description: "Learn how to use the LogLife dashboard to manage your sessions and account" --- diff --git a/docs/development.mdx b/docs/development.mdx index 037b3272..fc641429 100644 --- a/docs/development.mdx +++ b/docs/development.mdx @@ -1,5 +1,6 @@ --- title: 'Development' +sidebarTitle: 'Workflow' description: 'Local development workflow for the LogLife website and plugin' --- diff --git a/docs/docs.json b/docs/docs.json index b329763f..732db50c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -16,8 +16,7 @@ { "group": "Getting started", "pages": [ - "index", - "quickstart" + "index" ] }, { diff --git a/docs/index.mdx b/docs/index.mdx index 1d05898c..cb2c9f31 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -1,105 +1,58 @@ --- title: "Introduction" -description: "Welcome to the new home for your documentation" +description: "LogLife documentation — self-hosting, development, and API reference" --- -## Setting up +LogLife is a personal health-logging assistant that reaches you on WhatsApp. These docs cover everything from getting your own instance running to contributing code and documentation. -Get LogLife running locally in minutes. +## Where to start - Get the marketing site running in minutes. - - - Set up OpenClaw, the plugin, and the full dashboard. - - - -## Make it yours - -Design a docs site that looks great and empowers your users. - - - - Edit your docs locally and preview them in real time. + Install the plugin, configure secrets, and set up networking. - Customize the design and colors of your site to match your brand. - - - Organize your docs to help users find what they need and succeed with your product. - - - Auto-generate API documentation from OpenAPI specifications. + Manage sessions and verified users from the web UI. -## Create beautiful pages - -Everything you need to create world-class documentation. +## Go deeper - Use MDX to style your docs pages. + Local setup, CI/CD pipelines, and deployment. - Add sample code to demonstrate how to use your product. + Multi-user isolation, generated config, and memory layers. - Display images and other media. + Authentication, phone verification, and data flow. - Write once and reuse across your docs. + Plugin HTTP endpoints for sessions, verification, and registration. - -## Need inspiration? - - - Browse our showcase of exceptional documentation sites. - diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx deleted file mode 100644 index c711458b..00000000 --- a/docs/quickstart.mdx +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: "Quickstart" -description: "Start building awesome documentation in minutes" ---- - -## Get started in three steps - -Get your documentation site running locally and make your first customization. - -### Step 1: Set up your local environment - - - - During the onboarding process, you created a GitHub repository with your docs content if you didn't already have one. You can find a link to this repository in your [dashboard](https://dashboard.mintlify.com). - - To clone the repository locally so that you can make and preview changes to your docs, follow the [Cloning a repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) guide in the GitHub docs. - - - 1. Install the Mintlify CLI: `npm i -g mint` - 2. Navigate to your docs directory and run: `mint dev` - 3. Open `http://localhost:3000` to see your docs live! - - Your preview updates automatically as you edit files. - - - -### Step 2: Deploy your changes - - - - Install the Mintlify GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app). - - Our GitHub app automatically deploys your changes to your docs site, so you don't need to manage deployments yourself. - - - For a first change, let's update the name and colors of your docs site. - - 1. Open `docs.json` in your editor. - 2. Change the `"name"` field to your project name. - 3. Update the `"colors"` to match your brand. - 4. Save and see your changes instantly at `http://localhost:3000`. - - Try changing the primary color to see an immediate difference! - - - -### Step 3: Go live - - - 1. Commit and push your changes. - 2. Your docs will update and be live in moments! - - -## Next steps - -Now that you have your docs running, explore these key features: - - - - - Learn MDX syntax and start writing your documentation. - - - - Make your docs match your brand perfectly. - - - - Include syntax-highlighted code blocks. - - - - Auto-generate API docs from OpenAPI specs. - - - - - - **Need help?** See our [full documentation](https://mintlify.com/docs) or join our [community](https://mintlify.com/community). - diff --git a/docs/self-hosting.mdx b/docs/self-hosting.mdx index 366c1f00..dbbe3c88 100644 --- a/docs/self-hosting.mdx +++ b/docs/self-hosting.mdx @@ -1,5 +1,6 @@ --- title: "Self-hosting overview" +sidebarTitle: "Host in 3 steps" description: "Set up the LogLife dashboard and OpenClaw plugin from scratch" --- From eb400af4b43d99c65be32b17f87713123126fe98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Tue, 24 Feb 2026 05:48:50 -0800 Subject: [PATCH 09/18] Update self-hosting documentation: rename title and sidebar, remove outdated related guides for improved clarity and user experience. --- docs/self-hosting.mdx | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/docs/self-hosting.mdx b/docs/self-hosting.mdx index dbbe3c88..5389a5e1 100644 --- a/docs/self-hosting.mdx +++ b/docs/self-hosting.mdx @@ -1,6 +1,6 @@ --- -title: "Self-hosting overview" -sidebarTitle: "Host in 3 steps" +title: "Overview" +sidebarTitle: "Overview" description: "Set up the LogLife dashboard and OpenClaw plugin from scratch" --- @@ -45,20 +45,3 @@ Follow these guides in order to go from zero to a running LogLife instance: - -## Related guides - - - - Multi-user isolation, generated configuration, and memory layers. - - - Local development workflow and CI/CD pipelines. - - - Authentication, phone verification, and data flow. - - - Plugin HTTP endpoints documentation. - - From 2f6bbf26a4d455047fcf2defab0bee9e827547e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 7 Mar 2026 08:39:25 -0800 Subject: [PATCH 10/18] add user unregister endpoint and V1 testing docs Add /loglife/unregister and /loglife/users routes, plus tests and a helper script for removing users during onboarding tests. Update API/development docs to reflect V1 register-first flow and provide clear monitoring/removal steps. Made-with: Cursor --- docs/api-reference/endpoint/unregister.mdx | 4 + docs/api-reference/endpoint/users.mdx | 4 + docs/api-reference/introduction.mdx | 21 ++- docs/api-reference/openapi.json | 85 +++++++++ docs/architecture.mdx | 22 ++- docs/development.mdx | 79 ++++++++ docs/docs.json | 4 +- plugin/index.test.ts | 206 +++++++++++++++++++++ plugin/index.ts | 192 ++++++++++++++++--- plugin/scripts/unregister-user.sh | 41 ++++ 10 files changed, 614 insertions(+), 44 deletions(-) create mode 100644 docs/api-reference/endpoint/unregister.mdx create mode 100644 docs/api-reference/endpoint/users.mdx create mode 100755 plugin/scripts/unregister-user.sh diff --git a/docs/api-reference/endpoint/unregister.mdx b/docs/api-reference/endpoint/unregister.mdx new file mode 100644 index 00000000..10b2a859 --- /dev/null +++ b/docs/api-reference/endpoint/unregister.mdx @@ -0,0 +1,4 @@ +--- +title: "Unregister User" +openapi: "POST /loglife/unregister" +--- diff --git a/docs/api-reference/endpoint/users.mdx b/docs/api-reference/endpoint/users.mdx new file mode 100644 index 00000000..11d8ca8e --- /dev/null +++ b/docs/api-reference/endpoint/users.mdx @@ -0,0 +1,4 @@ +--- +title: "List Users" +openapi: "GET /loglife/users" +--- diff --git a/docs/api-reference/introduction.mdx b/docs/api-reference/introduction.mdx index 2b5681ef..787b8287 100644 --- a/docs/api-reference/introduction.mdx +++ b/docs/api-reference/introduction.mdx @@ -5,7 +5,7 @@ description: "The LogLife plugin exposes an HTTP API inside the OpenClaw gateway ## Overview -The LogLife plugin registers four HTTP routes on the OpenClaw gateway: +The LogLife plugin registers six HTTP routes on the OpenClaw gateway: | Endpoint | Method | Purpose | |---|---|---| @@ -13,6 +13,8 @@ The LogLife plugin registers four HTTP routes on the OpenClaw gateway: | `/loglife/verify/send` | POST | Send a 6-digit verification code via WhatsApp | | `/loglife/verify/check` | POST | Validate a verification code | | `/loglife/register` | POST | Register a new user in the multi-user configuration | +| `/loglife/unregister` | POST | Remove a user from the multi-user configuration | +| `/loglife/users` | GET | List currently registered users (monitoring/testing) | ## Authentication @@ -38,17 +40,17 @@ Browser → Next.js API route → LogLife Plugin (OpenClaw gateway) ## User registration flow -When a new user signs up on the dashboard, the verification and registration endpoints work together: +In V1, registration happens before code verification: -1. Dashboard calls `/loglife/verify/send` with the user's phone number -2. Plugin sends a 6-digit code via WhatsApp -3. User enters the code on the dashboard -4. Dashboard calls `/loglife/verify/check` to validate the code -5. On success, dashboard calls `/loglife/register` with the phone and user's name -6. Plugin adds the user to the multi-user config and triggers a gateway hot-reload +1. Dashboard calls `/loglife/register` with the user's phone number +2. Plugin adds the user to the multi-user config and updates gateway config +3. Dashboard calls `/loglife/verify/send` with the same phone number +4. Plugin sends a 6-digit code via WhatsApp +5. User enters the code on the dashboard +6. Dashboard calls `/loglife/verify/check` to validate the code 7. The user can now send messages to the bot via WhatsApp -No gateway restart is required — the hot-reload picks up the new configuration immediately. +No gateway restart is required. ## Security model @@ -58,5 +60,6 @@ No gateway restart is required — the hot-reload picks up the new configuration - **Single-use codes** deleted immediately after successful verification - **5-minute TTL** on verification codes - **Idempotent registration** — registering an already-registered phone returns success without duplicating +- **Idempotent unregistration** — unregistering an unknown phone returns `removed: false` Documenting these endpoints publicly is safe because knowing the URL structure and parameters is useless without the API key, which is only stored server-side. diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index 9cbf2559..1790d6ba 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -286,6 +286,91 @@ } } } + }, + "/loglife/unregister": { + "post": { + "operationId": "unregisterUser", + "summary": "Remove a registered user", + "description": "Removes a user from `users.json` by phone number, regenerates config, and updates gateway config. Idempotent — returns removed:false if the phone is not registered.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["phone"], + "properties": { + "phone": { + "type": "string", + "description": "Phone number in E.164 format", + "example": "+15551234567" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Unregister result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "removed": { "type": "boolean" }, + "existing": { "type": "boolean", "description": "False when no matching user exists" }, + "removedUserIds": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + }, + "400": { "description": "Missing or invalid phone number" }, + "401": { "description": "Unauthorized" }, + "500": { "description": "Unregister failed" } + } + } + }, + "/loglife/users": { + "get": { + "operationId": "listUsers", + "summary": "List registered users", + "description": "Returns the current users list from `users.json`. Useful for development and monitoring.", + "responses": { + "200": { + "description": "Current users list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { "type": "integer", "example": 2 }, + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "identifiers": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + } + }, + "401": { "description": "Unauthorized" } + } + } } } } diff --git a/docs/architecture.mdx b/docs/architecture.mdx index fdb2a052..caaf04ec 100644 --- a/docs/architecture.mdx +++ b/docs/architecture.mdx @@ -37,7 +37,7 @@ flowchart LR ### Generated configuration -The multi-user system produces a `generated.json` file that is included in `openclaw.json` via the `$include` directive. This file contains: +The multi-user system produces a `generated.json` file that contains: - **`agents.list`** — one agent definition per user (ID, name, model, skills) - **`bindings`** — routing rules mapping `{channel, peerId}` to an agent @@ -45,6 +45,8 @@ The multi-user system produces a `generated.json` file that is included in `open - **`session`** — session scoping settings - **`env`** — shared environment variables (API keys used by all users) +At runtime, the plugin merges this generated structure directly into `openclaw.json` after register/unregister operations. This avoids relying on `$include` during hot reload. + ### Session scoping The default session scope is `main`, which gives each agent one continuous conversation regardless of which channel the user messages from. This is the correct setting for journaling — your journal is your journal, whether you write from WhatsApp or Telegram. @@ -66,10 +68,22 @@ New users are added at runtime through the [`/loglife/register`](/api-reference/ 1. The plugin reads `users.json` (the source of truth for all users) 2. Appends the new user with their phone number as an identifier 3. Calls `generateConfig()` to rebuild `generated.json` -4. Writes the updated config -5. Touches `openclaw.json` to trigger a gateway hot-reload +4. Merges generated `agents`, `bindings`, `channels`, and `session` into `openclaw.json` +5. Writes `openclaw.json` (no restart required) + +The gateway picks up the new agent, binding, and allow-list entry immediately. + +### Runtime user removal + +Users can be removed at runtime through [`/loglife/unregister`](/api-reference/endpoint/unregister): + +1. The plugin reads `users.json` +2. Removes the matching user by phone identifier +3. Rebuilds `generated.json` +4. Merges the updated generated config into `openclaw.json` +5. Cleans stale allow-list fields when a channel no longer has managed users -The gateway picks up the new agent, binding, and allow-list entry immediately — **no restart required**. +This keeps add/remove symmetric and prevents stale access rules. The full rebuild approach regenerates `generated.json` from scratch on every registration. This is fast enough for the foreseeable scale — under 50ms at 1,000 users, about 1-2 seconds at 100,000 users. diff --git a/docs/development.mdx b/docs/development.mdx index fc641429..3888283e 100644 --- a/docs/development.mdx +++ b/docs/development.mdx @@ -106,6 +106,85 @@ If either check fails, the deploy is marked as failed in GitHub Actions. The website deploys to Vercel automatically on push to `main`. No gateway restart needed — the website is a separate deployment that connects to the plugin via `OPENCLAW_API_URL`. +## User lifecycle and onboarding tests + +Use these commands to verify the V1 onboarding flow and monitor user state while testing. + +### Monitor registered users + +```bash +curl -s "http://127.0.0.1:18789/loglife/users" \ + -H "Authorization: Bearer test-key-for-local-dev" +``` + +For live monitoring: + +```bash +watch -n 2 'curl -s "http://127.0.0.1:18789/loglife/users" -H "Authorization: Bearer test-key-for-local-dev"' +``` + +### Remove a user quickly + +Use the helper script: + +```bash +bash plugin/scripts/unregister-user.sh --phone +15551234567 +``` + +You can also call the endpoint directly: + +```bash +curl -s -X POST "http://127.0.0.1:18789/loglife/unregister" \ + -H "Authorization: Bearer test-key-for-local-dev" \ + -H "Content-Type: application/json" \ + -d '{"phone":"+15551234567"}' +``` + +### V1 onboarding test checklist + + + + Remove your test phone with `unregister-user.sh`. + + + Confirm `/loglife/users` shows your phone is no longer registered. + + + + + In the dashboard, enter your phone number and continue the V1 flow: + 1) register first, 2) send/check verification code. + + + You should receive a verification code on WhatsApp and complete verification. + + + + + Query `/loglife/users` and confirm your user appears. + + + The `count` increases and your phone appears in `users`. + + + + + Repeat the same registration for the same phone. + + + Registration should not create duplicates. + + + + + Remove the same phone using `/loglife/unregister`. + + + `/loglife/users` no longer includes that user. + + + + ## Docs preview To preview documentation changes locally: diff --git a/docs/docs.json b/docs/docs.json index 732db50c..a778f2fb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -61,7 +61,9 @@ "api-reference/endpoint/get-sessions", "api-reference/endpoint/verify-send", "api-reference/endpoint/verify-check", - "api-reference/endpoint/register" + "api-reference/endpoint/register", + "api-reference/endpoint/unregister", + "api-reference/endpoint/users" ] } ] diff --git a/plugin/index.test.ts b/plugin/index.test.ts index cb877a3c..b3edd189 100644 --- a/plugin/index.test.ts +++ b/plugin/index.test.ts @@ -666,3 +666,209 @@ describe("POST /loglife/register handler", () => { expect(mockWriteFileSync).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// Handler tests: POST /loglife/unregister +// --------------------------------------------------------------------------- + +describe("POST /loglife/unregister handler", () => { + const API_KEY = "unregister-test-key"; + + let unregisterHandler: RouteHandler; + let mockWriteFileSync: ReturnType; + let usersJsonContent: string; + + beforeEach(async () => { + vi.resetModules(); + + usersJsonContent = JSON.stringify({ + users: [ + { id: "alice", identifiers: ["+15551234567"] }, + { id: "bob", identifiers: ["+15557654321"] }, + ], + defaults: { dmScope: "main" }, + }); + + mockWriteFileSync = vi.fn(); + + vi.doMock("node:fs", () => ({ + readFileSync: vi.fn().mockImplementation((path: string) => { + if (path.includes("users.json")) return usersJsonContent; + return JSON.stringify({ + agents: { defaults: { model: { primary: "x" } } }, + channels: { whatsapp: { groupPolicy: "allowlist", debounceMs: 0, mediaMaxMb: 50 } }, + bindings: [], + }); + }), + writeFileSync: mockWriteFileSync, + mkdirSync: vi.fn(), + existsSync: vi.fn().mockReturnValue(true), + })); + + vi.doMock("node:fs/promises", () => ({ + readFile: vi.fn().mockResolvedValue("{}"), + writeFile: vi.fn().mockResolvedValue(undefined), + })); + + const mod = await import("./index.js"); + const { routes, api } = createMockApi({ apiKey: API_KEY }); + mod.default.register(api as never); + unregisterHandler = routes.get("/loglife/unregister")!; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("returns 401 without auth", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/unregister", + body: { phone: "+15551234567" }, + }); + const res = mockRes(); + await unregisterHandler(req, res); + expect(res._status).toBe(401); + }); + + it("returns 405 for GET method", async () => { + const req = mockReq({ + method: "GET", + url: "/loglife/unregister", + headers: { authorization: `Bearer ${API_KEY}` }, + }); + const res = mockRes(); + await unregisterHandler(req, res); + expect(res._status).toBe(405); + }); + + it("returns 400 when phone is missing", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/unregister", + headers: { authorization: `Bearer ${API_KEY}` }, + body: {}, + }); + const res = mockRes(); + await unregisterHandler(req, res); + expect(res._status).toBe(400); + expect(res.json()).toEqual({ error: "Missing required field: phone" }); + }); + + it("returns removed:false when phone is not registered", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/unregister", + headers: { authorization: `Bearer ${API_KEY}` }, + body: { phone: "+15550000000" }, + }); + const res = mockRes(); + await unregisterHandler(req, res); + expect(res._status).toBe(200); + expect(res.json()).toEqual({ removed: false, existing: false }); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("unregisters matching phone and rewrites config files", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/unregister", + headers: { authorization: `Bearer ${API_KEY}` }, + body: { phone: "+15551234567" }, + }); + const res = mockRes(); + await unregisterHandler(req, res); + + expect(res._status).toBe(200); + const body = res.json() as Record; + expect(body.removed).toBe(true); + expect(body.removedUserIds).toEqual(["alice"]); + + // users.json, generated.json, openclaw.json + expect(mockWriteFileSync).toHaveBeenCalledTimes(3); + }); +}); + +// --------------------------------------------------------------------------- +// Handler tests: GET /loglife/users +// --------------------------------------------------------------------------- + +describe("GET /loglife/users handler", () => { + const API_KEY = "list-users-key"; + + let usersHandler: RouteHandler; + + beforeEach(async () => { + vi.resetModules(); + + const usersJsonContent = JSON.stringify({ + users: [ + { id: "alice", identifiers: ["+15551234567"], name: "Alice" }, + { id: "bob", identifiers: ["+15557654321"], name: "Bob" }, + ], + defaults: { dmScope: "main" }, + }); + + vi.doMock("node:fs", () => ({ + readFileSync: vi.fn().mockImplementation((path: string) => { + if (path.includes("users.json")) return usersJsonContent; + return "{}"; + }), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + existsSync: vi.fn().mockReturnValue(true), + })); + + vi.doMock("node:fs/promises", () => ({ + readFile: vi.fn().mockResolvedValue("{}"), + writeFile: vi.fn().mockResolvedValue(undefined), + })); + + const mod = await import("./index.js"); + const { routes, api } = createMockApi({ apiKey: API_KEY }); + mod.default.register(api as never); + usersHandler = routes.get("/loglife/users")!; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("returns 401 without auth", async () => { + const req = mockReq({ + method: "GET", + url: "/loglife/users", + }); + const res = mockRes(); + await usersHandler(req, res); + expect(res._status).toBe(401); + }); + + it("returns 405 for non-GET methods", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/users", + headers: { authorization: `Bearer ${API_KEY}` }, + body: {}, + }); + const res = mockRes(); + await usersHandler(req, res); + expect(res._status).toBe(405); + }); + + it("returns current users list", async () => { + const req = mockReq({ + method: "GET", + url: "/loglife/users", + headers: { authorization: `Bearer ${API_KEY}` }, + }); + const res = mockRes(); + await usersHandler(req, res); + expect(res._status).toBe(200); + const body = res.json() as { count: number; users: Array<{ id: string }> }; + expect(body.count).toBe(2); + expect(body.users.map((u) => u.id)).toEqual(["alice", "bob"]); + }); +}); diff --git a/plugin/index.ts b/plugin/index.ts index f0df37fb..64d310fe 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,6 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { readFile } from "node:fs/promises"; -import { readFileSync, writeFileSync, mkdirSync, existsSync, utimesSync } from "node:fs"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join } from "node:path"; import { timingSafeEqual, randomInt, createHash } from "node:crypto"; import { URL } from "node:url"; @@ -102,6 +102,69 @@ function loadUsersJson(usersJsonPath: string): UsersConfig { return validateUsersConfig(raw); } +function hasMatchingIdentifier(userIdentifiers: string[], phone: string): boolean { + const phoneIdentifiers = parseAllIdentifiers([phone]); + return userIdentifiers.some((id) => { + try { + const parsed = parseAllIdentifiers([id]); + return parsed.some((p) => + phoneIdentifiers.some((pi) => pi.channel === p.channel && pi.peerId === p.peerId), + ); + } catch { + return false; + } + }); +} + +function applyGeneratedConfigToOpenclaw( + openclawJsonPath: string, + generated: ReturnType, +): void { + if (!existsSync(openclawJsonPath)) return; + + const ocRaw = JSON.parse(readFileSync(openclawJsonPath, "utf-8")); + + const previousManagedChannels = new Set(); + const existingBindings = ocRaw.bindings as Array<{ match?: { channel?: string } }> | undefined; + for (const binding of existingBindings ?? []) { + const channel = binding?.match?.channel; + if (channel) previousManagedChannels.add(channel); + } + + ocRaw.agents = { ...ocRaw.agents, list: generated.agents.list }; + ocRaw.bindings = generated.bindings; + ocRaw.session = { ...ocRaw.session, ...generated.session }; + + if (!ocRaw.channels) ocRaw.channels = {}; + + const newManagedChannels = new Set(Object.keys(generated.channels ?? {})); + const allManagedChannels = new Set([...previousManagedChannels, ...newManagedChannels]); + + for (const channel of allManagedChannels) { + const next = (generated.channels as Record>)[channel]; + if (next) { + ocRaw.channels[channel] = { ...ocRaw.channels[channel], ...next }; + continue; + } + + // Channel used to be managed by generated config but is no longer present. + // Remove allow-list fields so stale access does not remain after unregister. + if (!ocRaw.channels[channel]) continue; + delete ocRaw.channels[channel].dmPolicy; + delete ocRaw.channels[channel].allowFrom; + if (ocRaw.channels[channel].dm && typeof ocRaw.channels[channel].dm === "object") { + delete ocRaw.channels[channel].dm.policy; + delete ocRaw.channels[channel].dm.allowFrom; + } + } + + if (generated.env) { + ocRaw.env = { ...ocRaw.env, ...generated.env }; + } + + writeFileSync(openclawJsonPath, JSON.stringify(ocRaw, null, 2) + "\n"); +} + function deriveUserId(phone: string, name: string | undefined, config: UsersConfig): string { const existingIds = new Set(config.users.map((u) => u.id)); @@ -411,18 +474,8 @@ const plugin = { const usersConfig = loadUsersJson(usersJsonPath); // Idempotent: check if phone is already registered - const phoneIdentifiers = parseAllIdentifiers([phone]); const alreadyRegistered = usersConfig.users.some((u) => - u.identifiers.some((id) => { - try { - const parsed = parseAllIdentifiers([id]); - return parsed.some((p) => - phoneIdentifiers.some((pi) => pi.channel === p.channel && pi.peerId === p.peerId), - ); - } catch { - return false; - } - }), + hasMatchingIdentifier(u.identifiers, phone), ); if (alreadyRegistered) { @@ -446,33 +499,112 @@ const plugin = { const generated = generateConfig(usersConfig); writeFileSync(generatedJsonPath, JSON.stringify(generated, null, 2) + "\n"); - // Merge generated config directly into openclaw.json. // We can't rely on $include because the gateway flattens it on hot-reload. - if (existsSync(openclawJsonPath)) { - const ocRaw = JSON.parse(readFileSync(openclawJsonPath, "utf-8")); + applyGeneratedConfigToOpenclaw(openclawJsonPath, generated); + + api.logger.info(`Registered user "${userId}" (${phone})`); + jsonResponse(res, 200, { registered: true, userId }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + api.logger.error(`Registration failed for ${phone}: ${errMsg}`); + jsonResponse(res, 500, { error: "Registration failed" }); + } + }, + }); - ocRaw.agents = { ...ocRaw.agents, list: generated.agents.list }; - ocRaw.bindings = generated.bindings; - ocRaw.session = { ...ocRaw.session, ...generated.session }; + // --- POST /loglife/unregister --- - if (!ocRaw.channels) ocRaw.channels = {}; - for (const [ch, chCfg] of Object.entries(generated.channels as Record>)) { - ocRaw.channels[ch] = { ...ocRaw.channels[ch], ...chCfg }; - } + api.registerHttpRoute({ + path: "/loglife/unregister", + handler: async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "POST") { + jsonResponse(res, 405, { error: "Method not allowed" }); + return; + } - if (generated.env) { - ocRaw.env = { ...ocRaw.env, ...generated.env }; - } + if (!apiKey || !verifyApiKey(req, apiKey)) { + jsonResponse(res, 401, { error: "Unauthorized" }); + return; + } + + let body: Record; + try { + body = await readBody(req); + } catch { + jsonResponse(res, 400, { error: "Invalid JSON body" }); + return; + } + + const phoneRaw = body.phone as string | undefined; + if (!phoneRaw || typeof phoneRaw !== "string") { + jsonResponse(res, 400, { error: "Missing required field: phone" }); + return; + } + + const phone = normalizePhone(phoneRaw); + if (phone.length < 8) { + jsonResponse(res, 400, { error: "Invalid phone number" }); + return; + } + + try { + const usersConfig = loadUsersJson(usersJsonPath); + + const before = usersConfig.users.length; + const removed = usersConfig.users.filter((u) => hasMatchingIdentifier(u.identifiers, phone)); + usersConfig.users = usersConfig.users.filter((u) => !hasMatchingIdentifier(u.identifiers, phone)); - writeFileSync(openclawJsonPath, JSON.stringify(ocRaw, null, 2) + "\n"); + if (before === usersConfig.users.length) { + jsonResponse(res, 200, { removed: false, existing: false }); + return; } - api.logger.info(`Registered user "${userId}" (${phone})`); - jsonResponse(res, 200, { registered: true, userId }); + mkdirSync(multiUserDir, { recursive: true }); + writeFileSync(usersJsonPath, JSON.stringify(usersConfig, null, 2) + "\n"); + + const generated = generateConfig(usersConfig); + writeFileSync(generatedJsonPath, JSON.stringify(generated, null, 2) + "\n"); + + applyGeneratedConfigToOpenclaw(openclawJsonPath, generated); + + api.logger.info(`Unregistered ${removed.length} user(s) for phone ${phone}`); + jsonResponse(res, 200, { + removed: true, + removedUserIds: removed.map((u) => u.id), + }); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); - api.logger.error(`Registration failed for ${phone}: ${errMsg}`); - jsonResponse(res, 500, { error: "Registration failed" }); + api.logger.error(`Unregister failed for ${phone}: ${errMsg}`); + jsonResponse(res, 500, { error: "Unregister failed" }); + } + }, + }); + + // --- GET /loglife/users --- + + api.registerHttpRoute({ + path: "/loglife/users", + handler: async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "GET") { + jsonResponse(res, 405, { error: "Method not allowed" }); + return; + } + + if (!apiKey || !verifyApiKey(req, apiKey)) { + jsonResponse(res, 401, { error: "Unauthorized" }); + return; + } + + try { + const usersConfig = loadUsersJson(usersJsonPath); + jsonResponse(res, 200, { + count: usersConfig.users.length, + users: usersConfig.users, + }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + api.logger.error(`Failed to read users list: ${errMsg}`); + jsonResponse(res, 500, { error: "Failed to read users list" }); } }, }); diff --git a/plugin/scripts/unregister-user.sh b/plugin/scripts/unregister-user.sh new file mode 100755 index 00000000..4dd9d1b2 --- /dev/null +++ b/plugin/scripts/unregister-user.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Remove a user from LogLife/OpenClaw multi-user config via plugin endpoint. +# +# Usage: +# bash plugin/scripts/unregister-user.sh --phone +15551234567 +# +# Optional env vars: +# OPENCLAW_API_URL (default: http://127.0.0.1:18789) +# OPENCLAW_API_KEY (default: test-key-for-local-dev) + +PHONE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --phone) + PHONE="${2:-}" + shift 2 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +if [[ -z "$PHONE" ]]; then + echo "Missing required option: --phone" + exit 1 +fi + +OPENCLAW_API_URL="${OPENCLAW_API_URL:-http://127.0.0.1:18789}" +OPENCLAW_API_KEY="${OPENCLAW_API_KEY:-test-key-for-local-dev}" + +echo "Unregistering $PHONE ..." +curl -sS -X POST "$OPENCLAW_API_URL/loglife/unregister" \ + -H "Authorization: Bearer $OPENCLAW_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"phone\":\"$PHONE\"}" +echo From 7b6b621f92892f58ec73ec6bb97c6d86242c0b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 7 Mar 2026 11:10:35 -0800 Subject: [PATCH 11/18] add local gateway restart helper script Add a reusable script to restart local OpenClaw after plugin code changes and document it in the development workflow. Made-with: Cursor --- docs/development.mdx | 6 +++ plugin/scripts/restart-local-gateway.sh | 54 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100755 plugin/scripts/restart-local-gateway.sh diff --git a/docs/development.mdx b/docs/development.mdx index 3888283e..5a2bc0a2 100644 --- a/docs/development.mdx +++ b/docs/development.mdx @@ -42,6 +42,12 @@ The dashboard is at `http://localhost:3000/dashboard`. When you change `plugin/index.ts`, restart the local gateway to pick up the changes. The website hot-reloads automatically. + +Use the helper script: + +```bash +bash plugin/scripts/restart-local-gateway.sh +``` diff --git a/plugin/scripts/restart-local-gateway.sh b/plugin/scripts/restart-local-gateway.sh new file mode 100755 index 00000000..f7793ef7 --- /dev/null +++ b/plugin/scripts/restart-local-gateway.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Restart local OpenClaw gateway after plugin code changes. +# +# Usage: +# bash plugin/scripts/restart-local-gateway.sh +# +# Optional env vars: +# OPENCLAW_DIR (default: ~/openclaw) +# OPENCLAW_PORT (default: 18789) +# OPENCLAW_LOG_PATH (default: ~/.openclaw/gateway-local.log) + +OPENCLAW_DIR="${OPENCLAW_DIR:-$HOME/openclaw}" +OPENCLAW_PORT="${OPENCLAW_PORT:-18789}" +OPENCLAW_LOG_PATH="${OPENCLAW_LOG_PATH:-$HOME/.openclaw/gateway-local.log}" +OPENCLAW_BIN="$OPENCLAW_DIR/openclaw.mjs" + +if [[ ! -f "$OPENCLAW_BIN" ]]; then + echo "OpenClaw binary not found at: $OPENCLAW_BIN" + echo "Set OPENCLAW_DIR if your install is elsewhere." + exit 1 +fi + +echo "Restarting local OpenClaw gateway..." + +# Try service-style restart first (works if gateway service is installed). +if "$OPENCLAW_BIN" gateway restart >/dev/null 2>&1; then + echo "Gateway restarted via service manager." +else + # Fallback for foreground/local runs. + "$OPENCLAW_BIN" gateway stop >/dev/null 2>&1 || true + pkill -f "openclaw-gateway" >/dev/null 2>&1 || true + pkill -f "openclaw.mjs gateway run" >/dev/null 2>&1 || true + sleep 1 + + mkdir -p "$(dirname "$OPENCLAW_LOG_PATH")" + nohup "$OPENCLAW_BIN" gateway run >"$OPENCLAW_LOG_PATH" 2>&1 & + sleep 2 + echo "Gateway started in background (fallback mode)." +fi + +# Basic probe +if curl -sS "http://127.0.0.1:${OPENCLAW_PORT}/" >/dev/null 2>&1; then + echo "Gateway is up: http://127.0.0.1:${OPENCLAW_PORT}/" +else + echo "Gateway did not respond on port ${OPENCLAW_PORT} yet." + echo "Check logs:" + echo " tail -n 80 \"$OPENCLAW_LOG_PATH\"" + exit 1 +fi + +echo "" +echo "Tip: after plugin edits, run this helper before testing routes." From 6a7ab4eae44184d96996438326689b927402d4e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 7 Mar 2026 11:18:46 -0800 Subject: [PATCH 12/18] implement V2 whatsapp linking flow and ops helpers Add auto-linking with LF-#### codes, dashboard polling via /api/verify/status, and hook-based verification in the plugin. Improve local ops with restart/list/unregister scripts, including unregister --all and phone input normalization without requiring a plus sign. Made-with: Cursor --- docs/development.mdx | 17 +- plugin/index.test.ts | 22 +- plugin/index.ts | 251 +++++++++++++++++++++-- plugin/scripts/check-registered-users.sh | 45 ++++ plugin/scripts/restart-local-gateway.sh | 40 ++-- plugin/scripts/unregister-user.sh | 25 ++- website/app/api/verify/status/route.ts | 50 +++++ website/app/dashboard/page.tsx | 195 +++++++++++------- 8 files changed, 520 insertions(+), 125 deletions(-) create mode 100755 plugin/scripts/check-registered-users.sh create mode 100644 website/app/api/verify/status/route.ts diff --git a/docs/development.mdx b/docs/development.mdx index 5a2bc0a2..1ffced64 100644 --- a/docs/development.mdx +++ b/docs/development.mdx @@ -118,15 +118,16 @@ Use these commands to verify the V1 onboarding flow and monitor user state while ### Monitor registered users +One-time check: + ```bash -curl -s "http://127.0.0.1:18789/loglife/users" \ - -H "Authorization: Bearer test-key-for-local-dev" +bash plugin/scripts/check-registered-users.sh ``` -For live monitoring: +Live monitoring: ```bash -watch -n 2 'curl -s "http://127.0.0.1:18789/loglife/users" -H "Authorization: Bearer test-key-for-local-dev"' +bash plugin/scripts/check-registered-users.sh --watch --interval 2 ``` ### Remove a user quickly @@ -134,7 +135,7 @@ watch -n 2 'curl -s "http://127.0.0.1:18789/loglife/users" -H "Authorization: Be Use the helper script: ```bash -bash plugin/scripts/unregister-user.sh --phone +15551234567 +bash plugin/scripts/unregister-user.sh --phone 15551234567 ``` You can also call the endpoint directly: @@ -146,6 +147,12 @@ curl -s -X POST "http://127.0.0.1:18789/loglife/unregister" \ -d '{"phone":"+15551234567"}' ``` +To clear all registered users (clean slate): + +```bash +bash plugin/scripts/unregister-user.sh --all +``` + ### V1 onboarding test checklist diff --git a/plugin/index.test.ts b/plugin/index.test.ts index b3edd189..8c4df6ee 100644 --- a/plugin/index.test.ts +++ b/plugin/index.test.ts @@ -622,6 +622,7 @@ describe("POST /loglife/register handler", () => { const body = res.json() as Record; expect(body.registered).toBe(true); expect(body.userId).toBeDefined(); + expect(body.linkCode).toMatch(/^LF-\d{4}$/); // Verify users.json, generated.json, and openclaw.json were written expect(mockWriteFileSync).toHaveBeenCalledTimes(3); @@ -661,6 +662,7 @@ describe("POST /loglife/register handler", () => { const body = res.json() as Record; expect(body.registered).toBe(true); expect(body.existing).toBe(true); + expect(body.linkCode).toMatch(/^LF-\d{4}$/); // Should NOT write files for existing user expect(mockWriteFileSync).not.toHaveBeenCalled(); @@ -753,7 +755,7 @@ describe("POST /loglife/unregister handler", () => { const res = mockRes(); await unregisterHandler(req, res); expect(res._status).toBe(400); - expect(res.json()).toEqual({ error: "Missing required field: phone" }); + expect(res.json()).toEqual({ error: "Missing required field: phone (or pass all:true)" }); }); it("returns removed:false when phone is not registered", async () => { @@ -788,6 +790,24 @@ describe("POST /loglife/unregister handler", () => { // users.json, generated.json, openclaw.json expect(mockWriteFileSync).toHaveBeenCalledTimes(3); }); + + it("supports all:true to clear all users", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/unregister", + headers: { authorization: `Bearer ${API_KEY}` }, + body: { all: true }, + }); + const res = mockRes(); + await unregisterHandler(req, res); + + expect(res._status).toBe(200); + const body = res.json() as Record; + expect(body.removedAll).toBe(true); + expect(body.removed).toBe(true); + expect(body.removedUserIds).toEqual(["alice", "bob"]); + expect(mockWriteFileSync).toHaveBeenCalledTimes(3); + }); }); // --------------------------------------------------------------------------- diff --git a/plugin/index.ts b/plugin/index.ts index 64d310fe..828ae9c0 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -23,9 +23,24 @@ export type VerificationEntry = { const VERIFY_TTL_MS = 5 * 60 * 1000; const VERIFY_COOLDOWN_MS = 60 * 1000; +const LINK_TTL_MS = 5 * 60 * 1000; +const LINK_MAX_MESSAGES = 5; +const LINK_CODE_REGEX = /^LF-\d{4}$/; export const verificationCodes = new Map(); +type PendingLink = { + code: string; + phone: string; + expiresAt: number; + messageCount: number; + createdByRegister: boolean; +}; + +const pendingLinks = new Map(); +const suppressReply = new Set(); +const verifiedPhones = new Set(); + export function normalizePhone(raw: string): string { const digits = raw.replace(/[^0-9]/g, ""); return "+" + digits; @@ -165,6 +180,18 @@ function applyGeneratedConfigToOpenclaw( writeFileSync(openclawJsonPath, JSON.stringify(ocRaw, null, 2) + "\n"); } +function generateLinkCode(): string { + return `LF-${String(randomInt(0, 10_000)).padStart(4, "0")}`; +} + +function extractPhone(value: unknown): string | undefined { + if (typeof value !== "string" || !value) return undefined; + const base = value.includes("@") ? value.split("@")[0] : value; + const digits = base.replace(/[^0-9]/g, ""); + if (!digits) return undefined; + return `+${digits}`; +} + function deriveUserId(phone: string, name: string | undefined, config: UsersConfig): string { const existingIds = new Set(config.users.map((u) => u.id)); @@ -219,6 +246,107 @@ const plugin = { const sendWA = api.runtime.channel.whatsapp.sendMessageWhatsApp as SendWhatsApp; + const removeUserByPhone = (phone: string): { removed: boolean; removedUserIds: string[] } => { + const usersConfig = loadUsersJson(usersJsonPath); + const before = usersConfig.users.length; + const removed = usersConfig.users.filter((u) => hasMatchingIdentifier(u.identifiers, phone)); + usersConfig.users = usersConfig.users.filter((u) => !hasMatchingIdentifier(u.identifiers, phone)); + + if (before === usersConfig.users.length) { + return { removed: false, removedUserIds: [] }; + } + + mkdirSync(multiUserDir, { recursive: true }); + writeFileSync(usersJsonPath, JSON.stringify(usersConfig, null, 2) + "\n"); + + const generated = generateConfig(usersConfig); + writeFileSync(generatedJsonPath, JSON.stringify(generated, null, 2) + "\n"); + applyGeneratedConfigToOpenclaw(openclawJsonPath, generated); + return { removed: true, removedUserIds: removed.map((u) => u.id) }; + }; + + const onEvent = (api as { on?: (name: string, handler: (event: any, ctx: any) => unknown) => void }).on; + if (typeof onEvent === "function") { + onEvent("message_received", async (event: any) => { + if (pendingLinks.size === 0) return; + const phone = extractPhone( + event?.metadata?.senderE164 + ?? event?.metadata?.from + ?? event?.origin?.from + ?? event?.from + ?? event?.sender + ?? event?.senderPhone, + ); + if (!phone) return; + + const pending = pendingLinks.get(phone); + if (!pending) return; + + const now = Date.now(); + const content = String( + event?.content + ?? event?.message?.text + ?? event?.message?.body + ?? event?.text + ?? "", + ).trim().toUpperCase(); + + if (content === pending.code || LINK_CODE_REGEX.test(content)) { + if (content !== pending.code) { + pending.messageCount += 1; + if (pending.messageCount >= LINK_MAX_MESSAGES || now > pending.expiresAt) { + if (pending.createdByRegister) { + try { + removeUserByPhone(phone); + } catch { + // best-effort cleanup + } + } + pendingLinks.delete(phone); + } + return; + } + + verifiedPhones.add(phone); + pendingLinks.delete(phone); + suppressReply.add(phone); + + await sendWhatsAppMessage( + sendWA, + phone, + "Welcome to LogLife! Your WhatsApp is connected. Tip: send a quick voice note about your day to get started.", + ); + return; + } + + pending.messageCount += 1; + if (pending.messageCount >= LINK_MAX_MESSAGES || now > pending.expiresAt) { + if (pending.createdByRegister) { + try { + removeUserByPhone(phone); + } catch { + // best-effort cleanup + } + } + pendingLinks.delete(phone); + } + }); + + onEvent("message_sending", (event: any) => { + if (suppressReply.size === 0) return undefined; + const phone = extractPhone( + event?.metadata?.recipientE164 + ?? event?.metadata?.to + ?? event?.deliveryContext?.to + ?? event?.to + ?? event?.recipient, + ); + if (!phone || !suppressReply.has(phone)) return undefined; + suppressReply.delete(phone); + return { cancel: true }; + }); + } + // --- GET /loglife/sessions --- api.registerHttpRoute({ @@ -479,7 +607,15 @@ const plugin = { ); if (alreadyRegistered) { - jsonResponse(res, 200, { registered: true, existing: true }); + const linkCode = generateLinkCode(); + pendingLinks.set(phone, { + code: linkCode, + phone, + expiresAt: Date.now() + LINK_TTL_MS, + messageCount: 0, + createdByRegister: false, + }); + jsonResponse(res, 200, { registered: true, existing: true, linkCode }); return; } @@ -502,8 +638,17 @@ const plugin = { // We can't rely on $include because the gateway flattens it on hot-reload. applyGeneratedConfigToOpenclaw(openclawJsonPath, generated); + const linkCode = generateLinkCode(); + pendingLinks.set(phone, { + code: linkCode, + phone, + expiresAt: Date.now() + LINK_TTL_MS, + messageCount: 0, + createdByRegister: true, + }); + api.logger.info(`Registered user "${userId}" (${phone})`); - jsonResponse(res, 200, { registered: true, userId }); + jsonResponse(res, 200, { registered: true, userId, linkCode }); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); api.logger.error(`Registration failed for ${phone}: ${errMsg}`); @@ -535,42 +680,57 @@ const plugin = { return; } + const removeAll = body.all === true; const phoneRaw = body.phone as string | undefined; - if (!phoneRaw || typeof phoneRaw !== "string") { - jsonResponse(res, 400, { error: "Missing required field: phone" }); + if (!removeAll && (!phoneRaw || typeof phoneRaw !== "string")) { + jsonResponse(res, 400, { error: "Missing required field: phone (or pass all:true)" }); return; } - const phone = normalizePhone(phoneRaw); - if (phone.length < 8) { + const phone = removeAll ? "" : normalizePhone(phoneRaw as string); + if (!removeAll && phone.length < 8) { jsonResponse(res, 400, { error: "Invalid phone number" }); return; } try { - const usersConfig = loadUsersJson(usersJsonPath); + if (removeAll) { + const usersConfig = loadUsersJson(usersJsonPath); + const removedUserIds = usersConfig.users.map((u) => u.id); + + usersConfig.users = []; + mkdirSync(multiUserDir, { recursive: true }); + writeFileSync(usersJsonPath, JSON.stringify(usersConfig, null, 2) + "\n"); + + const generated = generateConfig(usersConfig); + writeFileSync(generatedJsonPath, JSON.stringify(generated, null, 2) + "\n"); + applyGeneratedConfigToOpenclaw(openclawJsonPath, generated); + + pendingLinks.clear(); + suppressReply.clear(); + verifiedPhones.clear(); + + jsonResponse(res, 200, { + removed: removedUserIds.length > 0, + removedAll: true, + removedUserIds, + }); + return; + } - const before = usersConfig.users.length; - const removed = usersConfig.users.filter((u) => hasMatchingIdentifier(u.identifiers, phone)); - usersConfig.users = usersConfig.users.filter((u) => !hasMatchingIdentifier(u.identifiers, phone)); + pendingLinks.delete(phone); + suppressReply.delete(phone); + verifiedPhones.delete(phone); - if (before === usersConfig.users.length) { + const result = removeUserByPhone(phone); + if (!result.removed) { jsonResponse(res, 200, { removed: false, existing: false }); return; } - - mkdirSync(multiUserDir, { recursive: true }); - writeFileSync(usersJsonPath, JSON.stringify(usersConfig, null, 2) + "\n"); - - const generated = generateConfig(usersConfig); - writeFileSync(generatedJsonPath, JSON.stringify(generated, null, 2) + "\n"); - - applyGeneratedConfigToOpenclaw(openclawJsonPath, generated); - - api.logger.info(`Unregistered ${removed.length} user(s) for phone ${phone}`); + api.logger.info(`Unregistered ${result.removedUserIds.length} user(s) for phone ${phone}`); jsonResponse(res, 200, { removed: true, - removedUserIds: removed.map((u) => u.id), + removedUserIds: result.removedUserIds, }); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); @@ -580,6 +740,53 @@ const plugin = { }, }); + // --- GET /loglife/verify/status --- + + api.registerHttpRoute({ + path: "/loglife/verify/status", + handler: async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "GET") { + jsonResponse(res, 405, { error: "Method not allowed" }); + return; + } + + if (!apiKey || !verifyApiKey(req, apiKey)) { + jsonResponse(res, 401, { error: "Unauthorized" }); + return; + } + + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const phoneRaw = url.searchParams.get("phone"); + if (!phoneRaw) { + jsonResponse(res, 400, { error: "Missing required query: phone" }); + return; + } + + const phone = normalizePhone(phoneRaw); + const verified = verifiedPhones.has(phone); + const pending = pendingLinks.has(phone); + const pendingEntry = pendingLinks.get(phone); + const expired = pendingEntry ? Date.now() > pendingEntry.expiresAt : false; + + if (verified) { + verifiedPhones.delete(phone); + } + + if (pendingEntry && expired) { + if (pendingEntry.createdByRegister) { + try { + removeUserByPhone(phone); + } catch { + // best-effort cleanup + } + } + pendingLinks.delete(phone); + } + + jsonResponse(res, 200, { verified, pending, expired }); + }, + }); + // --- GET /loglife/users --- api.registerHttpRoute({ diff --git a/plugin/scripts/check-registered-users.sh b/plugin/scripts/check-registered-users.sh new file mode 100755 index 00000000..0130ef74 --- /dev/null +++ b/plugin/scripts/check-registered-users.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Check currently registered users via plugin endpoint. +# +# Usage: +# bash plugin/scripts/check-registered-users.sh +# bash plugin/scripts/check-registered-users.sh --watch +# bash plugin/scripts/check-registered-users.sh --watch --interval 2 +# +# Optional env vars: +# OPENCLAW_API_URL (default: http://127.0.0.1:18789) +# OPENCLAW_API_KEY (default: test-key-for-local-dev) + +WATCH_MODE="false" +INTERVAL="2" + +while [[ $# -gt 0 ]]; do + case "$1" in + --watch) + WATCH_MODE="true" + shift + ;; + --interval) + INTERVAL="${2:-2}" + shift 2 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +OPENCLAW_API_URL="${OPENCLAW_API_URL:-http://127.0.0.1:18789}" +OPENCLAW_API_KEY="${OPENCLAW_API_KEY:-test-key-for-local-dev}" + +cmd="curl -s \"$OPENCLAW_API_URL/loglife/users\" -H \"Authorization: Bearer $OPENCLAW_API_KEY\"" + +if [[ "$WATCH_MODE" == "true" ]]; then + watch -n "$INTERVAL" "$cmd" +else + eval "$cmd" + echo +fi diff --git a/plugin/scripts/restart-local-gateway.sh b/plugin/scripts/restart-local-gateway.sh index f7793ef7..2a745c51 100755 --- a/plugin/scripts/restart-local-gateway.sh +++ b/plugin/scripts/restart-local-gateway.sh @@ -22,26 +22,34 @@ if [[ ! -f "$OPENCLAW_BIN" ]]; then exit 1 fi -echo "Restarting local OpenClaw gateway..." +echo "Restarting local OpenClaw gateway (force local mode)..." -# Try service-style restart first (works if gateway service is installed). -if "$OPENCLAW_BIN" gateway restart >/dev/null 2>&1; then - echo "Gateway restarted via service manager." -else - # Fallback for foreground/local runs. - "$OPENCLAW_BIN" gateway stop >/dev/null 2>&1 || true - pkill -f "openclaw-gateway" >/dev/null 2>&1 || true - pkill -f "openclaw.mjs gateway run" >/dev/null 2>&1 || true - sleep 1 +# Force a clean local restart. Service manager restarts can keep stale runtime state. +"$OPENCLAW_BIN" gateway stop >/dev/null 2>&1 || true +pkill -f "openclaw-gateway" >/dev/null 2>&1 || true +pkill -f "openclaw.mjs gateway run" >/dev/null 2>&1 || true +sleep 1 - mkdir -p "$(dirname "$OPENCLAW_LOG_PATH")" - nohup "$OPENCLAW_BIN" gateway run >"$OPENCLAW_LOG_PATH" 2>&1 & - sleep 2 - echo "Gateway started in background (fallback mode)." -fi +mkdir -p "$(dirname "$OPENCLAW_LOG_PATH")" + +# Ensure gateway uses the current workspace plugin source. +"$OPENCLAW_BIN" plugins install "$HOME/loglife/plugin" --link >/dev/null 2>&1 || true + +nohup "$OPENCLAW_BIN" gateway run >"$OPENCLAW_LOG_PATH" 2>&1 & +sleep 2 +echo "Gateway started in background from current workspace plugin." # Basic probe -if curl -sS "http://127.0.0.1:${OPENCLAW_PORT}/" >/dev/null 2>&1; then +ready="false" +for _ in {1..10}; do + if curl -sS "http://127.0.0.1:${OPENCLAW_PORT}/" >/dev/null 2>&1; then + ready="true" + break + fi + sleep 1 +done + +if [[ "$ready" == "true" ]]; then echo "Gateway is up: http://127.0.0.1:${OPENCLAW_PORT}/" else echo "Gateway did not respond on port ${OPENCLAW_PORT} yet." diff --git a/plugin/scripts/unregister-user.sh b/plugin/scripts/unregister-user.sh index 4dd9d1b2..61f7439e 100755 --- a/plugin/scripts/unregister-user.sh +++ b/plugin/scripts/unregister-user.sh @@ -4,13 +4,15 @@ set -euo pipefail # Remove a user from LogLife/OpenClaw multi-user config via plugin endpoint. # # Usage: -# bash plugin/scripts/unregister-user.sh --phone +15551234567 +# bash plugin/scripts/unregister-user.sh --phone 15551234567 +# bash plugin/scripts/unregister-user.sh --all # # Optional env vars: # OPENCLAW_API_URL (default: http://127.0.0.1:18789) # OPENCLAW_API_KEY (default: test-key-for-local-dev) PHONE="" +REMOVE_ALL="false" while [[ $# -gt 0 ]]; do case "$1" in @@ -18,6 +20,10 @@ while [[ $# -gt 0 ]]; do PHONE="${2:-}" shift 2 ;; + --all) + REMOVE_ALL="true" + shift + ;; *) echo "Unknown option: $1" exit 1 @@ -25,17 +31,26 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -z "$PHONE" ]]; then - echo "Missing required option: --phone" +if [[ "$REMOVE_ALL" != "true" && -z "$PHONE" ]]; then + echo "Missing required option: --phone (or use --all)" exit 1 fi OPENCLAW_API_URL="${OPENCLAW_API_URL:-http://127.0.0.1:18789}" OPENCLAW_API_KEY="${OPENCLAW_API_KEY:-test-key-for-local-dev}" -echo "Unregistering $PHONE ..." +if [[ "$REMOVE_ALL" == "true" ]]; then + echo "Unregistering ALL users ..." + payload='{"all":true}' +else + # Accept numbers with or without + (plugin normalizes internally too). + normalized="$(echo "$PHONE" | tr -cd '0-9')" + echo "Unregistering +$normalized ..." + payload="{\"phone\":\"$normalized\"}" +fi + curl -sS -X POST "$OPENCLAW_API_URL/loglife/unregister" \ -H "Authorization: Bearer $OPENCLAW_API_KEY" \ -H "Content-Type: application/json" \ - -d "{\"phone\":\"$PHONE\"}" + -d "$payload" echo diff --git a/website/app/api/verify/status/route.ts b/website/app/api/verify/status/route.ts new file mode 100644 index 00000000..523ff028 --- /dev/null +++ b/website/app/api/verify/status/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth, clerkClient } from "@clerk/nextjs/server"; + +const OPENCLAW_API_URL = process.env.OPENCLAW_API_URL; +const OPENCLAW_API_KEY = process.env.OPENCLAW_API_KEY; + +export async function GET(req: NextRequest) { + if (!OPENCLAW_API_URL || !OPENCLAW_API_KEY) { + return NextResponse.json( + { error: "Server not configured: missing OPENCLAW_API_URL or OPENCLAW_API_KEY" }, + { status: 503 }, + ); + } + + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const phone = req.nextUrl.searchParams.get("phone"); + if (!phone) { + return NextResponse.json({ error: "Missing required query: phone" }, { status: 400 }); + } + + try { + const response = await fetch( + `${OPENCLAW_API_URL}/loglife/verify/status?phone=${encodeURIComponent(phone)}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${OPENCLAW_API_KEY}`, + }, + }, + ); + + const data = response.ok ? await response.json() : { error: await response.text() }; + if (response.ok && data.verified) { + const normalized = "+" + phone.replace(/[^0-9]/g, ""); + const client = await clerkClient(); + const user = await client.users.getUser(userId); + await client.users.updateUser(userId, { + unsafeMetadata: { ...user.unsafeMetadata, whatsappPhone: normalized }, + }); + } + + return NextResponse.json(data, { status: response.status }); + } catch { + return NextResponse.json({ error: "Failed to reach OpenClaw server" }, { status: 502 }); + } +} diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index 24ec2ad1..b09f6aea 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -52,9 +52,11 @@ export default function DashboardPage() { const [refreshing, setRefreshing] = useState(false); const menuRef = useRef(null); - const [phoneNumber, setPhoneNumber] = useState(""); - const [verifyStep, setVerifyStep] = useState<"phone" | "code">("phone"); - const [verifyCode, setVerifyCode] = useState(""); + const [countryCode, setCountryCode] = useState("1"); + const [phoneLocal, setPhoneLocal] = useState(""); + const [verifyStep, setVerifyStep] = useState<"phone" | "message">("phone"); + const [linkCode, setLinkCode] = useState(""); + const [pollUntil, setPollUntil] = useState(null); const [verifyLoading, setVerifyLoading] = useState(false); const [verifyFeedback, setVerifyFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null); @@ -104,34 +106,38 @@ export default function DashboardPage() { router.push("/"); }; - const handleSendCode = async () => { - if (!phoneNumber.trim()) return; + const fullPhone = `${countryCode}${phoneLocal}`; + const fullPhoneDisplay = `+${countryCode}${phoneLocal}`; + + const handleStartLinking = async () => { + if (!fullPhone.trim()) return; setVerifyLoading(true); setVerifyFeedback(null); try { const regRes = await fetch("/api/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ phone: phoneNumber }), + body: JSON.stringify({ phone: fullPhone }), }); const regData = await regRes.json(); - if (!regRes.ok && regRes.status !== 409) { + if (!regRes.ok) { setVerifyFeedback({ type: "error", text: regData.error || "Registration failed" }); return; } - - const res = await fetch("/api/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "send", phone: phoneNumber }), - }); - const data = await res.json(); - if (res.ok && data.sent) { - setVerifyStep("code"); - setVerifyFeedback({ type: "success", text: "Code sent! Check your WhatsApp." }); - } else { - setVerifyFeedback({ type: "error", text: data.error || "Failed to send code" }); + if (!regData.linkCode) { + setVerifyFeedback({ + type: "error", + text: "Failed to generate link code. Restart gateway and try again.", + }); + return; } + setLinkCode(String(regData.linkCode)); + setVerifyStep("message"); + setPollUntil(Date.now() + 5 * 60 * 1000); + setVerifyFeedback({ + type: "success", + text: "Registered. Tap the WhatsApp button below and send the pre-filled code to finish linking.", + }); } catch { setVerifyFeedback({ type: "error", text: "Network error. Please try again." }); } finally { @@ -139,29 +145,50 @@ export default function DashboardPage() { } }; - const handleVerifyCode = async () => { - if (!verifyCode.trim()) return; - setVerifyLoading(true); - setVerifyFeedback(null); - try { - const res = await fetch("/api/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "check", phone: phoneNumber, code: verifyCode.trim() }), - }); - const data = await res.json(); - if (res.ok && data.verified) { - setVerifyFeedback({ type: "success", text: "Verified! Your WhatsApp is connected." }); - await user.reload(); - } else { - setVerifyFeedback({ type: "error", text: data.error || "Invalid or expired code" }); + useEffect(() => { + if (verifyStep !== "message" || !fullPhone || !pollUntil) return; + let timer: ReturnType | undefined; + let cancelled = false; + + const poll = async () => { + if (Date.now() > pollUntil) { + if (!cancelled) { + setVerifyFeedback({ + type: "error", + text: "Linking timed out after 5 minutes. Start again to generate a new code.", + }); + setVerifyStep("phone"); + setLinkCode(""); + setPollUntil(null); + } + if (timer) clearInterval(timer); + return; } - } catch { - setVerifyFeedback({ type: "error", text: "Network error. Please try again." }); - } finally { - setVerifyLoading(false); - } - }; + + try { + const res = await fetch(`/api/verify/status?phone=${encodeURIComponent(fullPhone)}`); + const data = await res.json(); + if (res.ok && data.verified) { + setVerifyFeedback({ type: "success", text: "Verified! Your WhatsApp is connected." }); + setPollUntil(null); + if (timer) clearInterval(timer); + await user.reload(); + } + } catch { + // Best-effort polling; keep trying until timeout. + } + }; + + void poll(); + timer = setInterval(() => { + void poll(); + }, 2000); + + return () => { + cancelled = true; + if (timer) clearInterval(timer); + }; + }, [verifyStep, fullPhone, pollUntil, user]); return (
@@ -305,7 +332,7 @@ export default function DashboardPage() {

Connect your WhatsApp

- Enter your phone number to receive a verification code on WhatsApp and link your dashboard. + Enter your phone number, then send the generated code from WhatsApp to link your dashboard.

@@ -320,7 +347,7 @@ export default function DashboardPage() {

WhatsApp verification

- {verifyStep === "phone" ? "Step 1 of 2 — enter your number" : "Step 2 of 2 — enter the code"} + {verifyStep === "phone" ? "Step 1 of 2 — enter your number" : "Step 2 of 2 — send code in WhatsApp"}

@@ -331,23 +358,32 @@ export default function DashboardPage() {
-
- + -
{ + setCountryCode(e.target.value.replace(/[^0-9]/g, "").slice(0, 4)); + setVerifyFeedback(null); + }} + onKeyDown={(e) => { if (e.key === "Enter" && fullPhone.trim()) handleStartLinking(); }} + className="w-24 rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600" + placeholder="1" + aria-label="Country code" + /> + { - setPhoneNumber(e.target.value.replace(/[^0-9]/g, "")); + setPhoneLocal(e.target.value.replace(/[^0-9]/g, "")); setVerifyFeedback(null); }} - onKeyDown={(e) => { if (e.key === "Enter" && phoneNumber.trim()) handleSendCode(); }} + onKeyDown={(e) => { if (e.key === "Enter" && fullPhone.trim()) handleStartLinking(); }} className="w-full rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600" - placeholder="1 555 123 4567" + placeholder="5551234567" autoFocus />
-

Include your country code (e.g. 1 for US, 44 for UK)

+

Country code in first box (e.g. 1, 44) and phone number in second box.

@@ -356,38 +392,45 @@ export default function DashboardPage() {

- We'll send a 6-digit code to verify you own this number. The code expires in 5 minutes. + We'll register this phone to your account, then generate a code for you to send from WhatsApp. Code expires in 5 minutes.

) : ( -
- - { - setVerifyCode(e.target.value.replace(/[^0-9]/g, "")); - setVerifyFeedback(null); - }} - onKeyDown={(e) => { if (e.key === "Enter" && verifyCode.length === 6) handleVerifyCode(); }} - className="w-full rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600 font-mono text-center text-lg tracking-[0.5em]" - placeholder="000000" - autoFocus - /> + <> +
+

+ Send this code from your WhatsApp number to finish linking: +

+

{linkCode}

+

Phone: {fullPhoneDisplay}

+
+ + {process.env.NEXT_PUBLIC_LOGLIFE_WHATSAPP_NUMBER && linkCode && ( + + + + + Send code on WhatsApp + + )} +

- Sent to +{phoneNumber}.{" "} + Waiting for your WhatsApp message.{" "}

-
+ )} {verifyFeedback && ( @@ -401,8 +444,8 @@ export default function DashboardPage() { )} From fe10a5f3c3b369af382a2f52d52800a01555e9b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 7 Mar 2026 11:43:06 -0800 Subject: [PATCH 13/18] streamline v2 waiting screen and add pending-link expiry cleanup Simplify step-two WhatsApp verification UI to a clear three-element flow (instruction, send button, waiting status). Add a background expiry sweep for pending links so unlinked numbers are automatically removed after timeout even if the user abandons the page. Made-with: Cursor --- plugin/index.ts | 26 ++++++ website/app/dashboard/page.tsx | 161 +++++++++++++++++++-------------- 2 files changed, 117 insertions(+), 70 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 828ae9c0..1a019a9f 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -265,6 +265,32 @@ const plugin = { return { removed: true, removedUserIds: removed.map((u) => u.id) }; }; + const cleanupExpiredPendingLinks = () => { + const now = Date.now(); + for (const [phone, pending] of pendingLinks.entries()) { + if (now <= pending.expiresAt) continue; + if (pending.createdByRegister) { + try { + removeUserByPhone(phone); + } catch { + // best-effort cleanup + } + } + pendingLinks.delete(phone); + suppressReply.delete(phone); + verifiedPhones.delete(phone); + } + }; + + const cleanupTimer = setInterval(() => { + try { + cleanupExpiredPendingLinks(); + } catch { + // best-effort background maintenance + } + }, 30_000); + cleanupTimer.unref?.(); + const onEvent = (api as { on?: (name: string, handler: (event: any, ctx: any) => unknown) => void }).on; if (typeof onEvent === "function") { onEvent("message_received", async (event: any) => { diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index b09f6aea..7a54f5b2 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -42,6 +42,25 @@ function formatTokens(count: number | undefined | null): string { return count.toString(); } +function normalizeWaMeTarget(raw: string | undefined): string { + if (!raw) return ""; + const trimmed = raw.trim(); + if (!trimmed) return ""; + + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + try { + const url = new URL(trimmed); + const parts = url.pathname.split("/").filter(Boolean); + const last = parts[parts.length - 1] ?? ""; + return last.replace(/[^0-9]/g, ""); + } catch { + // fall through and try digit extraction from raw value + } + } + + return trimmed.replace(/[^0-9]/g, ""); +} + export default function DashboardPage() { const { user, isLoaded } = useUser(); const { signOut } = useClerk(); @@ -61,6 +80,7 @@ export default function DashboardPage() { const [verifyFeedback, setVerifyFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null); const whatsappPhone = (user?.unsafeMetadata as Record | undefined)?.whatsappPhone || ""; + const waTarget = normalizeWaMeTarget(process.env.NEXT_PUBLIC_LOGLIFE_WHATSAPP_NUMBER); const fetchSession = useCallback((isRefresh = false) => { if (!whatsappPhone) { @@ -134,10 +154,7 @@ export default function DashboardPage() { setLinkCode(String(regData.linkCode)); setVerifyStep("message"); setPollUntil(Date.now() + 5 * 60 * 1000); - setVerifyFeedback({ - type: "success", - text: "Registered. Tap the WhatsApp button below and send the pre-filled code to finish linking.", - }); + setVerifyFeedback(null); } catch { setVerifyFeedback({ type: "error", text: "Network error. Please try again." }); } finally { @@ -295,9 +312,9 @@ export default function DashboardPage() {

Send a message on WhatsApp to start your first session

- {process.env.NEXT_PUBLIC_LOGLIFE_WHATSAPP_NUMBER && ( + {waTarget && ( - Message LogLife on WhatsApp + Send a message on WhatsApp )} -

+
+

+ Waiting for your WhatsApp message from {fullPhoneDisplay}. +

+
)} - {verifyFeedback && ( + {verifyFeedback && (verifyStep === "phone" || verifyFeedback.type === "error") && (
)} - + {verifyStep === "phone" && ( + + )}
From be37c992f9c10df963905fbc3b6c1495396c02b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 7 Mar 2026 11:51:56 -0800 Subject: [PATCH 14/18] refine v2 whatsapp step-two UX and add change-number action Simplifies the link step into a single registered/code message plus one clear WhatsApp CTA, and adds a change-number action while waiting so users can quickly correct wrong numbers. --- website/app/dashboard/page.tsx | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index 7a54f5b2..25b29117 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -425,9 +425,9 @@ export default function DashboardPage() { <>

- Register. Now click the button below to send this text: + Registered. Here's the generated code:{" "} + {linkCode}

-

{linkCode}

{waTarget && linkCode ? ( @@ -435,12 +435,12 @@ export default function DashboardPage() { href={`https://wa.me/${waTarget}?text=${encodeURIComponent(linkCode)}`} target="_blank" rel="noopener noreferrer" - className="inline-flex w-full items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white bg-[#25D366] hover:bg-[#20bd5a] transition-all" + className="inline-flex w-full items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white bg-[#25D366] border border-[#35df79] hover:bg-[#20bd5a] hover:shadow-[0_0_20px_rgba(37,211,102,0.45)] hover:scale-[1.01] transition-all" > - Click here to text this code to log in + Click here to send this on WhatsApp ) : (
@@ -450,7 +450,19 @@ export default function DashboardPage() {

- Waiting for your WhatsApp message from {fullPhoneDisplay}. + Waiting from a WhatsApp message from {fullPhoneDisplay}. + {" "} +

From da4d8b088c70f6b1c981ac4d90fa205c6ede1b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 7 Mar 2026 12:19:57 -0800 Subject: [PATCH 15/18] harden v2 link cleanup, suppress code echo, and add countdown UX Persists pending-link state across restarts, strengthens code-message suppression, and runs cleanup during status/user checks so stale provisional registrations are removed reliably. Also adds a visible verification countdown so users can see exactly when a link expires. --- docs/security.mdx | 46 ++++++++---- plugin/index.test.ts | 14 ++-- plugin/index.ts | 130 +++++++++++++++++++++++++-------- website/app/dashboard/page.tsx | 118 ++++++++++++++++++------------ 4 files changed, 211 insertions(+), 97 deletions(-) diff --git a/docs/security.mdx b/docs/security.mdx index 4ad17804..57b7e3c6 100644 --- a/docs/security.mdx +++ b/docs/security.mdx @@ -45,22 +45,22 @@ User authentication is handled by **Clerk**. The website's API routes verify tha - An unauthenticated browser request to `/api/verify` is rejected before it ever reaches the plugin. - The plugin itself does not need to know about individual users — it trusts the website's Bearer token. -## Phone verification +## WhatsApp linking security model (current V2 flow) -Phone ownership is proven through a **6-digit verification code** sent via WhatsApp: +Phone ownership is proven through a **user-initiated WhatsApp message** containing an `LF-XXXX` code. -1. User enters their phone number on the dashboard. -2. The plugin generates a code using `crypto.randomInt` (cryptographically secure). -3. The code is sent to the phone via the OpenClaw gateway. -4. The user enters the code on the dashboard. -5. The plugin compares it using `crypto.timingSafeEqual`. +1. User enters phone on the dashboard. +2. Backend registers the phone and generates a short-lived link code. +3. User taps a prefilled WhatsApp button and sends the code from that phone. +4. Plugin auto-verifies, marks link complete, and sends a welcome message. -### Protections +### Expiry and cleanup behavior -- **5-minute TTL**: Codes expire after 5 minutes. Expired codes are rejected. -- **Single use**: A code is deleted immediately after successful verification. It cannot be reused. -- **Rate limiting**: Only one code can be sent per phone number per 60 seconds. Repeated requests return `429 Too Many Requests`. -- **Timing-safe comparison**: Code comparison uses constant-time equality to prevent timing side-channel attacks. +- **TTL**: Pending link codes expire after 5 minutes. +- **Sweep interval**: Expired pending links are cleaned every 30 seconds. +- **Effective removal window**: cleanup usually happens between `5:00` and `5:30` after link creation (or earlier if message-count guardrails trigger). +- **Persistence across restarts**: pending links are stored in `multi-user/pending-links.json`, so a restart does not silently drop pending-link cleanup state. +- **Wrong-number rollback scope**: only users created during the current register flow are auto-removed on expiry; pre-existing users are not auto-deleted. ## Rate limits @@ -68,8 +68,8 @@ Phone ownership is proven through a **6-digit verification code** sent via Whats | Resource | Limit | Window | |---|---|---| -| Verification code sends | 1 per phone | 60 seconds | -| Verification code validity | 1 code | 5 minutes | +| Verify/send code API (legacy flow) | 1 per phone | 60 seconds | +| Pending link validity (V2 flow) | 1 link | 5 minutes | ### Planned limits @@ -80,7 +80,23 @@ As LogLife scales, additional guardrails will be added: - **Token budgets** — per-user token consumption caps per billing period - **Usage dashboard** — visible on the dashboard so users can monitor their own consumption -## Data flow +## Stage-by-stage attacks and mitigations + +| Stage | Possible attacks | Current mitigation | +|---|---|---| +| Dashboard register request | Unauthenticated caller attempts registration; forged client requests | Clerk session required by website API route; plugin route protected by Bearer API key; timing-safe token compare | +| Phone entry and linking | User enters wrong number; script repeatedly requests links | 5-minute TTL; 30-second sweeper; pending-link message-count cap; existing-user path is idempotent | +| Pending (not verified yet) | Unverified sender tries to trigger agent replies by messaging random text/code-like payloads | Pending links are tracked per phone; linking messages are intercepted and canceled before normal agent reply path | +| Code replay / brute-force | Try old codes, random `LF-XXXX` payloads, or repeated guesses | Exact-code match required per phone; pending link expires quickly; wrong attempts increase counters and can trigger cleanup | +| Verified messaging | Abuse via high message volume or expensive prompts | Allow-list DM policy; account-level controls and disable path; planned per-user message/token budgets | +| Infra / operational | Gateway restart during linking; stale in-memory state | Pending-link state is persisted to disk and reloaded; cleanup continues after restart | + +### Residual risk and intentional behavior + +- Existing users are intentionally not auto-deleted when a fresh link attempt expires. This avoids deleting valid long-lived users during accidental re-link attempts. +- If you need stronger enforcement (for example, "must re-verify before any further messaging"), use a stricter reconnect mode that temporarily suspends allow-list access until verification completes. + +## Data flow (legacy verification API) ```mermaid %%{init: {'theme': 'neutral'}}%% diff --git a/plugin/index.test.ts b/plugin/index.test.ts index 8c4df6ee..365bae27 100644 --- a/plugin/index.test.ts +++ b/plugin/index.test.ts @@ -624,8 +624,8 @@ describe("POST /loglife/register handler", () => { expect(body.userId).toBeDefined(); expect(body.linkCode).toMatch(/^LF-\d{4}$/); - // Verify users.json, generated.json, and openclaw.json were written - expect(mockWriteFileSync).toHaveBeenCalledTimes(3); + // users.json, generated.json, openclaw.json, pending-links.json + expect(mockWriteFileSync).toHaveBeenCalledTimes(4); // utimesSync no longer used — openclaw.json is written directly expect(mockUtimesSync).not.toHaveBeenCalled(); }); @@ -664,8 +664,12 @@ describe("POST /loglife/register handler", () => { expect(body.existing).toBe(true); expect(body.linkCode).toMatch(/^LF-\d{4}$/); - // Should NOT write files for existing user - expect(mockWriteFileSync).not.toHaveBeenCalled(); + // Existing user still gets a fresh pending-link entry on disk + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.stringContaining("pending-links.json"), + expect.any(String), + ); }); }); @@ -806,7 +810,7 @@ describe("POST /loglife/unregister handler", () => { expect(body.removedAll).toBe(true); expect(body.removed).toBe(true); expect(body.removedUserIds).toEqual(["alice", "bob"]); - expect(mockWriteFileSync).toHaveBeenCalledTimes(3); + expect(mockWriteFileSync).toHaveBeenCalledTimes(4); }); }); diff --git a/plugin/index.ts b/plugin/index.ts index 1a019a9f..4bfef3f3 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -26,6 +26,7 @@ const VERIFY_COOLDOWN_MS = 60 * 1000; const LINK_TTL_MS = 5 * 60 * 1000; const LINK_MAX_MESSAGES = 5; const LINK_CODE_REGEX = /^LF-\d{4}$/; +const SUPPRESS_REPLY_TTL_MS = 30_000; export const verificationCodes = new Map(); @@ -38,7 +39,7 @@ type PendingLink = { }; const pendingLinks = new Map(); -const suppressReply = new Set(); +const suppressReply = new Map(); const verifiedPhones = new Set(); export function normalizePhone(raw: string): string { @@ -192,6 +193,14 @@ function extractPhone(value: unknown): string | undefined { return `+${digits}`; } +function extractFirstPhone(...values: unknown[]): string | undefined { + for (const value of values) { + const phone = extractPhone(value); + if (phone) return phone; + } + return undefined; +} + function deriveUserId(phone: string, name: string | undefined, config: UsersConfig): string { const existingIds = new Set(config.users.map((u) => u.id)); @@ -242,10 +251,55 @@ const plugin = { const multiUserDir = cfg.multiUserDir ?? join(stateDir, "multi-user"); const usersJsonPath = join(multiUserDir, "users.json"); const generatedJsonPath = join(multiUserDir, "generated.json"); + const pendingLinksPath = join(multiUserDir, "pending-links.json"); const openclawJsonPath = join(stateDir, "openclaw.json"); const sendWA = api.runtime.channel.whatsapp.sendMessageWhatsApp as SendWhatsApp; + const persistPendingLinks = () => { + mkdirSync(multiUserDir, { recursive: true }); + const records = [...pendingLinks.values()]; + writeFileSync(pendingLinksPath, JSON.stringify({ pending: records }, null, 2) + "\n"); + }; + + const upsertPendingLink = (entry: PendingLink) => { + pendingLinks.set(entry.phone, entry); + persistPendingLinks(); + }; + + const removePendingLink = (phone: string) => { + const removed = pendingLinks.delete(phone); + if (removed) persistPendingLinks(); + }; + + const clearSuppressReply = (phone: string) => { + suppressReply.delete(phone); + }; + + const addSuppressReply = (phone: string) => { + suppressReply.set(phone, Date.now() + SUPPRESS_REPLY_TTL_MS); + }; + + try { + if (existsSync(pendingLinksPath)) { + const raw = JSON.parse(readFileSync(pendingLinksPath, "utf-8")) as { pending?: PendingLink[] }; + for (const entry of raw.pending ?? []) { + const phone = normalizePhone(entry.phone ?? ""); + if (!phone || !entry.code || !entry.expiresAt) continue; + pendingLinks.set(phone, { + code: String(entry.code).trim().toUpperCase(), + phone, + expiresAt: Number(entry.expiresAt), + messageCount: Number(entry.messageCount ?? 0), + createdByRegister: Boolean(entry.createdByRegister), + }); + } + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + api.logger.warn(`Failed to load pending links: ${errMsg}`); + } + const removeUserByPhone = (phone: string): { removed: boolean; removedUserIds: string[] } => { const usersConfig = loadUsersJson(usersJsonPath); const before = usersConfig.users.length; @@ -267,6 +321,7 @@ const plugin = { const cleanupExpiredPendingLinks = () => { const now = Date.now(); + let changed = false; for (const [phone, pending] of pendingLinks.entries()) { if (now <= pending.expiresAt) continue; if (pending.createdByRegister) { @@ -277,9 +332,19 @@ const plugin = { } } pendingLinks.delete(phone); - suppressReply.delete(phone); + changed = true; + clearSuppressReply(phone); verifiedPhones.delete(phone); } + + for (const [phone, expiresAt] of suppressReply.entries()) { + if (now <= expiresAt) continue; + suppressReply.delete(phone); + } + + if (changed) { + persistPendingLinks(); + } }; const cleanupTimer = setInterval(() => { @@ -328,21 +393,21 @@ const plugin = { // best-effort cleanup } } - pendingLinks.delete(phone); + removePendingLink(phone); } - return; + return { cancel: true }; } verifiedPhones.add(phone); - pendingLinks.delete(phone); - suppressReply.add(phone); + removePendingLink(phone); + addSuppressReply(phone); await sendWhatsAppMessage( sendWA, phone, "Welcome to LogLife! Your WhatsApp is connected. Tip: send a quick voice note about your day to get started.", ); - return; + return { cancel: true }; } pending.messageCount += 1; @@ -354,20 +419,33 @@ const plugin = { // best-effort cleanup } } - pendingLinks.delete(phone); + removePendingLink(phone); + return { cancel: true }; } + + upsertPendingLink(pending); + return { cancel: true }; }); onEvent("message_sending", (event: any) => { if (suppressReply.size === 0) return undefined; - const phone = extractPhone( - event?.metadata?.recipientE164 - ?? event?.metadata?.to - ?? event?.deliveryContext?.to - ?? event?.to - ?? event?.recipient, + const now = Date.now(); + const phone = extractFirstPhone( + event?.metadata?.recipientE164, + event?.metadata?.to, + event?.deliveryContext?.to, + event?.origin?.to, + event?.to, + event?.recipient, + event?.recipientPhone, ); - if (!phone || !suppressReply.has(phone)) return undefined; + if (!phone) return undefined; + const suppressUntil = suppressReply.get(phone); + if (!suppressUntil) return undefined; + if (now > suppressUntil) { + suppressReply.delete(phone); + return undefined; + } suppressReply.delete(phone); return { cancel: true }; }); @@ -634,7 +712,7 @@ const plugin = { if (alreadyRegistered) { const linkCode = generateLinkCode(); - pendingLinks.set(phone, { + upsertPendingLink({ code: linkCode, phone, expiresAt: Date.now() + LINK_TTL_MS, @@ -665,7 +743,7 @@ const plugin = { applyGeneratedConfigToOpenclaw(openclawJsonPath, generated); const linkCode = generateLinkCode(); - pendingLinks.set(phone, { + upsertPendingLink({ code: linkCode, phone, expiresAt: Date.now() + LINK_TTL_MS, @@ -733,6 +811,7 @@ const plugin = { applyGeneratedConfigToOpenclaw(openclawJsonPath, generated); pendingLinks.clear(); + persistPendingLinks(); suppressReply.clear(); verifiedPhones.clear(); @@ -744,8 +823,8 @@ const plugin = { return; } - pendingLinks.delete(phone); - suppressReply.delete(phone); + removePendingLink(phone); + clearSuppressReply(phone); verifiedPhones.delete(phone); const result = removeUserByPhone(phone); @@ -789,6 +868,7 @@ const plugin = { } const phone = normalizePhone(phoneRaw); + cleanupExpiredPendingLinks(); const verified = verifiedPhones.has(phone); const pending = pendingLinks.has(phone); const pendingEntry = pendingLinks.get(phone); @@ -798,17 +878,6 @@ const plugin = { verifiedPhones.delete(phone); } - if (pendingEntry && expired) { - if (pendingEntry.createdByRegister) { - try { - removeUserByPhone(phone); - } catch { - // best-effort cleanup - } - } - pendingLinks.delete(phone); - } - jsonResponse(res, 200, { verified, pending, expired }); }, }); @@ -829,6 +898,7 @@ const plugin = { } try { + cleanupExpiredPendingLinks(); const usersConfig = loadUsersJson(usersJsonPath); jsonResponse(res, 200, { count: usersConfig.users.length, diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index 25b29117..1d186949 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -61,6 +61,12 @@ function normalizeWaMeTarget(raw: string | undefined): string { return trimmed.replace(/[^0-9]/g, ""); } +function formatCountdown(totalSeconds: number): string { + const mins = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; +} + export default function DashboardPage() { const { user, isLoaded } = useUser(); const { signOut } = useClerk(); @@ -76,11 +82,14 @@ export default function DashboardPage() { const [verifyStep, setVerifyStep] = useState<"phone" | "message">("phone"); const [linkCode, setLinkCode] = useState(""); const [pollUntil, setPollUntil] = useState(null); + const [pollNow, setPollNow] = useState(Date.now()); const [verifyLoading, setVerifyLoading] = useState(false); const [verifyFeedback, setVerifyFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null); const whatsappPhone = (user?.unsafeMetadata as Record | undefined)?.whatsappPhone || ""; const waTarget = normalizeWaMeTarget(process.env.NEXT_PUBLIC_LOGLIFE_WHATSAPP_NUMBER); + const fullPhone = `${countryCode}${phoneLocal}`; + const fullPhoneDisplay = `+${countryCode}${phoneLocal}`; const fetchSession = useCallback((isRefresh = false) => { if (!whatsappPhone) { @@ -108,6 +117,59 @@ export default function DashboardPage() { return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + useEffect(() => { + if (!user || verifyStep !== "message" || !fullPhone || !pollUntil) return; + let cancelled = false; + + const poll = async () => { + if (Date.now() > pollUntil) { + if (!cancelled) { + setVerifyFeedback({ + type: "error", + text: "Linking timed out after 5 minutes. Start again to generate a new code.", + }); + setVerifyStep("phone"); + setLinkCode(""); + setPollUntil(null); + } + clearInterval(timer); + return; + } + + try { + const res = await fetch(`/api/verify/status?phone=${encodeURIComponent(fullPhone)}`); + const data = await res.json(); + if (res.ok && data.verified) { + setVerifyFeedback({ type: "success", text: "Verified! Your WhatsApp is connected." }); + setPollUntil(null); + clearInterval(timer); + await user.reload(); + } + } catch { + // Best-effort polling; keep trying until timeout. + } + }; + + const timer: ReturnType = setInterval(() => { + void poll(); + }, 2000); + + void poll(); + + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [verifyStep, fullPhone, pollUntil, user]); + + useEffect(() => { + if (verifyStep !== "message" || !pollUntil) return; + const ticker = setInterval(() => { + setPollNow(Date.now()); + }, 1000); + return () => clearInterval(ticker); + }, [verifyStep, pollUntil]); + if (!isLoaded) { return (
@@ -126,9 +188,6 @@ export default function DashboardPage() { router.push("/"); }; - const fullPhone = `${countryCode}${phoneLocal}`; - const fullPhoneDisplay = `+${countryCode}${phoneLocal}`; - const handleStartLinking = async () => { if (!fullPhone.trim()) return; setVerifyLoading(true); @@ -162,50 +221,9 @@ export default function DashboardPage() { } }; - useEffect(() => { - if (verifyStep !== "message" || !fullPhone || !pollUntil) return; - let timer: ReturnType | undefined; - let cancelled = false; - - const poll = async () => { - if (Date.now() > pollUntil) { - if (!cancelled) { - setVerifyFeedback({ - type: "error", - text: "Linking timed out after 5 minutes. Start again to generate a new code.", - }); - setVerifyStep("phone"); - setLinkCode(""); - setPollUntil(null); - } - if (timer) clearInterval(timer); - return; - } - - try { - const res = await fetch(`/api/verify/status?phone=${encodeURIComponent(fullPhone)}`); - const data = await res.json(); - if (res.ok && data.verified) { - setVerifyFeedback({ type: "success", text: "Verified! Your WhatsApp is connected." }); - setPollUntil(null); - if (timer) clearInterval(timer); - await user.reload(); - } - } catch { - // Best-effort polling; keep trying until timeout. - } - }; - - void poll(); - timer = setInterval(() => { - void poll(); - }, 2000); - - return () => { - cancelled = true; - if (timer) clearInterval(timer); - }; - }, [verifyStep, fullPhone, pollUntil, user]); + const countdownSeconds = pollUntil + ? Math.max(0, Math.ceil((pollUntil - pollNow) / 1000)) + : 0; return (
@@ -452,6 +470,12 @@ export default function DashboardPage() {

Waiting from a WhatsApp message from {fullPhoneDisplay}. {" "} + {pollUntil && ( + + Code expires in {formatCountdown(countdownSeconds)}. + + )} + {" "}

) : ( diff --git a/website/app/api/unregister/route.ts b/website/app/api/unregister/route.ts new file mode 100644 index 00000000..356f1ea1 --- /dev/null +++ b/website/app/api/unregister/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; + +const OPENCLAW_API_URL = process.env.OPENCLAW_API_URL; +const OPENCLAW_API_KEY = process.env.OPENCLAW_API_KEY; + +export async function POST(req: NextRequest) { + if (!OPENCLAW_API_URL || !OPENCLAW_API_KEY) { + return NextResponse.json( + { error: "Server not configured: missing OPENCLAW_API_URL or OPENCLAW_API_KEY" }, + { status: 503 }, + ); + } + + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: { phone?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { phone } = body; + if (!phone) { + return NextResponse.json({ error: "Missing required field: phone" }, { status: 400 }); + } + + try { + const response = await fetch(`${OPENCLAW_API_URL}/loglife/unregister`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENCLAW_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ phone }), + }); + + const data = response.ok ? await response.json() : { error: await response.text() }; + return NextResponse.json(data, { status: response.status }); + } catch { + return NextResponse.json({ error: "Failed to reach OpenClaw server" }, { status: 502 }); + } +} diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index 1d186949..bd813692 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -221,6 +221,26 @@ export default function DashboardPage() { } }; + const handleChangeNumber = async () => { + const phoneToRemove = fullPhone.trim(); + if (phoneToRemove) { + try { + await fetch("/api/unregister", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phone: phoneToRemove }), + }); + } catch { + // Best-effort cleanup. We still reset local state so user can continue. + } + } + + setVerifyStep("phone"); + setLinkCode(""); + setVerifyFeedback(null); + setPollUntil(null); + }; + const countdownSeconds = pollUntil ? Math.max(0, Math.ceil((pollUntil - pollNow) / 1000)) : 0; @@ -466,28 +486,28 @@ export default function DashboardPage() { )} -
+

- Waiting from a WhatsApp message from {fullPhoneDisplay}. - {" "} - {pollUntil && ( - - Code expires in {formatCountdown(countdownSeconds)}. - - )} - {" "} + Waiting for a WhatsApp message from{" "} + {fullPhoneDisplay}. +

+ +

+ Wrong number?{" "}

+ + {pollUntil && ( +

+ Code expires in{" "} + {formatCountdown(countdownSeconds)}. +

+ )}
)} From 661c0567a502dd4e7b10e4af3a727d2b3eef9146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 7 Mar 2026 13:07:59 -0800 Subject: [PATCH 17/18] force-suppress LF control messages and add subtle code copy action Treats LF-#### as a control token so linking codes never trigger user-facing assistant replies, including web auto-reply paths. Also adds a minimal copy affordance for the generated linking code in the dashboard. --- plugin/index.ts | 81 ++++++++++++++++++++++++++++++---- website/app/dashboard/page.tsx | 31 +++++++++++-- 2 files changed, 100 insertions(+), 12 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 45f745fc..9863e5f3 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -28,6 +28,8 @@ const LINK_MAX_MESSAGES = 5; const LINK_CODE_REGEX = /^LF-\d{4}$/; const SUPPRESS_REPLY_TTL_MS = 30_000; const LINK_WELCOME_TEXT = "Welcome to LogLife! Your WhatsApp is connected. Tip: send a quick voice note about your day to get started."; +const SILENT_REPLY_TOKEN = "NO_REPLY"; +const RECENT_CODE_TTL_MS = 2 * 60 * 1000; export const verificationCodes = new Map(); @@ -42,6 +44,7 @@ type PendingLink = { const pendingLinks = new Map(); const suppressReply = new Map(); const verifiedPhones = new Set(); +const recentlyVerifiedCodes = new Map(); export function normalizePhone(raw: string): string { const digits = raw.replace(/[^0-9]/g, ""); @@ -202,6 +205,32 @@ function extractFirstPhone(...values: unknown[]): string | undefined { return undefined; } +function extractLinkCode(text: string): string | undefined { + const match = text.toUpperCase().match(/\bLF-\d{4}\b/); + return match?.[0]; +} + +function messageText(value: unknown): string { + if (!value || typeof value !== "object") { + return typeof value === "string" ? value : ""; + } + const rec = value as Record; + if (typeof rec.content === "string") return rec.content; + if (Array.isArray(rec.content)) { + return rec.content + .map((part) => { + if (typeof part === "string") return part; + if (!part || typeof part !== "object") return ""; + const p = part as Record; + return typeof p.text === "string" ? p.text : ""; + }) + .filter(Boolean) + .join("\n"); + } + if (typeof rec.text === "string") return rec.text; + return ""; +} + function deriveUserId(phone: string, name: string | undefined, config: UsersConfig): string { const existingIds = new Set(config.users.map((u) => u.id)); @@ -346,6 +375,11 @@ const plugin = { suppressReply.delete(phone); } + for (const [code, expiresAt] of recentlyVerifiedCodes.entries()) { + if (now <= expiresAt) continue; + recentlyVerifiedCodes.delete(code); + } + if (changed) { persistPendingLinks(); } @@ -403,6 +437,7 @@ const plugin = { } verifiedPhones.add(phone); + recentlyVerifiedCodes.set(pending.code, Date.now() + RECENT_CODE_TTL_MS); removePendingLink(phone); // Suppress the first generated reply after linking code. // Use remaining=2 to stay robust across channel internals that may emit an @@ -434,8 +469,24 @@ const plugin = { return { cancel: true }; }); - onEvent("message_sending", (event: any) => { - if (suppressReply.size === 0) return undefined; + onEvent("before_prompt_build", (event: any) => { + const messages = Array.isArray(event?.messages) ? event.messages : []; + const last = messages.length > 0 ? messages[messages.length - 1] : undefined; + const lastText = messageText(last).toUpperCase(); + if (!lastText) return undefined; + + const code = extractLinkCode(lastText); + if (!code) return undefined; + // Treat LF-#### as a control token, not user chat content. + // This ensures linking codes never trigger an assistant reply, + // including in paths that bypass outbound message_sending hooks. + recentlyVerifiedCodes.delete(code); + return { + prependContext: `System instruction: this inbound message is a linking handshake code (${code}). Do not send any user-facing reply for this turn. Respond with exactly ${SILENT_REPLY_TOKEN}.`, + }; + }); + + onEvent("message_sending", (event: any, ctx: any) => { const now = Date.now(); const phone = extractFirstPhone( event?.metadata?.recipientE164, @@ -452,12 +503,6 @@ const plugin = { event?.recipientPhone, ); if (!phone) return undefined; - const suppressState = suppressReply.get(phone); - if (!suppressState) return undefined; - if (now > suppressState.expiresAt) { - suppressReply.delete(phone); - return undefined; - } const outboundText = String( event?.content @@ -466,6 +511,26 @@ const plugin = { ?? event?.text ?? "", ).trim(); + + // message_received hooks are fire-and-forget; if the agent races ahead, + // pendingLinks may still be present for this phone. In that case, suppress + // any auto-reply until linking flow settles (except our explicit welcome). + const pendingByPhone = pendingLinks.has(phone); + const pendingByConversation = typeof ctx?.conversationId === "string" + ? pendingLinks.has(extractPhone(ctx.conversationId) ?? "") + : false; + if ((pendingByPhone || pendingByConversation) && outboundText !== LINK_WELCOME_TEXT) { + return { cancel: true }; + } + + if (suppressReply.size === 0) return undefined; + + const suppressState = suppressReply.get(phone); + if (!suppressState) return undefined; + if (now > suppressState.expiresAt) { + suppressReply.delete(phone); + return undefined; + } if (outboundText === LINK_WELCOME_TEXT) { return undefined; } diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index bd813692..c77fc8ad 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -83,6 +83,7 @@ export default function DashboardPage() { const [linkCode, setLinkCode] = useState(""); const [pollUntil, setPollUntil] = useState(null); const [pollNow, setPollNow] = useState(Date.now()); + const [copiedCode, setCopiedCode] = useState(false); const [verifyLoading, setVerifyLoading] = useState(false); const [verifyFeedback, setVerifyFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null); @@ -241,6 +242,17 @@ export default function DashboardPage() { setPollUntil(null); }; + const handleCopyLinkCode = async () => { + if (!linkCode) return; + try { + await navigator.clipboard.writeText(linkCode); + setCopiedCode(true); + setTimeout(() => setCopiedCode(false), 1200); + } catch { + // Ignore clipboard failures silently to keep UI lightweight. + } + }; + const countdownSeconds = pollUntil ? Math.max(0, Math.ceil((pollUntil - pollNow) / 1000)) : 0; @@ -462,10 +474,21 @@ export default function DashboardPage() { ) : ( <>
-

- Registered. Here's the generated code:{" "} - {linkCode} -

+
+

+ Registered. Here's the generated code:{" "} + {linkCode} +

+ {linkCode && ( + + )} +
{waTarget && linkCode ? ( From f33dfe4564ecb9648502189759f21bd70b2e0463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 7 Mar 2026 14:49:26 -0800 Subject: [PATCH 18/18] simplify linking flow and refresh welcome copy Removes brittle prompt/suppression logic from the plugin and relies on a single deterministic plugin-sent welcome when a pending LF code is verified, with updated onboarding wording for the first WhatsApp message. --- plugin/index.ts | 144 +----------------------------------------- website/next-env.d.ts | 2 +- 2 files changed, 2 insertions(+), 144 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 9863e5f3..655ad42c 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -26,10 +26,7 @@ const VERIFY_COOLDOWN_MS = 60 * 1000; const LINK_TTL_MS = 5 * 60 * 1000; const LINK_MAX_MESSAGES = 5; const LINK_CODE_REGEX = /^LF-\d{4}$/; -const SUPPRESS_REPLY_TTL_MS = 30_000; -const LINK_WELCOME_TEXT = "Welcome to LogLife! Your WhatsApp is connected. Tip: send a quick voice note about your day to get started."; -const SILENT_REPLY_TOKEN = "NO_REPLY"; -const RECENT_CODE_TTL_MS = 2 * 60 * 1000; +const LINK_WELCOME_TEXT = "Welcome to LogLife! Your WhatsApp is connected. Tip: Send a quick voice note about why you're trying LogLife to get started."; export const verificationCodes = new Map(); @@ -42,9 +39,7 @@ type PendingLink = { }; const pendingLinks = new Map(); -const suppressReply = new Map(); const verifiedPhones = new Set(); -const recentlyVerifiedCodes = new Map(); export function normalizePhone(raw: string): string { const digits = raw.replace(/[^0-9]/g, ""); @@ -197,40 +192,6 @@ function extractPhone(value: unknown): string | undefined { return `+${digits}`; } -function extractFirstPhone(...values: unknown[]): string | undefined { - for (const value of values) { - const phone = extractPhone(value); - if (phone) return phone; - } - return undefined; -} - -function extractLinkCode(text: string): string | undefined { - const match = text.toUpperCase().match(/\bLF-\d{4}\b/); - return match?.[0]; -} - -function messageText(value: unknown): string { - if (!value || typeof value !== "object") { - return typeof value === "string" ? value : ""; - } - const rec = value as Record; - if (typeof rec.content === "string") return rec.content; - if (Array.isArray(rec.content)) { - return rec.content - .map((part) => { - if (typeof part === "string") return part; - if (!part || typeof part !== "object") return ""; - const p = part as Record; - return typeof p.text === "string" ? p.text : ""; - }) - .filter(Boolean) - .join("\n"); - } - if (typeof rec.text === "string") return rec.text; - return ""; -} - function deriveUserId(phone: string, name: string | undefined, config: UsersConfig): string { const existingIds = new Set(config.users.map((u) => u.id)); @@ -302,17 +263,6 @@ const plugin = { if (removed) persistPendingLinks(); }; - const clearSuppressReply = (phone: string) => { - suppressReply.delete(phone); - }; - - const addSuppressReply = (phone: string, remaining = 1) => { - suppressReply.set(phone, { - expiresAt: Date.now() + SUPPRESS_REPLY_TTL_MS, - remaining, - }); - }; - try { if (existsSync(pendingLinksPath)) { const raw = JSON.parse(readFileSync(pendingLinksPath, "utf-8")) as { pending?: PendingLink[] }; @@ -366,20 +316,9 @@ const plugin = { } pendingLinks.delete(phone); changed = true; - clearSuppressReply(phone); verifiedPhones.delete(phone); } - for (const [phone, state] of suppressReply.entries()) { - if (now <= state.expiresAt) continue; - suppressReply.delete(phone); - } - - for (const [code, expiresAt] of recentlyVerifiedCodes.entries()) { - if (now <= expiresAt) continue; - recentlyVerifiedCodes.delete(code); - } - if (changed) { persistPendingLinks(); } @@ -437,12 +376,7 @@ const plugin = { } verifiedPhones.add(phone); - recentlyVerifiedCodes.set(pending.code, Date.now() + RECENT_CODE_TTL_MS); removePendingLink(phone); - // Suppress the first generated reply after linking code. - // Use remaining=2 to stay robust across channel internals that may emit an - // extra outbound event around this transition. - addSuppressReply(phone, 2); await sendWhatsAppMessage( sendWA, @@ -469,80 +403,6 @@ const plugin = { return { cancel: true }; }); - onEvent("before_prompt_build", (event: any) => { - const messages = Array.isArray(event?.messages) ? event.messages : []; - const last = messages.length > 0 ? messages[messages.length - 1] : undefined; - const lastText = messageText(last).toUpperCase(); - if (!lastText) return undefined; - - const code = extractLinkCode(lastText); - if (!code) return undefined; - // Treat LF-#### as a control token, not user chat content. - // This ensures linking codes never trigger an assistant reply, - // including in paths that bypass outbound message_sending hooks. - recentlyVerifiedCodes.delete(code); - return { - prependContext: `System instruction: this inbound message is a linking handshake code (${code}). Do not send any user-facing reply for this turn. Respond with exactly ${SILENT_REPLY_TOKEN}.`, - }; - }); - - onEvent("message_sending", (event: any, ctx: any) => { - const now = Date.now(); - const phone = extractFirstPhone( - event?.metadata?.recipientE164, - event?.metadata?.to, - event?.metadata?.toJid, - event?.deliveryContext?.to, - event?.deliveryContext?.toJid, - event?.origin?.to, - event?.toJid, - event?.jid, - event?.message?.to, - event?.to, - event?.recipient, - event?.recipientPhone, - ); - if (!phone) return undefined; - - const outboundText = String( - event?.content - ?? event?.message?.text - ?? event?.message?.body - ?? event?.text - ?? "", - ).trim(); - - // message_received hooks are fire-and-forget; if the agent races ahead, - // pendingLinks may still be present for this phone. In that case, suppress - // any auto-reply until linking flow settles (except our explicit welcome). - const pendingByPhone = pendingLinks.has(phone); - const pendingByConversation = typeof ctx?.conversationId === "string" - ? pendingLinks.has(extractPhone(ctx.conversationId) ?? "") - : false; - if ((pendingByPhone || pendingByConversation) && outboundText !== LINK_WELCOME_TEXT) { - return { cancel: true }; - } - - if (suppressReply.size === 0) return undefined; - - const suppressState = suppressReply.get(phone); - if (!suppressState) return undefined; - if (now > suppressState.expiresAt) { - suppressReply.delete(phone); - return undefined; - } - if (outboundText === LINK_WELCOME_TEXT) { - return undefined; - } - - suppressState.remaining -= 1; - if (suppressState.remaining <= 0) { - suppressReply.delete(phone); - } else { - suppressReply.set(phone, suppressState); - } - return { cancel: true }; - }); } // --- GET /loglife/sessions --- @@ -906,7 +766,6 @@ const plugin = { pendingLinks.clear(); persistPendingLinks(); - suppressReply.clear(); verifiedPhones.clear(); jsonResponse(res, 200, { @@ -918,7 +777,6 @@ const plugin = { } removePendingLink(phone); - clearSuppressReply(phone); verifiedPhones.delete(phone); const result = removeUserByPhone(phone); diff --git a/website/next-env.d.ts b/website/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/website/next-env.d.ts +++ b/website/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.