# PANIK Complaint Listener Completion Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extend the existing `complaint-listener` plugin so PANIK alerts dual-deliver per event (Telegram chat `8416955510` + WhatsApp `+51922286663`) and a daily WhatsApp digest fires to `+51922286663` at 09:00 America/Lima, silent on zero-complaint days.

**Architecture:** Plugin-only changes. Split the existing single-channel notifier into a dual-channel fan-out using `Promise.all`. Add a new `summary.ts` module that runs hourly via `setInterval`, checks Lima local time, queries the past 24 h, and posts via the existing WhatsApp runtime. Persist `last_fire_ymd` in a new SQLite singleton row to dedupe and gate retries.

**Tech Stack:** Node 22 `node:sqlite` (built-in), TypeScript strict mode, vitest, existing `@nikolasp98/minion` plugin SDK (`api.runtime.channel.{telegram,whatsapp}.sendMessage*`).

**Source spec:** `docs/superpowers/specs/2026-05-12-panik-complaint-listener-completion-design.md` (commit `1e4ab4058` on DEV).

**Working directory:** `~/Documents/CODE/AI/minion` on branch `DEV`. All commits use repo-relative paths and the existing `oxlint + oxfmt` + `tsgo` + `vitest` toolchain. Run all commands from the minion repo root unless noted otherwise.

---

## File Structure

```
extensions/complaint-listener/
├── index.ts             ← MODIFY: wire summary scheduler + dual-channel targets
├── index.test.ts        ← MODIFY: assert scheduler wiring + dual targets
└── src/
    ├── notifier.ts      ← MODIFY: split into TG + WA senders, dual-channel return
    ├── notifier.test.ts ← MODIFY: new dual-channel cases
    ├── store.ts         ← MODIFY: + summary_state table, + getComplaintsSince, + ymd accessors
    ├── store.test.ts    ← MODIFY: new cases for summary_state and getComplaintsSince
    ├── summary.ts       ← CREATE: digest builder + maybeFireSummary
    ├── summary.test.ts  ← CREATE: fake-timers + injected clock cases
    └── types.ts         ← unchanged
```

---

## Task 1: Store — `summary_state` table and ymd accessors

**Files:**

- Modify: `extensions/complaint-listener/src/store.ts`
- Test: `extensions/complaint-listener/src/store.test.ts`

- [ ] **Step 1.1: Write failing tests for `summary_state` table creation, idempotency, ymd accessors, and `getComplaintsSince`**

Append to `store.test.ts` inside the existing `describe("store", ...)` block:

```ts
it("creates summary_state table on init with seeded row", () => {
  const tables = db
    .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='summary_state'")
    .all();
  expect(tables).toHaveLength(1);
  const row = db.prepare("SELECT id, last_fire_ymd FROM summary_state").get() as
    | { id: number; last_fire_ymd: string | null }
    | undefined;
  expect(row?.id).toBe(1);
  expect(row?.last_fire_ymd).toBeNull();
});

it("initSchema does not duplicate summary_state row on re-init", () => {
  initSchema(db);
  initSchema(db);
  const rows = db.prepare("SELECT id FROM summary_state").all();
  expect(rows).toHaveLength(1);
});

it("getSummaryYmd returns null initially", () => {
  expect(getSummaryYmd(db)).toBeNull();
});

it("setSummaryYmd updates the singleton row", () => {
  setSummaryYmd(db, "2026-05-12");
  expect(getSummaryYmd(db)).toBe("2026-05-12");
  setSummaryYmd(db, "2026-05-13");
  expect(getSummaryYmd(db)).toBe("2026-05-13");
});

it("getComplaintsSince returns only is_complaint=1 rows at or after the cutoff", () => {
  const oldId = insertComplaint(db, baseParams);
  db.prepare("UPDATE complaints SET created_at = ? WHERE id = ?").run(1000, oldId);
  const recentId = insertComplaint(db, baseParams);
  insertComplaint(db, {
    ...baseParams,
    result: { is_complaint: false, severity: "low", category: "other", summary: "" },
  });
  const rows = getComplaintsSince(db, 2000);
  const ids = rows.map((r) => r.id);
  expect(ids).toContain(recentId);
  expect(ids).not.toContain(oldId);
  expect(rows.every((r) => r.is_complaint === 1)).toBe(true);
});

it("getComplaintsSince orders rows by created_at ascending", () => {
  const a = insertComplaint(db, baseParams);
  const b = insertComplaint(db, baseParams);
  db.prepare("UPDATE complaints SET created_at = ? WHERE id = ?").run(5000, a);
  db.prepare("UPDATE complaints SET created_at = ? WHERE id = ?").run(3000, b);
  const rows = getComplaintsSince(db, 0);
  expect(rows.map((r) => r.id)).toEqual([b, a]);
});
```

Update the import block at the top of `store.test.ts` to include the new exports:

```ts
import {
  initSchema,
  insertComplaint,
  markNotified,
  getPendingNotifications,
  getComplaintById,
  countBySeverity,
  getSummaryYmd,
  setSummaryYmd,
  getComplaintsSince,
} from "./store.js";
```

- [ ] **Step 1.2: Run tests; verify they fail**

Run: `pnpm vitest run extensions/complaint-listener/src/store.test.ts`

Expected: failures referencing missing exports `getSummaryYmd`, `setSummaryYmd`, `getComplaintsSince`, and a missing `summary_state` table.

- [ ] **Step 1.3: Extend `initSchema` and add new helpers in `store.ts`**

Replace the body of `initSchema` so it creates both tables (and seeds the singleton row) in one DDL block. Append the three new helper functions below `countBySeverity`. The final `store.ts` should contain:

```ts
export function initSchema(db: DatabaseSync): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS complaints (
      id          INTEGER PRIMARY KEY AUTOINCREMENT,
      agent_id    TEXT    NOT NULL,
      channel_id  TEXT    NOT NULL,
      from_sender TEXT    NOT NULL,
      content     TEXT    NOT NULL,
      is_complaint INTEGER NOT NULL DEFAULT 0,
      severity    TEXT    NOT NULL DEFAULT 'low',
      category    TEXT    NOT NULL DEFAULT 'other',
      summary     TEXT    NOT NULL DEFAULT '',
      created_at  INTEGER NOT NULL,
      notified_at INTEGER
    );
    CREATE TABLE IF NOT EXISTS summary_state (
      id            INTEGER PRIMARY KEY CHECK (id = 1),
      last_fire_ymd TEXT
    );
    INSERT OR IGNORE INTO summary_state (id, last_fire_ymd) VALUES (1, NULL);
  `);
}

// ... existing insertComplaint, markNotified, getPendingNotifications, getComplaintById, countBySeverity unchanged ...

export function getSummaryYmd(db: DatabaseSync): string | null {
  const row = db.prepare("SELECT last_fire_ymd FROM summary_state WHERE id = 1").get() as
    | { last_fire_ymd: string | null }
    | undefined;
  return row?.last_fire_ymd ?? null;
}

export function setSummaryYmd(db: DatabaseSync, ymd: string): void {
  db.prepare("UPDATE summary_state SET last_fire_ymd = ? WHERE id = 1").run(ymd);
}

export function getComplaintsSince(db: DatabaseSync, sinceMs: number): ComplaintRow[] {
  return db
    .prepare(
      "SELECT * FROM complaints WHERE is_complaint = 1 AND created_at >= ? ORDER BY created_at ASC",
    )
    .all(sinceMs) as ComplaintRow[];
}
```

- [ ] **Step 1.4: Run tests; verify pass**

Run: `pnpm vitest run extensions/complaint-listener/src/store.test.ts`

Expected: every existing store test and all 6 new tests pass.

- [ ] **Step 1.5: Commit**

```bash
git add extensions/complaint-listener/src/store.ts extensions/complaint-listener/src/store.test.ts
git commit -m "feat(complaint-listener): summary_state table + ymd accessors + getComplaintsSince

Backing storage for the daily WhatsApp digest. Singleton row guards
against double-fires within the same Lima-local day.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 2: Notifier — dual-channel fan-out

**Files:**

- Modify: `extensions/complaint-listener/src/notifier.ts`
- Test: `extensions/complaint-listener/src/notifier.test.ts`

- [ ] **Step 2.1: Replace `notifier.test.ts` with dual-channel test suite**

Overwrite `extensions/complaint-listener/src/notifier.test.ts` with the contents in `plan-snippets/notifier.test.ts.txt` (full content shown at the end of this plan in **Appendix A**, copy verbatim).

- [ ] **Step 2.2: Run tests; verify they fail**

Run: `pnpm vitest run extensions/complaint-listener/src/notifier.test.ts`

Expected: failures referencing the new `targets` argument shape and missing telegram runtime mock.

- [ ] **Step 2.3: Replace `notifier.ts` with the dual-channel implementation**

Overwrite `extensions/complaint-listener/src/notifier.ts` with the content in **Appendix B** (copy verbatim).

- [ ] **Step 2.4: Run tests; verify pass**

Run: `pnpm vitest run extensions/complaint-listener/src/notifier.test.ts`

Expected: all 13 tests pass.

- [ ] **Step 2.5: Commit**

```bash
git add extensions/complaint-listener/src/notifier.ts extensions/complaint-listener/src/notifier.test.ts
git commit -m "feat(complaint-listener): dual-channel notifier (Telegram + WhatsApp)

sendComplaintNotification now fans out to both channels via
Promise.all and returns per-channel success flags. Caller marks
notified_at if EITHER channel succeeded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 3: Summary module — digest builder + scheduler

**Files:**

- Create: `extensions/complaint-listener/src/summary.ts`
- Create: `extensions/complaint-listener/src/summary.test.ts`

- [ ] **Step 3.1: Create `summary.test.ts`**

Create `extensions/complaint-listener/src/summary.test.ts` with the content in **Appendix C** (copy verbatim).

- [ ] **Step 3.2: Run tests; verify they fail**

Run: `pnpm vitest run extensions/complaint-listener/src/summary.test.ts`

Expected: module not found / undefined exports for `buildDigest`, `nowYmdLima`, `maybeFireSummary`.

- [ ] **Step 3.3: Create `summary.ts`**

Create `extensions/complaint-listener/src/summary.ts` with the content in **Appendix D** (copy verbatim).

- [ ] **Step 3.4: Run tests; verify pass**

Run: `pnpm vitest run extensions/complaint-listener/src/summary.test.ts`

Expected: all 9 summary tests pass.

- [ ] **Step 3.5: Commit**

```bash
git add extensions/complaint-listener/src/summary.ts extensions/complaint-listener/src/summary.test.ts
git commit -m "feat(complaint-listener): daily digest builder + maybeFireSummary scheduler

Lima-local time gating, singleton ymd dedupe, silent zero-day handling,
WA-failure retry. Pure functions accept an injected now for testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 4: Wire scheduler + dual-channel targets into `index.ts`

**Files:**

- Modify: `extensions/complaint-listener/index.ts`
- Test: `extensions/complaint-listener/index.test.ts`

- [ ] **Step 4.1: Replace `index.test.ts` with the wiring test suite (Appendix E)**

Overwrite `extensions/complaint-listener/index.test.ts` with the content in **Appendix E** (copy verbatim).

- [ ] **Step 4.2: Run tests; verify they fail**

Run: `pnpm vitest run extensions/complaint-listener/index.test.ts`

Expected: failures referencing the new `targets` shape and missing summary wiring.

- [ ] **Step 4.3: Replace `index.ts` (Appendix F)**

Overwrite `extensions/complaint-listener/index.ts` with the content in **Appendix F** (copy verbatim).

- [ ] **Step 4.4: Run tests; verify pass**

Run: `pnpm vitest run extensions/complaint-listener/index.test.ts`

Expected: all index tests pass.

- [ ] **Step 4.5: Commit**

```bash
git add extensions/complaint-listener/index.ts extensions/complaint-listener/index.test.ts
git commit -m "feat(complaint-listener): wire dual-channel targets + hourly summary scheduler

Per-event handler reads TELEGRAM_CHAT_ID + WHATSAPP_TO from env and
marks notified_at if either channel succeeds. Service start runs an
immediate maybeFireSummary for boot catch-up, then schedules hourly
ticks; service stop clears the interval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 5: Full suite + tsgo + check

- [ ] **Step 5.1: Plugin test suite**

Run: `pnpm vitest run extensions/complaint-listener`

Expected: all green; no skipped files.

- [ ] **Step 5.2: Type-check**

Run: `pnpm tsgo`

Expected: zero new errors in `extensions/complaint-listener/`. Pre-existing repo errors elsewhere are OK; do not fix outside this scope.

- [ ] **Step 5.3: Lint + format**

Run: `pnpm check`

Expected: clean. If oxfmt rewrites whitespace, stage the diff and amend the most recent feature commit (only if not yet pushed).

- [ ] **Step 5.4: On any failure, fix in the smallest task involved, re-run targeted vitest file, then re-run 5.1 → 5.3. Stop on first failure.**

---

## Task 6: PR to DEV

- [ ] **Step 6.1: Push**

If on DEV directly (matches Phase 23 history): `git push origin DEV`.
If on a feature branch: `git push -u origin HEAD`.

- [ ] **Step 6.2: Open PR (feature branch only)**

```bash
gh pr create --base DEV --title "feat(complaint-listener): dual-channel + daily digest for PANIK" --body "$(cat <<'BODY'
## Summary
- Extends Phase 23 complaint-listener with Telegram per-event delivery (chat 8416955510) alongside WhatsApp.
- Adds daily WhatsApp digest at 09:00 America/Lima to +51 922286663 with silent zero-complaint days.
- Plugin-only changes; opt-in still gated by agent YAML listeners.complaints: true.

Spec: docs/superpowers/specs/2026-05-12-panik-complaint-listener-completion-design.md

## Test plan
- [x] pnpm vitest run extensions/complaint-listener — green locally
- [x] pnpm tsgo — clean for this plugin
- [x] pnpm check — clean
- [ ] Smoke on bot-prd: PANIK opt-in YAML flip + Infisical secrets + systemd restart, then send a real complaint and verify both TG and WA alerts within 5s; verify digest at next 09:00 Lima.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
BODY
)"
```

- [ ] **Step 6.3: Watch CI**

Run: `gh pr checks --watch`

Expected: vitest + tsgo + check all green. If install-smoke fails, see `[[reference_openclaw_ai_defunct.md]]` — known broken.

---

## Out-of-band rollout (NOT TDD; confirm with user first)

1. Set 3 Infisical secrets in `minion-gateway`:
   - `COMPLAINTS_NOTIFY_TELEGRAM_CHAT_ID=8416955510`
   - `COMPLAINTS_SUMMARY_WHATSAPP_TO=+51922286663`
   - Confirm `COMPLAINTS_NOTIFY_WHATSAPP_TO=+51922286663`
2. Flip PANIK opt-in on bot-prd:
   ```bash
   sudo -u bot-prd bash -c "jq '(.agents.list[] | select(.id==\"panik\")) |= (.listeners.complaints = true)' ~/.minion/gateway.json > /tmp/gw.json && mv /tmp/gw.json ~/.minion/gateway.json"
   ```
3. Restart gateway:
   ```bash
   sudo XDG_RUNTIME_DIR=/run/user/$(id -u bot-prd) -u bot-prd systemctl --user restart minion-gateway
   ```
4. Run spec's Live test section.

---

## Self-Review Summary

- **Spec coverage:** Goals 1-3 → Tasks 1-4. Failure modes → Tasks 2-4 test cases. Testing list → Task step matrix. Rollout → out-of-band section.
- **Placeholders:** none — every code block is complete (long source files moved to appendices for readability).
- **Type consistency:** `DeliveryResult { telegram, whatsapp }`, `DeliveryTargets { telegramChatId, whatsappTo }`, `SummaryConfig { summaryTo, summaryHour, lookbackMs }` consistent across files. `maybeFireSummary(api, db, cfg, now?)` signature matches tests, impl, and index wiring.

---

## Appendix A — `notifier.test.ts`

```ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { formatNotification, sendComplaintNotification } from "./notifier.js";
import type { ComplaintResult } from "./types.js";

function makeApi(opts?: { waSend?: ReturnType<typeof vi.fn>; tgSend?: ReturnType<typeof vi.fn> }) {
  return {
    logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
    runtime: {
      channel: {
        whatsapp: {
          sendMessageWhatsApp:
            opts?.waSend ?? vi.fn().mockResolvedValue({ messageId: "m1", toJid: "+1" }),
        },
        telegram: {
          sendMessageTelegram:
            opts?.tgSend ?? vi.fn().mockResolvedValue({ messageId: 42, chatId: "8416955510" }),
        },
      },
    },
  };
}

const complaint: ComplaintResult = {
  is_complaint: true,
  severity: "high",
  category: "product_defect",
  summary: "Product completely broken",
};

const targets = { telegramChatId: "8416955510", whatsappTo: "+51922286663" };

describe("formatNotification", () => {
  it("includes severity emoji for high", () => {
    expect(formatNotification("a", "c", "+1", complaint)).toContain("🔴");
  });
  it("includes severity emoji for med", () => {
    expect(formatNotification("a", "c", "+1", { ...complaint, severity: "med" })).toContain("🟠");
  });
  it("includes severity emoji for low", () => {
    expect(formatNotification("a", "c", "+1", { ...complaint, severity: "low" })).toContain("🟡");
  });
  it("includes agent, channel, sender, category, summary", () => {
    const msg = formatNotification("my-agent", "telegram", "+15559876543", complaint);
    expect(msg).toContain("my-agent");
    expect(msg).toContain("telegram");
    expect(msg).toContain("+15559876543");
    expect(msg).toContain("Product defect");
    expect(msg).toContain("Product completely broken");
  });
  it("omits summary line when empty", () => {
    expect(formatNotification("a", "c", "s", { ...complaint, summary: "" })).not.toContain(
      "Summary:",
    );
  });
});

describe("sendComplaintNotification dual-channel", () => {
  beforeEach(() => vi.clearAllMocks());

  it("returns telegram:true whatsapp:true when both succeed", async () => {
    const api = makeApi() as any;
    const res = await sendComplaintNotification(api, targets, "a", "wa", "+1", complaint);
    expect(res).toEqual({ telegram: true, whatsapp: true });
  });

  it("calls WA with whatsappTo and TG with telegramChatId", async () => {
    const waSend = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "+1" });
    const tgSend = vi.fn().mockResolvedValue({ messageId: 42, chatId: "8416955510" });
    const api = makeApi({ waSend, tgSend }) as any;
    await sendComplaintNotification(api, targets, "a", "wa", "+1", complaint);
    expect(waSend).toHaveBeenCalledWith("+51922286663", expect.any(String), expect.any(Object));
    expect(tgSend).toHaveBeenCalledWith("8416955510", expect.any(String), expect.any(Object));
  });

  it("returns telegram:true whatsapp:false when WA throws", async () => {
    const api = makeApi({ waSend: vi.fn().mockRejectedValue(new Error("WA offline")) }) as any;
    const res = await sendComplaintNotification(api, targets, "a", "c", "s", complaint);
    expect(res).toEqual({ telegram: true, whatsapp: false });
    expect(api.logger.error).toHaveBeenCalled();
  });

  it("returns telegram:false whatsapp:true when TG throws", async () => {
    const api = makeApi({ tgSend: vi.fn().mockRejectedValue(new Error("TG 401")) }) as any;
    const res = await sendComplaintNotification(api, targets, "a", "c", "s", complaint);
    expect(res).toEqual({ telegram: false, whatsapp: true });
    expect(api.logger.error).toHaveBeenCalled();
  });

  it("returns telegram:false whatsapp:false when both throw", async () => {
    const api = makeApi({
      waSend: vi.fn().mockRejectedValue(new Error("wa down")),
      tgSend: vi.fn().mockRejectedValue(new Error("tg down")),
    }) as any;
    const res = await sendComplaintNotification(api, targets, "a", "c", "s", complaint);
    expect(res).toEqual({ telegram: false, whatsapp: false });
  });

  it("skips TG when telegramChatId is empty", async () => {
    const tgSend = vi.fn();
    const api = makeApi({ tgSend }) as any;
    const res = await sendComplaintNotification(
      api,
      { telegramChatId: "", whatsappTo: "+51922286663" },
      "a",
      "c",
      "s",
      complaint,
    );
    expect(tgSend).not.toHaveBeenCalled();
    expect(res.telegram).toBe(false);
    expect(res.whatsapp).toBe(true);
  });

  it("skips WA when whatsappTo is empty", async () => {
    const waSend = vi.fn();
    const api = makeApi({ waSend }) as any;
    const res = await sendComplaintNotification(
      api,
      { telegramChatId: "8416955510", whatsappTo: "" },
      "a",
      "c",
      "s",
      complaint,
    );
    expect(waSend).not.toHaveBeenCalled();
    expect(res.telegram).toBe(true);
    expect(res.whatsapp).toBe(false);
  });

  it("does not throw when both channels throw", async () => {
    const api = makeApi({
      waSend: vi.fn().mockRejectedValue(new Error("x")),
      tgSend: vi.fn().mockRejectedValue(new Error("y")),
    }) as any;
    await expect(
      sendComplaintNotification(api, targets, "a", "c", "s", complaint),
    ).resolves.toBeDefined();
  });
});
```

## Appendix B — `notifier.ts`

```ts
import type { MinionPluginApi } from "../../../src/plugins/types.js";
import type { ComplaintResult } from "./types.js";

const SEVERITY_EMOJI: Record<string, string> = {
  low: "🟡",
  med: "🟠",
  high: "🔴",
};

const CATEGORY_LABEL: Record<string, string> = {
  product_defect: "Product defect",
  billing: "Billing",
  delivery: "Delivery",
  service: "Service",
  other: "Other",
};

export type DeliveryTargets = {
  telegramChatId: string;
  whatsappTo: string;
};

export type DeliveryResult = {
  telegram: boolean;
  whatsapp: boolean;
};

export function formatNotification(
  agentId: string,
  channelId: string,
  fromSender: string,
  result: ComplaintResult,
): string {
  const emoji = SEVERITY_EMOJI[result.severity] ?? "⚪";
  const category = CATEGORY_LABEL[result.category] ?? result.category;
  const lines = [
    `${emoji} *Complaint detected* (${result.severity.toUpperCase()})`,
    `Agent: ${agentId} | Channel: ${channelId}`,
    `From: ${fromSender}`,
    `Category: ${category}`,
  ];
  if (result.summary) {
    lines.push(`Summary: ${result.summary}`);
  }
  return lines.join("\n");
}

async function sendWhatsApp(api: MinionPluginApi, to: string, text: string): Promise<boolean> {
  if (!to) {
    api.logger.warn(
      "complaint-listener: COMPLAINTS_NOTIFY_WHATSAPP_TO not set; skipping WA notify",
    );
    return false;
  }
  try {
    await api.runtime.channel.whatsapp.sendMessageWhatsApp(to, text, { verbose: false });
    return true;
  } catch (err) {
    api.logger.error(`complaint-listener: WhatsApp notify failed: ${String(err)}`);
    return false;
  }
}

async function sendTelegram(api: MinionPluginApi, chatId: string, text: string): Promise<boolean> {
  if (!chatId) {
    api.logger.warn(
      "complaint-listener: COMPLAINTS_NOTIFY_TELEGRAM_CHAT_ID not set; skipping TG notify",
    );
    return false;
  }
  try {
    await api.runtime.channel.telegram.sendMessageTelegram(chatId, text, { textMode: "markdown" });
    return true;
  } catch (err) {
    api.logger.error(`complaint-listener: Telegram notify failed: ${String(err)}`);
    return false;
  }
}

export async function sendComplaintNotification(
  api: MinionPluginApi,
  targets: DeliveryTargets,
  agentId: string,
  channelId: string,
  fromSender: string,
  result: ComplaintResult,
): Promise<DeliveryResult> {
  const message = formatNotification(agentId, channelId, fromSender, result);
  const [tg, wa] = await Promise.all([
    sendTelegram(api, targets.telegramChatId, message),
    sendWhatsApp(api, targets.whatsappTo, message),
  ]);
  return { telegram: tg, whatsapp: wa };
}
```

## Appendix C — `summary.test.ts`

```ts
import { DatabaseSync } from "node:sqlite";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { initSchema, insertComplaint, getSummaryYmd } from "./store.js";
import { buildDigest, nowYmdLima, maybeFireSummary } from "./summary.js";
import type { ComplaintRow } from "./types.js";

const complaintRow = (over: Partial<ComplaintRow> = {}): ComplaintRow => ({
  id: 1,
  agent_id: "panik",
  channel_id: "whatsapp",
  from_sender: "+51999111222",
  content: "broken",
  is_complaint: 1,
  severity: "high",
  category: "product_defect",
  summary: "Product arrived broken",
  created_at: 1_000,
  notified_at: null,
  ...over,
});

const insertC = (db: DatabaseSync, summary = "x") =>
  insertComplaint(db, {
    agentId: "panik",
    channelId: "whatsapp",
    fromSender: "+51999",
    content: "x",
    result: { is_complaint: true, severity: "high", category: "other", summary },
  });

describe("nowYmdLima", () => {
  it("formats America/Lima local date as YYYY-MM-DD", () => {
    expect(nowYmdLima(new Date("2026-05-12T05:00:00Z"))).toBe("2026-05-12");
  });
  it("rolls over date relative to Lima midnight (UTC-5)", () => {
    expect(nowYmdLima(new Date("2026-05-12T04:30:00Z"))).toBe("2026-05-11");
  });
});

describe("buildDigest", () => {
  it("contains total count, ymd, and per-severity buckets", () => {
    const rows = [
      complaintRow({ id: 1, severity: "high" }),
      complaintRow({ id: 2, severity: "high" }),
      complaintRow({ id: 3, severity: "med" }),
      complaintRow({ id: 4, severity: "low" }),
    ];
    const text = buildDigest(rows, "2026-05-12");
    expect(text).toContain("2026-05-12");
    expect(text).toContain("Total: 4");
    expect(text).toContain("HIGH (2)");
    expect(text).toContain("MED");
    expect(text).toContain("LOW");
  });

  it("includes up to 3 latest rows in reverse-chronological order", () => {
    const rows = [
      complaintRow({ id: 1, created_at: 1000, summary: "first" }),
      complaintRow({ id: 2, created_at: 2000, summary: "second" }),
      complaintRow({ id: 3, created_at: 3000, summary: "third" }),
      complaintRow({ id: 4, created_at: 4000, summary: "fourth" }),
    ];
    const text = buildDigest(rows, "2026-05-12");
    expect(text).toContain("fourth");
    expect(text).toContain("third");
    expect(text).toContain("second");
    expect(text).not.toContain("first");
  });
});

describe("maybeFireSummary", () => {
  let db: DatabaseSync;
  let sendMessageWhatsApp: ReturnType<typeof vi.fn>;
  let api: any;

  beforeEach(() => {
    db = new DatabaseSync(":memory:");
    initSchema(db);
    sendMessageWhatsApp = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "+1" });
    api = {
      logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
      runtime: { channel: { whatsapp: { sendMessageWhatsApp } } },
    };
  });

  const cfg = { summaryTo: "+51922286663", summaryHour: 9, lookbackMs: 24 * 60 * 60 * 1000 };

  it("no-op before COMPLAINTS_SUMMARY_HOUR", async () => {
    insertC(db);
    await maybeFireSummary(api, db, cfg, new Date("2026-05-12T13:00:00Z"));
    expect(sendMessageWhatsApp).not.toHaveBeenCalled();
    expect(getSummaryYmd(db)).toBeNull();
  });

  it("fires once at or after the hour and advances ymd", async () => {
    insertC(db);
    await maybeFireSummary(api, db, cfg, new Date("2026-05-12T14:30:00Z"));
    expect(sendMessageWhatsApp).toHaveBeenCalledTimes(1);
    expect(sendMessageWhatsApp.mock.calls[0]?.[0]).toBe("+51922286663");
    expect(getSummaryYmd(db)).toBe("2026-05-12");
  });

  it("does not double-fire same Lima day", async () => {
    insertC(db);
    await maybeFireSummary(api, db, cfg, new Date("2026-05-12T14:30:00Z"));
    await maybeFireSummary(api, db, cfg, new Date("2026-05-12T22:00:00Z"));
    expect(sendMessageWhatsApp).toHaveBeenCalledTimes(1);
  });

  it("zero complaints: silent send but ymd advances", async () => {
    await maybeFireSummary(api, db, cfg, new Date("2026-05-12T14:30:00Z"));
    expect(sendMessageWhatsApp).not.toHaveBeenCalled();
    expect(getSummaryYmd(db)).toBe("2026-05-12");
  });

  it("WA throws: ymd not advanced, retry next tick", async () => {
    insertC(db);
    sendMessageWhatsApp.mockRejectedValueOnce(new Error("WA offline"));
    await maybeFireSummary(api, db, cfg, new Date("2026-05-12T14:30:00Z"));
    expect(getSummaryYmd(db)).toBeNull();
    sendMessageWhatsApp.mockResolvedValueOnce({ messageId: "m2", toJid: "+1" });
    await maybeFireSummary(api, db, cfg, new Date("2026-05-12T15:30:00Z"));
    expect(sendMessageWhatsApp).toHaveBeenCalledTimes(2);
    expect(getSummaryYmd(db)).toBe("2026-05-12");
  });

  it("empty summaryTo: no-op", async () => {
    await maybeFireSummary(api, db, { ...cfg, summaryTo: "" }, new Date("2026-05-12T14:30:00Z"));
    expect(sendMessageWhatsApp).not.toHaveBeenCalled();
    expect(getSummaryYmd(db)).toBeNull();
  });

  it("queries last 24h: rows outside the lookback are excluded", async () => {
    const oldId = insertC(db, "old");
    const tNow = new Date("2026-05-12T14:30:00Z").getTime();
    db.prepare("UPDATE complaints SET created_at = ? WHERE id = ?").run(
      tNow - 48 * 60 * 60 * 1000,
      oldId,
    );
    insertC(db, "recent");
    await maybeFireSummary(api, db, cfg, new Date(tNow));
    const text: string = sendMessageWhatsApp.mock.calls[0]?.[1] ?? "";
    expect(text).toContain("Total: 1");
    expect(text).toContain("recent");
    expect(text).not.toContain("old");
  });
});
```

## Appendix D — `summary.ts`

```ts
import type { DatabaseSync } from "node:sqlite";
import type { MinionPluginApi } from "../../../src/plugins/types.js";
import type { ComplaintRow } from "./types.js";
import { getComplaintsSince, getSummaryYmd, setSummaryYmd } from "./store.js";

const SEVERITY_EMOJI: Record<string, string> = {
  low: "🟡",
  med: "🟠",
  high: "🔴",
};

const CATEGORY_LABEL: Record<string, string> = {
  product_defect: "Product defect",
  billing: "Billing",
  delivery: "Delivery",
  service: "Service",
  other: "Other",
};

export type SummaryConfig = {
  summaryTo: string;
  summaryHour: number;
  lookbackMs: number;
};

const LIMA_FMT = new Intl.DateTimeFormat("en-CA", {
  timeZone: "America/Lima",
  year: "numeric",
  month: "2-digit",
  day: "2-digit",
});

const LIMA_HOUR_FMT = new Intl.DateTimeFormat("en-US", {
  timeZone: "America/Lima",
  hour: "2-digit",
  hour12: false,
});

export function nowYmdLima(now: Date): string {
  return LIMA_FMT.format(now);
}

export function nowHourLima(now: Date): number {
  const parts = LIMA_HOUR_FMT.format(now);
  const n = Number.parseInt(parts, 10);
  return n === 24 ? 0 : n;
}

export function buildDigest(rows: ComplaintRow[], ymd: string): string {
  const total = rows.length;
  const bucket: Record<string, number> = { high: 0, med: 0, low: 0 };
  const catBucket: Record<string, number> = {};
  for (const r of rows) {
    bucket[r.severity] = (bucket[r.severity] ?? 0) + 1;
    catBucket[r.category] = (catBucket[r.category] ?? 0) + 1;
  }

  const lines = [
    `🎧 PANIK complaint digest — ${ymd}`,
    "",
    `Total: ${total} complaint${total === 1 ? "" : "s"} in the last 24h`,
    "",
    `${SEVERITY_EMOJI.high} HIGH (${bucket.high})`,
    `${SEVERITY_EMOJI.med} MED  (${bucket.med})`,
    `${SEVERITY_EMOJI.low} LOW  (${bucket.low})`,
  ];

  const catLines = Object.entries(catBucket)
    .sort(([, a], [, b]) => b - a)
    .map(([cat, n]) => `  • ${CATEGORY_LABEL[cat] ?? cat}: ${n}`);
  if (catLines.length > 0) {
    lines.push("", "By category:", ...catLines);
  }

  const latest = [...rows].sort((a, b) => b.created_at - a.created_at).slice(0, 3);
  if (latest.length > 0) {
    lines.push("", `Latest ${latest.length}:`);
    latest.forEach((r, i) => {
      lines.push(
        `  ${i + 1}. [${r.severity.toUpperCase()}] From ${r.from_sender} — ${r.summary || "(no summary)"}`,
      );
    });
  }
  return lines.join("\n");
}

export async function maybeFireSummary(
  api: MinionPluginApi,
  db: DatabaseSync,
  cfg: SummaryConfig,
  now: Date = new Date(),
): Promise<void> {
  if (!cfg.summaryTo) return;
  if (nowHourLima(now) < cfg.summaryHour) return;

  const ymd = nowYmdLima(now);
  if (getSummaryYmd(db) === ymd) return;

  const sinceMs = now.getTime() - cfg.lookbackMs;
  const rows = getComplaintsSince(db, sinceMs);

  if (rows.length === 0) {
    setSummaryYmd(db, ymd);
    api.logger.info(`complaint-listener: zero complaints in last 24h; digest skipped for ${ymd}`);
    return;
  }

  const text = buildDigest(rows, ymd);
  try {
    await api.runtime.channel.whatsapp.sendMessageWhatsApp(cfg.summaryTo, text, { verbose: false });
    setSummaryYmd(db, ymd);
    api.logger.info(
      `complaint-listener: digest sent (${rows.length} complaints) for ${ymd} to ${cfg.summaryTo}`,
    );
  } catch (err) {
    api.logger.error(`complaint-listener: digest WhatsApp send failed: ${String(err)}`);
  }
}
```

## Appendix E — `index.test.ts`

```ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

vi.mock("../../src/plugin-sdk/index.js", () => ({
  emptyPluginConfigSchema: () => ({}),
}));

vi.mock("./src/classifier.js", () => ({
  classifyMessage: vi.fn(),
}));

vi.mock("./src/store.js", () => ({
  initSchema: vi.fn(),
  insertComplaint: vi.fn(),
  markNotified: vi.fn(),
  getPendingNotifications: vi.fn(),
}));

vi.mock("./src/notifier.js", () => ({
  sendComplaintNotification: vi.fn(),
  formatNotification: vi.fn(),
}));

vi.mock("./src/summary.js", () => ({
  maybeFireSummary: vi.fn(),
}));

import plugin from "./index.js";
import { classifyMessage } from "./src/classifier.js";
import { sendComplaintNotification } from "./src/notifier.js";
import { insertComplaint, markNotified, getPendingNotifications } from "./src/store.js";
import { maybeFireSummary } from "./src/summary.js";

type MockFn = ReturnType<typeof vi.fn>;
const PLUGIN_ID = "complaint-listener";

function makeApi(agentListeners?: Record<string, boolean>, manualStart = false) {
  const agents = agentListeners
    ? Object.entries(agentListeners).map(([id, complaints]) => ({
        id,
        listeners: { complaints },
      }))
    : [{ id: "agent-1", listeners: { complaints: true } }];

  let capturedSvc: { start: (ctx: any) => Promise<void>; stop?: () => void } | null = null;

  const api = {
    id: PLUGIN_ID,
    name: "complaint-listener",
    source: "test",
    config: { agents: { list: agents } },
    pluginConfig: {},
    runtime: {
      channel: {
        whatsapp: { sendMessageWhatsApp: vi.fn().mockResolvedValue({ messageId: "m1" }) },
        telegram: { sendMessageTelegram: vi.fn().mockResolvedValue({ messageId: 1 }) },
      },
    },
    logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
    registerService: vi.fn((svc) => {
      capturedSvc = svc;
      if (!manualStart) {
        svc.start({ stateDir: "/tmp/test-state", config: {}, logger: {} });
      }
    }),
    on: vi.fn(),
    triggerStart: async () => {
      await capturedSvc!.start({ stateDir: "/tmp/test-state", config: {}, logger: {} });
    },
    triggerStop: () => {
      capturedSvc?.stop?.();
    },
  };
  return api;
}

const complaintResult = {
  is_complaint: true,
  severity: "high" as const,
  category: "product_defect" as const,
  summary: "Broken",
};

const msgEvent = { from: "+15551234567", content: "broken!" };
const msgCtx = { channelId: "whatsapp", agentId: "agent-1" };

describe("complaint-listener wiring", () => {
  beforeEach(() => {
    vi.resetAllMocks();
    vi.useFakeTimers();
    process.env.ANTHROPIC_API_KEY = "test-key";
    process.env.COMPLAINTS_CLASSIFIER_MODEL = "claude-haiku-4-5";
    process.env.COMPLAINTS_NOTIFY_WHATSAPP_TO = "+51922286663";
    process.env.COMPLAINTS_NOTIFY_TELEGRAM_CHAT_ID = "8416955510";
    process.env.COMPLAINTS_SUMMARY_WHATSAPP_TO = "+51922286663";
    process.env.COMPLAINTS_SUMMARY_HOUR = "9";
    (insertComplaint as MockFn).mockReturnValue(1);
    (sendComplaintNotification as MockFn).mockResolvedValue({ telegram: true, whatsapp: true });
    (getPendingNotifications as MockFn).mockReturnValue([]);
    (maybeFireSummary as MockFn).mockResolvedValue(undefined);
  });

  afterEach(() => {
    vi.useRealTimers();
    delete process.env.COMPLAINTS_NOTIFY_TELEGRAM_CHAT_ID;
    delete process.env.COMPLAINTS_SUMMARY_WHATSAPP_TO;
    delete process.env.COMPLAINTS_SUMMARY_HOUR;
  });

  it("registers message_received hook and a service", () => {
    const api = makeApi() as any;
    plugin.register(api);
    expect(api.on).toHaveBeenCalledWith("message_received", expect.any(Function));
    expect(api.registerService).toHaveBeenCalled();
  });

  it("passes dual-channel targets and marks notified when EITHER succeeds", async () => {
    (classifyMessage as MockFn).mockResolvedValue(complaintResult);
    (sendComplaintNotification as MockFn).mockResolvedValue({ telegram: true, whatsapp: false });
    const api = makeApi() as any;
    plugin.register(api);
    const handler = (api.on as MockFn).mock.calls[0][1];
    await handler(msgEvent, msgCtx);
    expect(sendComplaintNotification).toHaveBeenCalledWith(
      api,
      { telegramChatId: "8416955510", whatsappTo: "+51922286663" },
      "agent-1",
      "whatsapp",
      "+15551234567",
      complaintResult,
    );
    expect(markNotified).toHaveBeenCalled();
  });

  it("does not markNotified when both channels fail", async () => {
    (classifyMessage as MockFn).mockResolvedValue(complaintResult);
    (sendComplaintNotification as MockFn).mockResolvedValue({ telegram: false, whatsapp: false });
    const api = makeApi() as any;
    plugin.register(api);
    const handler = (api.on as MockFn).mock.calls[0][1];
    await handler(msgEvent, msgCtx);
    expect(markNotified).not.toHaveBeenCalled();
  });

  it("invokes maybeFireSummary on boot (catch-up)", async () => {
    const api = makeApi(undefined, true) as any;
    plugin.register(api);
    await api.triggerStart();
    expect(maybeFireSummary).toHaveBeenCalled();
  });

  it("schedules hourly maybeFireSummary via setInterval", async () => {
    const api = makeApi(undefined, true) as any;
    plugin.register(api);
    await api.triggerStart();
    (maybeFireSummary as MockFn).mockClear();
    await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
    expect(maybeFireSummary).toHaveBeenCalled();
  });

  it("service.stop clears the interval", async () => {
    const api = makeApi(undefined, true) as any;
    plugin.register(api);
    await api.triggerStart();
    api.triggerStop();
    (maybeFireSummary as MockFn).mockClear();
    await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
    expect(maybeFireSummary).not.toHaveBeenCalled();
  });

  it("skips classification for non-opted-in agent", async () => {
    const api = makeApi({ "agent-2": true }) as any;
    plugin.register(api);
    const handler = (api.on as MockFn).mock.calls[0][1];
    await handler(msgEvent, { channelId: "whatsapp", agentId: "agent-1" });
    expect(classifyMessage).not.toHaveBeenCalled();
  });

  it("skips when ANTHROPIC_API_KEY is missing", async () => {
    delete process.env.ANTHROPIC_API_KEY;
    const api = makeApi() as any;
    plugin.register(api);
    const handler = (api.on as MockFn).mock.calls[0][1];
    await handler(msgEvent, msgCtx);
    expect(classifyMessage).not.toHaveBeenCalled();
  });

  it("non-complaint result: no insert, no notify", async () => {
    (classifyMessage as MockFn).mockResolvedValue({
      is_complaint: false,
      severity: "low",
      category: "other",
      summary: "",
    });
    const api = makeApi() as any;
    plugin.register(api);
    const handler = (api.on as MockFn).mock.calls[0][1];
    await handler(msgEvent, msgCtx);
    expect(insertComplaint).not.toHaveBeenCalled();
    expect(sendComplaintNotification).not.toHaveBeenCalled();
  });

  it("classifier error is swallowed", async () => {
    (classifyMessage as MockFn).mockRejectedValue(new Error("API down"));
    const api = makeApi() as any;
    plugin.register(api);
    const handler = (api.on as MockFn).mock.calls[0][1];
    await expect(handler(msgEvent, msgCtx)).resolves.toBeUndefined();
  });

  it("catch-up drains pending rows using dual-channel sender", async () => {
    const pendingRow = {
      id: 1,
      agent_id: "agent-1",
      channel_id: "whatsapp",
      from_sender: "+15551234567",
      content: "x",
      is_complaint: 1,
      severity: "high" as const,
      category: "product_defect" as const,
      summary: "x",
      created_at: Date.now(),
      notified_at: null,
    };
    (getPendingNotifications as MockFn).mockReturnValue([pendingRow]);
    (sendComplaintNotification as MockFn).mockResolvedValue({ telegram: true, whatsapp: true });
    const api = makeApi(undefined, true) as any;
    plugin.register(api);
    await api.triggerStart();
    expect(sendComplaintNotification).toHaveBeenCalled();
    expect(markNotified).toHaveBeenCalledWith(expect.anything(), 1);
  });
});
```

## Appendix F — `index.ts`

```ts
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { emptyPluginConfigSchema } from "../../src/plugin-sdk/index.js";
import type { MinionPluginApi } from "../../src/plugins/types.js";
import { classifyMessage } from "./src/classifier.js";
import { sendComplaintNotification, type DeliveryTargets } from "./src/notifier.js";
import { initSchema, insertComplaint, markNotified, getPendingNotifications } from "./src/store.js";
import { maybeFireSummary, type SummaryConfig } from "./src/summary.js";

const PLUGIN_ID = "complaint-listener";
const SUMMARY_INTERVAL_MS = 60 * 60 * 1000;
const LOOKBACK_MS = 24 * 60 * 60 * 1000;

function resolveDbPath(stateDir: string): string {
  return path.join(stateDir, "plugins", PLUGIN_ID, "complaints.db");
}

function isOptedIn(api: MinionPluginApi, agentId: string): boolean {
  const agents = api.config.agents?.list ?? [];
  const agent = agents.find((a) => a.id === agentId);
  if (!agent) return false;
  return (
    (agent as Record<string, unknown> & { listeners?: { complaints?: boolean } })?.listeners
      ?.complaints === true
  );
}

function readTargets(): DeliveryTargets {
  return {
    telegramChatId: process.env.COMPLAINTS_NOTIFY_TELEGRAM_CHAT_ID ?? "",
    whatsappTo: process.env.COMPLAINTS_NOTIFY_WHATSAPP_TO ?? "",
  };
}

function readSummaryConfig(): SummaryConfig {
  const hour = Number.parseInt(process.env.COMPLAINTS_SUMMARY_HOUR ?? "9", 10);
  return {
    summaryTo: process.env.COMPLAINTS_SUMMARY_WHATSAPP_TO ?? "",
    summaryHour: Number.isFinite(hour) ? hour : 9,
    lookbackMs: LOOKBACK_MS,
  };
}

export default {
  id: PLUGIN_ID,
  name: "Silent Complaint Listener",
  description:
    "Silently classifies opted-in agent messages for complaints via Haiku, persists to SQLite, dual-delivers per-event alerts to Telegram + WhatsApp, and sends a daily WhatsApp digest.",
  configSchema: emptyPluginConfigSchema(),

  register(api: MinionPluginApi) {
    let db: DatabaseSync | null = null;
    let summaryTimer: ReturnType<typeof setInterval> | null = null;

    api.registerService({
      id: PLUGIN_ID,
      async start(ctx) {
        const dbPath = resolveDbPath(ctx.stateDir);
        fs.mkdirSync(path.dirname(dbPath), { recursive: true });
        db = new DatabaseSync(dbPath);
        initSchema(db);
        api.logger.info(`${PLUGIN_ID}: initialized db at ${dbPath}`);

        const targets = readTargets();
        if (!targets.whatsappTo && !targets.telegramChatId) {
          api.logger.info(
            `${PLUGIN_ID}: catch-up skipped (no COMPLAINTS_NOTIFY_TELEGRAM_CHAT_ID or COMPLAINTS_NOTIFY_WHATSAPP_TO)`,
          );
        } else {
          const pending = getPendingNotifications(db);
          let drained = 0;
          for (const row of pending) {
            const res = await sendComplaintNotification(
              api,
              targets,
              row.agent_id,
              row.channel_id,
              row.from_sender,
              {
                is_complaint: true,
                severity: row.severity,
                category: row.category,
                summary: row.summary,
              },
            );
            if (res.telegram || res.whatsapp) {
              markNotified(db, row.id);
              drained++;
            }
          }
          api.logger.info(`${PLUGIN_ID}: catch-up drained ${drained} of ${pending.length} pending`);
        }

        const summaryCfg = readSummaryConfig();
        const fireSummary = async () => {
          if (!db) return;
          try {
            await maybeFireSummary(api, db, summaryCfg);
          } catch (err) {
            api.logger.error(`${PLUGIN_ID}: summary scheduler error: ${String(err)}`);
          }
        };
        void fireSummary();
        summaryTimer = setInterval(() => void fireSummary(), SUMMARY_INTERVAL_MS);
      },
      stop() {
        if (summaryTimer) {
          clearInterval(summaryTimer);
          summaryTimer = null;
        }
        db = null;
        api.logger.info(`${PLUGIN_ID}: stopped`);
      },
    });

    api.on("message_received", async (event, ctx) => {
      if (!db) return;
      const agentId = ctx.agentId;
      if (!agentId) return;
      if (!isOptedIn(api, agentId)) return;

      const apiKey = process.env.ANTHROPIC_API_KEY;
      if (!apiKey) {
        api.logger.warn(`${PLUGIN_ID}: ANTHROPIC_API_KEY not set; skipping classification`);
        return;
      }

      const model = process.env.COMPLAINTS_CLASSIFIER_MODEL ?? "claude-haiku-4-5";
      const targets = readTargets();

      let result;
      try {
        result = await classifyMessage(event.content, apiKey, model);
      } catch (err) {
        api.logger.error(`${PLUGIN_ID}: classifier error: ${String(err)}`);
        return;
      }

      if (!result.is_complaint) return;

      const currentDb = db;
      let rowId: number;
      try {
        rowId = insertComplaint(currentDb, {
          agentId,
          channelId: ctx.channelId,
          fromSender: event.from,
          content: event.content,
          result,
        });
      } catch (err) {
        api.logger.error(`${PLUGIN_ID}: store error: ${String(err)}`);
        rowId = -1;
      }

      const res = await sendComplaintNotification(
        api,
        targets,
        agentId,
        ctx.channelId,
        event.from,
        result,
      );

      if ((res.telegram || res.whatsapp) && rowId > 0) {
        try {
          markNotified(currentDb, rowId);
        } catch (err) {
          api.logger.error(`${PLUGIN_ID}: markNotified error: ${String(err)}`);
        }
      }

      api.logger.info(
        `${PLUGIN_ID}: complaint logged (id=${rowId}, severity=${result.severity}, tg=${res.telegram}, wa=${res.whatsapp})`,
      );
    });
  },
};
```
