mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef5d09680a | |||
| 33b33b84b9 | |||
| a30547fb6a | |||
| 27de55647b | |||
| 6cd15c6d13 | |||
| 40a21fe8f3 | |||
| 91d30713b5 | |||
| 1ad258622b | |||
| e370cae4e7 | |||
| e40d43a43b | |||
| 642c5ecd37 | |||
| 0bb9016e94 | |||
| f797024770 | |||
| bea2dab171 | |||
| 53ffa3d347 | |||
| 8d39368352 | |||
| 59fc8e3cc7 | |||
| 45f656f21c | |||
| a4513f292f | |||
| 38eb5a44df | |||
| 6d397fc116 | |||
| cb2bbcc7aa | |||
| b9998a4bf1 | |||
| 0d41f0ef94 | |||
| 3363a0be0f | |||
| 05cf44035e | |||
| c1eb1079cc | |||
| 1725d86fbd | |||
| df4929d61d |
@@ -167,7 +167,14 @@ pnpm exec tsx scripts/init-first-agent.ts \
|
||||
|
||||
### Groups
|
||||
|
||||
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
|
||||
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group, or wire it directly with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket):
|
||||
|
||||
```bash
|
||||
# Engage mode/pattern default to the DeltaChat adapter's declared channel
|
||||
# defaults — for DeltaChat groups that's a name pattern (the platform has no
|
||||
# mention metadata), so the agent responds when addressed by name.
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <ag-id>
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -184,7 +191,7 @@ Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaC
|
||||
- **user-id-format**: `deltachat:{email}` — the contact's email address
|
||||
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
|
||||
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically get their own agent group (the default `shared` session mode already gives each messaging group its own session)
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -105,6 +105,14 @@ pnpm exec tsx setup/index.ts --step register -- \
|
||||
|
||||
`agent-shared` puts Emacs messages in the same session as any other channel wired to the same agent group — so a conversation you started in Telegram continues in Emacs. Use `shared` to keep an independent Emacs thread with the same workspace, or a new `--folder` for a dedicated Emacs-only agent.
|
||||
|
||||
Alternatively create the rows with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket). Engage mode/pattern and `unknown_sender_policy` default to the Emacs adapter's declared channel defaults:
|
||||
|
||||
```bash
|
||||
ncl messaging-groups create --channel-type emacs --platform-id "default" --name "Emacs"
|
||||
ncl wirings create --messaging-group-id <mg-id-from-above> --agent-group-id <ag-id> \
|
||||
--session-mode agent-shared
|
||||
```
|
||||
|
||||
## Configure Emacs
|
||||
|
||||
`nanoclaw.el` needs only Emacs 27.1+ builtins (`url`, `json`, `org`) — no package manager.
|
||||
|
||||
@@ -107,16 +107,17 @@ Ask the user: **Is this a private or public repo?**
|
||||
- **Private repo** — use `unknown_sender_policy: 'public'`. Only collaborators can comment anyway, so it's safe to let all comments through.
|
||||
- **Public repo** — use `unknown_sender_policy: 'strict'`. Only registered members can trigger the agent, preventing strangers from consuming agent resources. Add trusted collaborators as members (see below).
|
||||
|
||||
Run `/manage-channels` to wire the GitHub channel to an agent group, or insert manually:
|
||||
Run `/manage-channels` to wire the GitHub channel to an agent group, or create the rows directly with `ncl`. **The host service must be running** — `ncl` connects to it over a Unix socket:
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per repo)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
```bash
|
||||
# Create messaging group (one per repo)
|
||||
ncl messaging-groups create --channel-type github --platform-id "github:owner/repo" \
|
||||
--name "owner/repo" --is-group 1 --unknown-sender-policy <policy>
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES ('mga-github-myrepo', 'mg-github-myrepo', '<your-agent-group-id>', '', 'all', 'per-thread', 10, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
# Wire to agent group (engage mode/pattern default to the GitHub adapter's
|
||||
# declared channel defaults; grab the mg id from the create output above)
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <your-agent-group-id> \
|
||||
--session-mode per-thread
|
||||
```
|
||||
|
||||
Replace `<policy>` with `public` or `strict` based on the user's choice above.
|
||||
@@ -125,14 +126,12 @@ Replace `<policy>` with `public` or `strict` based on the user's choice above.
|
||||
|
||||
When using `strict`, add each GitHub user who should be able to trigger the agent:
|
||||
|
||||
```sql
|
||||
-- Add user (kind = 'github', id = 'github:<numeric-user-id>')
|
||||
INSERT OR IGNORE INTO users (id, kind, display_name, created_at)
|
||||
VALUES ('github:<user-id>', 'github', '<username>', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
```bash
|
||||
# Add user (kind = 'github', id = 'github:<numeric-user-id>')
|
||||
ncl users create --id "github:<user-id>" --kind github --display-name "<username>"
|
||||
|
||||
-- Grant membership to the agent group
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id)
|
||||
VALUES ('github:<user-id>', '<agent-group-id>');
|
||||
# Grant membership to the agent group
|
||||
ncl members add --user "github:<user-id>" --group "<agent-group-id>"
|
||||
```
|
||||
|
||||
To find a GitHub user's numeric ID: `gh api users/<username> --jq .id`
|
||||
|
||||
@@ -115,19 +115,20 @@ Ask the user: **Is this a private or public Linear workspace?**
|
||||
- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment.
|
||||
- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example).
|
||||
|
||||
Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually:
|
||||
Run `/manage-channels` to wire the Linear channel to an agent group, or create the rows directly with `ncl`. **The host service must be running** — `ncl` connects to it over a Unix socket:
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per team)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
```bash
|
||||
# Create messaging group (one per team)
|
||||
ncl messaging-groups create --channel-type linear --platform-id "linear:ENG" \
|
||||
--name "Engineering" --is-group 1 --unknown-sender-policy <policy>
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
# Wire to agent group (engage mode/pattern default to the Linear adapter's
|
||||
# declared channel defaults; grab the mg id from the create output above)
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <your-agent-group-id> \
|
||||
--session-mode per-thread
|
||||
```
|
||||
|
||||
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
|
||||
Replace `<policy>` with `public` or `strict` based on the user's choice above. The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -220,30 +220,21 @@ Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agen
|
||||
|
||||
### Groups
|
||||
|
||||
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
|
||||
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. Each group gets its own session with the default `shared` mode (one session per agent + messaging group). Create the wiring with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket):
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "
|
||||
INSERT OR IGNORE INTO messaging_group_agents
|
||||
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
|
||||
VALUES
|
||||
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
|
||||
"
|
||||
# Engage mode/pattern default to the Signal adapter's declared channel defaults
|
||||
ncl wirings create --messaging-group-id mg-GROUPID --agent-group-id ag-AGENTID
|
||||
```
|
||||
|
||||
### Grant user access
|
||||
|
||||
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
|
||||
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups` (host service running):
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "
|
||||
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
|
||||
"
|
||||
ncl users create --id "signal:UUID" --kind signal --display-name "<name>"
|
||||
ncl roles grant --user "signal:UUID" --role owner
|
||||
ncl members add --user "signal:UUID" --group ag-AGENTID
|
||||
```
|
||||
|
||||
Find the UUID from `messaging_groups.platform_id` or the `users` table.
|
||||
@@ -264,7 +255,7 @@ Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal
|
||||
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
|
||||
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant via Signal DMs or small group chats
|
||||
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically get their own agent group (the default `shared` session mode already gives each messaging group its own session)
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Skip to **Credentials** if all of these are already in place:
|
||||
- `src/channels/slack.ts` exists
|
||||
- `src/channels/slack-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './slack.js';`
|
||||
- `container/skills/slack-formatting/SKILL.md` exists
|
||||
- `@chat-adapter/slack` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
@@ -33,8 +34,15 @@ git fetch origin channels
|
||||
```bash
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
git show origin/channels:src/channels/slack-registration.test.ts > src/channels/slack-registration.test.ts
|
||||
mkdir -p container/skills/slack-formatting
|
||||
git show origin/channels:container/skills/slack-formatting/SKILL.md > container/skills/slack-formatting/SKILL.md
|
||||
```
|
||||
|
||||
The `slack-formatting` container skill is part of the channel payload: it
|
||||
reaches agents via `~/.claude/skills` (synced at spawn) and teaches Slack's
|
||||
mrkdwn syntax. Trunk does not ship it — without this copy step agents send
|
||||
Slack messages with generic markdown that renders literally.
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
@@ -121,9 +121,11 @@ The bot is now connected as your WeChat account.
|
||||
|
||||
A successful QR login alone isn't enough — the adapter still needs to be wired to an agent group before it can respond.
|
||||
|
||||
**Prerequisite: the host service must be running.** The wire script creates the wiring through `ncl`, which talks to the running host over a Unix socket — there is no offline mode.
|
||||
|
||||
### 1. Trigger the first inbound message
|
||||
|
||||
Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id`.
|
||||
Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id` and the `unknown_sender_policy` the WeChat adapter declares.
|
||||
|
||||
### 2. Run the wire script
|
||||
|
||||
@@ -131,9 +133,9 @@ Have a different WeChat account send a message to the bot account. This auto-cre
|
||||
pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
|
||||
```
|
||||
|
||||
Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and creates the `messaging_group_agents` row with sensible defaults (sender policy `request_approval`, session mode `shared`).
|
||||
Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and runs `ncl wirings create` — engage mode/pattern and priority come from the WeChat adapter's declared channel defaults, so a wiring created here matches one created by `/manage-channels` or the approval-card flow.
|
||||
|
||||
With `request_approval`, the next DM from the stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent.
|
||||
With `request_approval` as the sender policy, the next DM from a stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent.
|
||||
|
||||
Non-interactive:
|
||||
|
||||
@@ -147,10 +149,16 @@ pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts \
|
||||
Flags:
|
||||
|
||||
- `--platform-id <id>` — wire a specific messaging group (default: most recent unwired)
|
||||
- `--agent-group <id>` — target agent group (default: prompt; or solo admin group in non-interactive)
|
||||
- `--sender-policy public|strict|request_approval` — default `request_approval` (fires an admin approval card on unknown-sender DMs)
|
||||
- `--agent-group <id>` — target agent group (default: prompt; auto-picked when only one exists)
|
||||
- `--sender-policy public|strict|request_approval` — override the messaging group's `unknown_sender_policy` (default: leave whatever the WeChat adapter declared when the row was auto-created)
|
||||
- `--session-mode shared|per-thread` — default `shared`
|
||||
|
||||
Equivalent raw `ncl` invocation (host must be running):
|
||||
|
||||
```bash
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <ag-id> --session-mode shared
|
||||
```
|
||||
|
||||
### 3. Test
|
||||
|
||||
Have the sender message the bot again — the agent should respond.
|
||||
|
||||
@@ -5,43 +5,49 @@
|
||||
* After /add-wechat installs the adapter and the user scans the QR login,
|
||||
* the first inbound message from another WeChat account auto-creates a
|
||||
* `messaging_groups` row. This script finds that row, asks the operator
|
||||
* which agent group to wire it to, and inserts the `messaging_group_agents`
|
||||
* join row with sensible defaults — the "post-login wiring" step /add-wechat
|
||||
* otherwise requires manual SQL for.
|
||||
* which agent group to wire it to, and creates the wiring via
|
||||
* `ncl wirings create` — engage mode/pattern and priority come from the
|
||||
* WeChat adapter's declared channel defaults, not from SQL baked into this
|
||||
* script, so it can't drift against schema migrations.
|
||||
*
|
||||
* Usage:
|
||||
* PREREQUISITE: the NanoClaw host service must be RUNNING — `ncl` talks to
|
||||
* it over a Unix socket and has no offline mode.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
|
||||
*
|
||||
* Flags:
|
||||
* --platform-id <id> Wire a specific messaging group (default: most recent unwired)
|
||||
* --agent-group <id> Target agent group (default: interactive pick; or solo admin group)
|
||||
* --sender-policy <p> public | strict (default: public)
|
||||
* --agent-group <id> Target agent group (default: interactive pick; auto-picked when only one exists)
|
||||
* --sender-policy <p> public | strict | request_approval — overrides the
|
||||
* channel-declared unknown_sender_policy on the
|
||||
* messaging group (default: leave as the adapter declared)
|
||||
* --session-mode <m> shared | per-thread (default: shared)
|
||||
* --non-interactive Fail instead of prompting
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DB_PATH = process.env.NANOCLAW_DB_PATH ?? path.join(process.cwd(), 'data', 'v2.db');
|
||||
// <root>/.claude/skills/add-wechat/scripts/wire-dm.ts → <root>
|
||||
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
|
||||
|
||||
type SenderPolicy = 'public' | 'strict' | 'request_approval';
|
||||
|
||||
interface Args {
|
||||
platformId?: string;
|
||||
agentGroupId?: string;
|
||||
senderPolicy: SenderPolicy;
|
||||
senderPolicy?: SenderPolicy;
|
||||
sessionMode: 'shared' | 'per-thread';
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const args: Args = {
|
||||
// Default matches the router's auto-create (`request_approval`) so the
|
||||
// admin gets an approval card on the next unknown-sender DM rather than
|
||||
// a silent allow. Pass `--sender-policy public` to open the channel to
|
||||
// anyone, or `strict` to require explicit membership.
|
||||
senderPolicy: 'request_approval',
|
||||
// No --sender-policy default: the router already stamped the policy the
|
||||
// WeChat adapter declares when it auto-created the messaging group.
|
||||
// Only an explicit flag overrides it.
|
||||
sessionMode: 'shared',
|
||||
interactive: true,
|
||||
};
|
||||
@@ -68,72 +74,90 @@ function parseArgs(argv: string[]): Args {
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Run one ncl command against the running host and return its parsed data. */
|
||||
function ncl(...cliArgs: string[]): unknown {
|
||||
const res = spawnSync('pnpm', ['exec', 'tsx', 'src/cli/client.ts', ...cliArgs, '--json'], {
|
||||
cwd: PROJECT_ROOT,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (res.error) throw res.error;
|
||||
let frame: { ok: boolean; data?: unknown; error?: { message: string } } | undefined;
|
||||
try {
|
||||
frame = JSON.parse(res.stdout);
|
||||
} catch {
|
||||
// No frame — transport-level failure (host not running), reported on stderr.
|
||||
}
|
||||
if (frame && !frame.ok) throw new Error(`ncl ${cliArgs.join(' ')} failed: ${frame.error?.message}`);
|
||||
if (!frame || res.status !== 0) {
|
||||
const detail = (res.stderr || res.stdout || '').trim();
|
||||
throw new Error(
|
||||
`ncl ${cliArgs.join(' ')} failed:\n${detail}\n\n` +
|
||||
'Is the NanoClaw host service running? ncl connects to it over a Unix socket.',
|
||||
);
|
||||
}
|
||||
return frame.data;
|
||||
}
|
||||
|
||||
async function prompt(q: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => rl.question(q, (a) => { rl.close(); resolve(a.trim()); }));
|
||||
}
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
interface MgRow { id: string; platform_id: string; name: string | null; is_group: number; created_at: string }
|
||||
interface AgRow { id: string; name: string; created_at: string }
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
const mgs = ncl('messaging-groups', 'list', '--channel-type', 'wechat') as MgRow[];
|
||||
const wirings = ncl('wirings', 'list', '--limit', '10000') as Array<{ messaging_group_id: string }>;
|
||||
const wiredMgIds = new Set(wirings.map((w) => w.messaging_group_id));
|
||||
|
||||
// 1. Pick the messaging group
|
||||
let platformId = args.platformId;
|
||||
if (!platformId) {
|
||||
const rows = db.prepare(`
|
||||
SELECT mg.id, mg.platform_id, mg.name, mg.is_group, mg.created_at
|
||||
FROM messaging_groups mg
|
||||
LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id
|
||||
WHERE mg.channel_type = 'wechat' AND mga.id IS NULL
|
||||
ORDER BY mg.created_at DESC
|
||||
`).all() as Array<{ id: string; platform_id: string; name: string | null; is_group: number; created_at: string }>;
|
||||
let mg: MgRow | undefined;
|
||||
if (args.platformId) {
|
||||
mg = mgs.find((r) => r.platform_id === args.platformId);
|
||||
if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${args.platformId}`);
|
||||
} else {
|
||||
const unwired = mgs
|
||||
.filter((r) => !wiredMgIds.has(r.id))
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||
|
||||
if (rows.length === 0) {
|
||||
if (unwired.length === 0) {
|
||||
console.error('No unwired WeChat messaging groups found.');
|
||||
console.error('Send a message to the bot first (from another WeChat account), then re-run.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (rows.length === 1 || !args.interactive) {
|
||||
platformId = rows[0].platform_id;
|
||||
console.log(`Using most recent unwired group: ${platformId} (${rows[0].is_group ? 'group' : 'DM'})`);
|
||||
if (unwired.length === 1 || !args.interactive) {
|
||||
mg = unwired[0];
|
||||
console.log(`Using most recent unwired group: ${mg.platform_id} (${mg.is_group ? 'group' : 'DM'})`);
|
||||
} else {
|
||||
console.log('Unwired WeChat messaging groups:');
|
||||
rows.forEach((r, i) => {
|
||||
unwired.forEach((r, i) => {
|
||||
console.log(` ${i + 1}. ${r.platform_id} (${r.is_group ? 'group' : 'DM'}, ${r.created_at})`);
|
||||
});
|
||||
const pick = await prompt('Pick one [1]: ');
|
||||
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
|
||||
if (Number.isNaN(idx) || idx < 0 || idx >= rows.length) throw new Error('invalid choice');
|
||||
platformId = rows[idx].platform_id;
|
||||
if (Number.isNaN(idx) || idx < 0 || idx >= unwired.length) throw new Error('invalid choice');
|
||||
mg = unwired[idx];
|
||||
}
|
||||
}
|
||||
|
||||
const mg = db.prepare(
|
||||
'SELECT id, platform_id, is_group FROM messaging_groups WHERE channel_type = ? AND platform_id = ?'
|
||||
).get('wechat', platformId) as { id: string; platform_id: string; is_group: number } | undefined;
|
||||
if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${platformId}`);
|
||||
|
||||
// 2. Pick the agent group
|
||||
let agentGroupId = args.agentGroupId;
|
||||
if (!agentGroupId) {
|
||||
const agents = db.prepare('SELECT id, name, is_admin FROM agent_groups ORDER BY is_admin DESC, created_at ASC')
|
||||
.all() as Array<{ id: string; name: string; is_admin: number }>;
|
||||
const agents = (ncl('groups', 'list') as AgRow[])
|
||||
.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
||||
if (agents.length === 0) throw new Error('no agent groups exist — create one first');
|
||||
|
||||
const adminAgents = agents.filter((a) => a.is_admin === 1);
|
||||
if (adminAgents.length === 1 && !args.interactive) {
|
||||
agentGroupId = adminAgents[0].id;
|
||||
console.log(`Auto-selected sole admin agent group: ${adminAgents[0].name} (${agentGroupId})`);
|
||||
if (agents.length === 1) {
|
||||
agentGroupId = agents[0].id;
|
||||
console.log(`Auto-selected sole agent group: ${agents[0].name} (${agentGroupId})`);
|
||||
} else if (args.interactive) {
|
||||
console.log('Agent groups:');
|
||||
agents.forEach((a, i) => {
|
||||
console.log(` ${i + 1}. ${a.name} (${a.id})${a.is_admin ? ' [admin]' : ''}`);
|
||||
console.log(` ${i + 1}. ${a.name} (${a.id})`);
|
||||
});
|
||||
const pick = await prompt('Pick one [1]: ');
|
||||
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
|
||||
@@ -144,26 +168,29 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const ag = db.prepare('SELECT id, name FROM agent_groups WHERE id = ?').get(agentGroupId) as
|
||||
{ id: string; name: string } | undefined;
|
||||
const ag = (ncl('groups', 'list') as AgRow[]).find((a) => a.id === agentGroupId);
|
||||
if (!ag) throw new Error(`no agent_group with id = ${agentGroupId}`);
|
||||
|
||||
// 3. Update sender policy + wire
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare('UPDATE messaging_groups SET unknown_sender_policy = ? WHERE id = ?')
|
||||
.run(args.senderPolicy, mg.id);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO messaging_group_agents
|
||||
(id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES (?, ?, ?, '', 'all', ?, 10, ?)
|
||||
`).run(generateId('mga'), mg.id, ag.id, args.sessionMode, new Date().toISOString());
|
||||
});
|
||||
tx();
|
||||
// 3. Wire, then apply the optional policy override. Engage mode/pattern and
|
||||
// priority are filled by the wirings resolveDefaults hook from the WeChat
|
||||
// adapter's declared channel defaults. Policy update runs second so a
|
||||
// failed create (e.g. already wired) leaves the mg row untouched.
|
||||
const wiring = ncl(
|
||||
'wirings', 'create',
|
||||
'--messaging-group-id', mg.id,
|
||||
'--agent-group-id', ag.id,
|
||||
'--session-mode', args.sessionMode,
|
||||
) as { engage_mode: string; engage_pattern: string | null };
|
||||
if (args.senderPolicy) {
|
||||
ncl('messaging-groups', 'update', mg.id, '--unknown-sender-policy', args.senderPolicy);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} policy=${args.senderPolicy} mode=${args.sessionMode}`);
|
||||
db.close();
|
||||
console.log(
|
||||
`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} ` +
|
||||
`engage=${wiring.engage_mode}${wiring.engage_pattern ? `(${wiring.engage_pattern})` : ''} ` +
|
||||
`policy=${args.senderPolicy ?? '(channel default)'} mode=${args.sessionMode}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -7,6 +7,34 @@ description: Add WhatsApp channel via native Baileys adapter. Direct connection
|
||||
|
||||
Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge).
|
||||
|
||||
## Number safety check (required)
|
||||
|
||||
Complete this check before running any install or authentication command. If the user already said they want to use their **shared**, **personal**, **main**, **existing**, or **everyday** WhatsApp number, treat it as a shared number and show the warning immediately. Do not ask the number-type question again.
|
||||
|
||||
Otherwise, use `AskUserQuestion`:
|
||||
|
||||
**Which WhatsApp number will NanoClaw use?**
|
||||
|
||||
- **Dedicated number (Recommended)** — a separate number used only for NanoClaw
|
||||
- **Shared / personal number** — the user's existing everyday WhatsApp number
|
||||
|
||||
Remember the answer as `NUMBER_MODE` for the rest of this workflow.
|
||||
|
||||
If `NUMBER_MODE=shared`, show this warning exactly:
|
||||
|
||||
> ⚠️ **Risk to your WhatsApp account**
|
||||
>
|
||||
> Connecting your shared or personal number could cause WhatsApp to temporarily suspend or permanently ban that number. You could lose access to the WhatsApp account, chats, and groups you rely on.
|
||||
>
|
||||
> We strongly recommend using a separate, dedicated number for NanoClaw.
|
||||
|
||||
Then use `AskUserQuestion`:
|
||||
|
||||
- **Go back and use a dedicated number (Recommended)**
|
||||
- **I understand the risk — continue with my shared number**
|
||||
|
||||
Do not continue with installation or authentication unless the user explicitly selects the second option. If they choose a dedicated number, set `NUMBER_MODE=dedicated` and continue without showing the warning again.
|
||||
|
||||
## Install
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge.
|
||||
@@ -20,6 +48,7 @@ Skip to **Credentials** if all of these are already in place:
|
||||
- `src/channels/whatsapp.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './whatsapp.js';`
|
||||
- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist
|
||||
- `container/skills/whatsapp-formatting/instructions.md` exists
|
||||
- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:`
|
||||
- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies
|
||||
- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill)
|
||||
@@ -40,8 +69,17 @@ git show origin/channels:src/channels/whatsapp-registration.test.ts > src/cha
|
||||
git show origin/channels:src/channels/whatsapp.test.ts > src/channels/whatsapp.test.ts
|
||||
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
|
||||
git show origin/channels:setup/groups.ts > setup/groups.ts
|
||||
mkdir -p container/skills/whatsapp-formatting
|
||||
git show origin/channels:container/skills/whatsapp-formatting/SKILL.md > container/skills/whatsapp-formatting/SKILL.md
|
||||
git show origin/channels:container/skills/whatsapp-formatting/instructions.md > container/skills/whatsapp-formatting/instructions.md
|
||||
```
|
||||
|
||||
The `whatsapp-formatting` container skill is part of the channel payload: its
|
||||
`instructions.md` becomes the `skill-whatsapp-formatting.md` fragment in every
|
||||
group's composed CLAUDE.md (see `src/claude-md-compose.ts`), teaching agents
|
||||
WhatsApp's formatting syntax. Trunk does not ship it — without this copy step
|
||||
agents format WhatsApp messages with generic markdown that renders literally.
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
@@ -82,7 +120,7 @@ WhatsApp uses linked-device authentication — no API key, just a one-time pairi
|
||||
|
||||
### Check current state
|
||||
|
||||
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Shared vs dedicated number".
|
||||
Check if WhatsApp is already authenticated. The number safety check above is still required even when credentials already exist. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number" after completing the safety check.
|
||||
|
||||
```bash
|
||||
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
|
||||
@@ -192,16 +230,58 @@ for i in $(seq 1 60); do grep -q 'STATUS: authenticated' /tmp/wa-auth.log 2>/dev
|
||||
test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed"
|
||||
```
|
||||
|
||||
### Shared vs dedicated number
|
||||
## Dedicated vs personal number
|
||||
|
||||
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
|
||||
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
|
||||
- **Dedicated number** — a separate phone/SIM for the assistant
|
||||
The adapter behaves fundamentally differently depending on whether the linked number is the assistant's own or the operator's personal one. The switch is `ASSISTANT_HAS_OWN_NUMBER` in `.env`, read by the adapter itself at startup. **Inference rule: absent (or anything other than `true`) means shared/personal** — the safe default, since misreading a personal number as dedicated makes the bot claim messages addressed to the human.
|
||||
|
||||
If dedicated, add to `.env`:
|
||||
- **Shared/personal number** (`ASSISTANT_HAS_OWN_NUMBER` unset or not `true`) — DMs to this number and group @-tags of it address the *human*, not the bot. The adapter never emits a mention signal (`mentions: 'never'` in its declared channel defaults), so: no stranger DM ever auto-creates a messaging group or raises an admin approval card; group wirings default to a name pattern (`\b<AgentName>\b`) instead of platform mentions; auto-created chats default to `unknown_sender_policy: 'strict'`; outbound messages are prefixed with the assistant's name.
|
||||
- **Dedicated number** (`ASSISTANT_HAS_OWN_NUMBER=true`) — everything sent to the number is for the bot. DMs and group mentions carry a real mention signal (`mentions: 'platform'`), unknown senders escalate via `request_approval` approval cards, and card-approved groups wire with `engage_mode: 'mention'`. No name prefix on outbound.
|
||||
|
||||
Use the `NUMBER_MODE` selected in the required safety check. If information discovered later contradicts that selection, ask again before changing modes; switching to shared requires the same warning and explicit acknowledgement.
|
||||
|
||||
Write the answer to `.env` **explicitly in both cases** (don't rely on the inference rule for new installs):
|
||||
|
||||
```bash
|
||||
# Dedicated:
|
||||
ASSISTANT_HAS_OWN_NUMBER=true
|
||||
# Shared/personal:
|
||||
ASSISTANT_HAS_OWN_NUMBER=false
|
||||
```
|
||||
|
||||
### Update path: existing install, flag unset
|
||||
|
||||
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Use the mode established by the required safety check and write it explicitly.
|
||||
|
||||
Suggest a default by comparing the authed number against the wired DM chat:
|
||||
|
||||
```bash
|
||||
# The number this install is authenticated as
|
||||
node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0])"
|
||||
# The wired WhatsApp DM chats
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp' AND mg.is_group=0"
|
||||
```
|
||||
|
||||
If the wired DM's phone **equals** the authed number, the operator is talking to the bot in their own self-chat — that's a personal number: suggest **Shared**. If they differ, the operator messages the bot from a different number: suggest **Dedicated**. Confirm with the operator either way, then write the flag and restart the service.
|
||||
|
||||
### Migration audit: spam-era group wirings
|
||||
|
||||
Before the shared-number fix, group chats approved via the channel-registration card were wired `engage_mode='pattern'` with pattern `.` — respond-to-everything — because the card flow couldn't tell groups from DMs on non-threaded platforms. On a personal number this shows up as the bot answering every message in family/work groups after someone once tapped Connect on a spam-triggered card.
|
||||
|
||||
List the suspect wirings (host service running — `ncl` is socket-only):
|
||||
|
||||
```bash
|
||||
ncl wirings list --engage-mode pattern --engage-pattern "." --json
|
||||
```
|
||||
|
||||
Cross-reference against WhatsApp group chats (`ncl messaging-groups list --channel-type whatsapp --is-group 1`). For each wiring with pattern `.` on a WhatsApp group that is *not* the operator's deliberate always-on chat (e.g. their self-chat), offer:
|
||||
|
||||
- **Flip to name-based engagement**: `ncl wirings update <wiring-id> --engage-mode pattern --engage-pattern '\b<AgentName>\b'` (or `--engage-mode mention` on a dedicated number)
|
||||
- **Delete the wiring**: `ncl wirings delete <wiring-id>`
|
||||
|
||||
Stale approval cards from that era can also linger. Clear pending channel approvals for chats the operator doesn't want wired:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM pending_channel_approvals WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='whatsapp')"
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
@@ -279,3 +359,12 @@ systemctl --user start $(systemd_unit)
|
||||
### "conflict" disconnection
|
||||
|
||||
Two instances connected with same credentials. Ensure only one NanoClaw process is running.
|
||||
|
||||
### Trunk updated but shared-number behavior unchanged (stale adapter copy)
|
||||
|
||||
The shared-number behavior (no stranger approval cards, name-pattern group defaults) lives in the **adapter copy** at `src/channels/whatsapp.ts`, installed from the `channels` branch — not in trunk. If you updated trunk via `/update-nanoclaw` but skipped the skill-update step, the old adapter copy neither reads `ASSISTANT_HAS_OWN_NUMBER` itself nor declares channel defaults, so trunk falls back to the legacy behavior: approval cards still fire on a personal number, and new wirings get the channel-blind defaults. Symptoms of the skew:
|
||||
|
||||
- `.env` says `ASSISTANT_HAS_OWN_NUMBER=false` (or unset) but strangers' DMs still raise approval cards
|
||||
- `ncl wirings create` on a WhatsApp group defaults to `mention` instead of a name pattern
|
||||
|
||||
Fix: re-run `/add-whatsapp` (or `/update-skills`) to pull the current adapter from the `channels` branch, then restart the service. The reverse skew (new adapter, old trunk) can't happen — the adapter's `defaults` field is optional and old trunk ignores it.
|
||||
|
||||
@@ -113,7 +113,7 @@ If they say it didn't arrive, then diagnose using the DB directly (no waiting lo
|
||||
|
||||
**"Missing required args"** — the script wants `--channel`, `--user-id`, `--platform-id`, `--display-name` at minimum. Re-check the command you assembled.
|
||||
|
||||
**No `messaging_groups` row appears after the user DMs (step 3a)** — the router silently drops messages from unknown senders under `strict` policy but still creates the `messaging_groups` row. If the row is missing entirely, the adapter isn't receiving the inbound message. Check `logs/nanoclaw.log` for adapter errors (auth, gateway disconnect, rate limit).
|
||||
**No `messaging_groups` row appears after the user DMs (step 3a)** — auto-created rows are stamped with the channel adapter's declared `unknown_sender_policy` (two-level model: adapter declaration → per-row override; `strict` only when the adapter has no declaration). Under `strict` the router silently drops messages from unknown senders but still creates the `messaging_groups` row; under `request_approval` an approval card goes to an admin instead. If the row is missing entirely, the adapter isn't receiving the inbound message. Check `logs/nanoclaw.log` for adapter errors (auth, gateway disconnect, rate limit).
|
||||
|
||||
**Owner already exists** — `hasAnyOwner()` returned true, so the grant is skipped silently. That's fine; the script still creates the agent and wiring. Reassigning ownership needs a separate flow (not this skill).
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ pnpm exec tsx scripts/q.ts data/v2.db "<query>"
|
||||
```sql
|
||||
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
|
||||
SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups;
|
||||
SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents;
|
||||
SELECT messaging_group_id, agent_group_id, engage_mode, engage_pattern, session_mode, threads, priority FROM messaging_group_agents;
|
||||
SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC;
|
||||
```
|
||||
|
||||
@@ -36,6 +36,47 @@ If the instance has no owner yet (`SELECT COUNT(*) FROM user_roles WHERE role='o
|
||||
|
||||
**Delegate to `/init-first-agent`.** It handles: channel choice, operator identity lookup, DM platform id resolution (with cold-DM or pair-code fallback), agent group creation, wiring, and the welcome DM. Return here afterward for any additional channels.
|
||||
|
||||
## Channel Defaults: The Two-Level Model
|
||||
|
||||
Wiring defaults (engage mode/pattern, threading, `unknown_sender_policy`) resolve through **exactly two levels**:
|
||||
|
||||
1. **Adapter declaration** — each channel adapter declares `ChannelDefaults` (separate DM and group contexts, plus a `mentions` capability) in its source file. The adapter copy is skill-installed and user-owned: to change a default install-wide, edit `src/channels/<channel>.ts` and restart. Declarations are never persisted to the DB.
|
||||
2. **Per-wiring/per-mg values chosen at creation** — every creation surface (`ncl wirings create` / `ncl messaging-groups create`, the `register` wizard step, the approval-card flow, `/init-first-agent`) fills omitted fields from the declaration and stores the result on the row. Pass explicit flags to override one wiring.
|
||||
|
||||
There is no third level: existing rows are never re-resolved, so editing a declaration only affects wirings created afterward. The one exception is the **`threads` column**, which stays live — `NULL` means "inherit the declaration at message time".
|
||||
|
||||
Channels with no declaration (stale adapter copies) fall back to the legacy behavior; run `/update-skills` to pull current adapters.
|
||||
|
||||
### Wiring via ncl
|
||||
|
||||
`ncl` requires the **host service to be running** (it connects over a Unix socket):
|
||||
|
||||
```bash
|
||||
ncl messaging-groups create --channel-type <type> --platform-id "<id>" --name "<name>" [--is-group 1]
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <ag-id> [--session-mode <mode>]
|
||||
```
|
||||
|
||||
Omitted `engage_mode`/`engage_pattern`/`unknown_sender_policy` come from the adapter declaration for the right context (DM vs group). Run `ncl wirings help` / `ncl messaging-groups help` for the full flag list.
|
||||
|
||||
### Threading override (`--threads`)
|
||||
|
||||
`ncl wirings create/update ... --threads true|false` controls whether platform thread ids are honored for this wiring. `true` (in groups) means per-thread sessions and in-thread replies/typing/cards; `false` collapses to a flat session with top-level replies. Omitted = `NULL` = inherit the channel declaration. A wiring can *disable* threads on a threaded platform (Slack, Discord, GitHub), never enable them on a non-threaded one.
|
||||
|
||||
Two consequences to warn the user about:
|
||||
|
||||
- **Session identity**: sessions are never deleted. Flipping `threads` on a live wiring orphans existing per-thread sessions (or splinters a shared one) — history stays in the old sessions; new messages start fresh ones.
|
||||
- **`mention-sticky` needs threads**: sticky engagement is keyed on per-thread session existence, so with resolved threads off it would engage once and never disengage. Creation and update coerce `mention-sticky` → `mention` (with a warning) when the effective thread policy is off.
|
||||
|
||||
### Mention capability
|
||||
|
||||
Each declaration states which mention signal the adapter emits: `platform` (real platform mentions), `dm-only` (only DMs are flagged), or `never`. On a `mentions: 'never'` channel (Linear OAuth apps, WhatsApp personal-number mode, Emacs), `mention`/`mention-sticky` wirings are **inert — they can never engage** — and `ncl` rejects them at create/update with an error citing the declaration. For groups on those channels, use a name pattern instead:
|
||||
|
||||
```bash
|
||||
ncl wirings update <id> --engage-mode pattern --engage-pattern '(?i)^@?<Name>\b'
|
||||
```
|
||||
|
||||
**Renaming an agent group does not update stored patterns.** Declared group patterns containing `{name}` are substituted with the agent group's name *at creation* and stored literally — after `ncl groups update <id> --name <NewName>`, audit that group's wirings for patterns still matching the old name and update them.
|
||||
|
||||
## Wire New Channel
|
||||
|
||||
For each unwired channel:
|
||||
@@ -67,6 +108,8 @@ pnpm exec tsx setup/index.ts --step register -- \
|
||||
|
||||
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name.
|
||||
|
||||
Omitted engage/policy fields default from the channel adapter's declaration (see "Channel Defaults" above). Optional overrides: `--trigger "<regex>"` (explicit engage pattern), `--engage-mode <pattern|mention|mention-sticky>`, `--is-group <true|false>`, `--unknown-sender-policy <strict|request_approval|public>`. Don't pick a mention mode on a channel whose declaration says `mentions: 'never'` — it can never engage there.
|
||||
|
||||
When creating a NEW agent group on a non-default provider, append `--provider <name>` (e.g. `--provider codex`) — there is no install-wide default; existing groups switch via `ncl groups config update --provider` instead.
|
||||
|
||||
For separate agents, also ask for a folder name and optionally a different assistant name.
|
||||
@@ -75,7 +118,7 @@ For separate agents, also ask for a folder name and optionally a different assis
|
||||
|
||||
When adding another group/chat on an already-configured platform (e.g. a second Telegram group):
|
||||
|
||||
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the `CODE` from the `PAIR_TELEGRAM_CODE` status block, and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the final `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row with `unknown_sender_policy = 'strict'` and upserted the paired user — `register` only needs to add the wiring:
|
||||
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the `CODE` from the `PAIR_TELEGRAM_CODE` status block, and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the final `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row stamped with the Telegram adapter's declared policy (`request_approval` on current adapter copies; `strict` only on stale pre-declaration copies) and upserted the paired user — `register` only needs to add the wiring:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step register -- \
|
||||
@@ -94,6 +137,16 @@ When adding another group/chat on an already-configured platform (e.g. a second
|
||||
3. Delete the old `messaging_group_agents` entry, create a new one
|
||||
4. Note: existing sessions stay with the old agent group; new messages route to the new one. The `agent_destinations` row created for the old wiring is NOT automatically removed — if you want the old agent to stop seeing the channel as a named target, delete it from `agent_destinations` manually.
|
||||
|
||||
## One-Time Check: Legacy Mis-Wired WhatsApp Groups
|
||||
|
||||
Installs that approved WhatsApp group registration cards before the channel-defaults model wired those groups as `engage_mode='pattern'`, `engage_pattern='.'` — respond-to-everything (the card flow couldn't tell groups from DMs on non-threaded platforms). Check once:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT mga.id, mg.platform_id, mg.name FROM messaging_group_agents mga JOIN messaging_groups mg ON mg.id = mga.messaging_group_id WHERE mg.channel_type='whatsapp' AND mg.is_group=1 AND mga.engage_mode='pattern' AND mga.engage_pattern='.'"
|
||||
```
|
||||
|
||||
For any hit the operator didn't deliberately configure as always-on, offer the repair options in `/add-whatsapp`'s "Migration audit" section (flip to mention/name-pattern engagement, or delete the wiring).
|
||||
|
||||
## Show Configuration
|
||||
|
||||
Display a readable summary showing:
|
||||
|
||||
@@ -194,8 +194,10 @@ Notes:
|
||||
runtime, so pass the `=>` value discovery emitted (or the raw OpenClaw id).
|
||||
- Reuse a `--folder` to put a group on an existing agent (shared base/separate
|
||||
conversations); use a new `--folder` for a fully separate agent.
|
||||
- Group chats default to mention-only; pass `--trigger` to set a regex, or
|
||||
`--no-trigger-required` for respond-to-everything.
|
||||
- Engage defaults come from the channel adapter's declaration (most group
|
||||
chats default to mention-based engagement; channels without a mention
|
||||
signal default to a name pattern). Pass `--trigger` to set an explicit
|
||||
regex, or `--no-trigger-required` for respond-to-everything.
|
||||
- Register groups from channels v2 doesn't support yet too — the messaging
|
||||
group and wiring persist and activate when that channel is installed.
|
||||
|
||||
@@ -238,10 +240,13 @@ model, which is **not** a JSON file. Each messaging group has an
|
||||
- `dmPolicy: "disabled"` → don't wire that chat (or leave it registered but
|
||||
unwired).
|
||||
|
||||
The messaging groups `register` / `init-first-agent` create already default to
|
||||
`unknown_sender_policy = 'strict'`, so unknown senders are gated until you add
|
||||
them. Show the user the OpenClaw allowlist and confirm who to grant before
|
||||
running the commands.
|
||||
The messaging groups `register` / `init-first-agent` create default their
|
||||
`unknown_sender_policy` to whatever the channel adapter declares for that
|
||||
context (DM vs group) — `strict` when the channel has no declaration — so
|
||||
unknown senders are gated until you add them (or an admin approves the
|
||||
adapter-declared approval card). Pass `--unknown-sender-policy` to `register`
|
||||
to override. Show the user the OpenClaw allowlist and confirm who to grant
|
||||
before running the commands.
|
||||
|
||||
## Phase 3: Identity and Memory
|
||||
|
||||
|
||||
@@ -281,6 +281,34 @@ Keep it to these two options — the per-skill selection lives inside
|
||||
its Step 4 — so nothing container-related is owed back here.)
|
||||
- On "Skip": note that `/update-skills` can be run anytime, then proceed.
|
||||
|
||||
## Known behavior changes when channel adapters update
|
||||
|
||||
Channel adapters now declare per-channel wiring defaults (engage mode, threading,
|
||||
sender policy). Updating trunk alone changes nothing for existing rows, but once
|
||||
`/update-skills` pulls current adapter copies, two deliberate behavior changes
|
||||
land. If the user's install has Slack, Discord, or WhatsApp, tell them:
|
||||
|
||||
1. **Slack/Discord DM replies move top-level.** Both adapters now declare
|
||||
`threads: false` for DMs, so DM replies stop chasing per-message sub-threads
|
||||
and land in the main DM view, matching the DM session (which was already
|
||||
flat). Group/channel threading is unchanged. To keep the old in-thread DM
|
||||
behavior for a specific wiring, override it per wiring:
|
||||
`ncl wirings update <wiring-id> --threads true`.
|
||||
2. **Shared-identity channels stop raising stranger approval cards.** On
|
||||
channels where the linked account is the operator's personal identity, the
|
||||
mechanics differ by channel: WhatsApp personal-number mode suppresses the
|
||||
mention signal entirely (no auto-created messaging groups, no cards);
|
||||
iMessage and WeChat still emit DM mention signals — stranger DMs still
|
||||
auto-create `messaging_groups` rows — but their declared `strict` policy
|
||||
makes those rows drop unknown senders silently instead of raising
|
||||
channel-registration cards to the admin.
|
||||
|
||||
**WhatsApp installs on a shared/personal number should re-run `/add-whatsapp`**
|
||||
after the skill update: it now asks the dedicated-vs-personal question
|
||||
explicitly (writing `ASSISTANT_HAS_OWN_NUMBER` to `.env`), audits for legacy
|
||||
mis-wired group rows from spam-era approval cards, and shows how to clear
|
||||
stale pending approvals.
|
||||
|
||||
Proceed to Step 7.9.
|
||||
|
||||
# Step 7.9: Stamp the upgrade marker (required)
|
||||
|
||||
@@ -4,6 +4,7 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
|
||||
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
|
||||
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
|
||||
@@ -77,9 +77,10 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
|
||||
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
|
||||
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
|
||||
| `src/channels/channel-defaults.ts` | Wiring-creation helpers over adapter-declared channel defaults (`resolveWiringDefaults`, `resolveThreadPolicy`, engage validation) |
|
||||
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
|
||||
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`) |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `vercel-cli`, `welcome`; channel-specific skills like `slack-formatting` and `whatsapp-formatting` install with their channel) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
|
||||
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
|
||||
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
|
||||
@@ -121,6 +122,8 @@ Trunk does not ship any specific channel adapter or non-default agent provider.
|
||||
|
||||
Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy module(s) into the standard paths → append a self-registration import to the relevant barrel → `pnpm install <pkg>@<pinned-version>` → build.
|
||||
|
||||
**Channel defaults.** Each adapter declares its wiring-time defaults (`ChannelDefaults`: per DM/group context — engage mode/pattern, thread policy, unknown-sender policy — plus mention signaling). Exactly two levels: the adapter declaration, and the per-wiring override chosen at creation — no per-instance DB config table. Undeclared (stale) adapters resolve through a behavior-faithful fallback, so a trunk update alone changes nothing. See [docs/api-details.md](docs/api-details.md#channel-defaults) and `src/channels/channel-defaults.ts`.
|
||||
|
||||
## Self-Modification
|
||||
|
||||
One tier of agent self-modification today:
|
||||
@@ -183,7 +186,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
|
||||
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. a `scripts/` CLI or helper).
|
||||
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `vercel-cli`, `welcome`; channel-specific skills like `slack-formatting` and `whatsapp-formatting` are copied in by their `/add-<channel>` skill).
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ Skills that run inside the agent container, not on the host. These teach the Nan
|
||||
|
||||
**Location:** `container/skills/<name>/`
|
||||
|
||||
**Examples:** `agent-browser` (web browsing), `frontend-engineer`, `onecli-gateway` (OneCLI proxy usage), `self-customize`, `slack-formatting` (Slack mrkdwn syntax), `vercel-cli`, `welcome`, `whatsapp-formatting`
|
||||
**Examples:** `agent-browser` (web browsing), `frontend-engineer`, `onecli-gateway` (OneCLI proxy usage), `self-customize`, `vercel-cli`, `welcome`; channel-specific: `slack-formatting` (Slack mrkdwn syntax) and `whatsapp-formatting` (channels branch; installed by `/add-slack` / `/add-whatsapp`)
|
||||
|
||||
**Key difference:** You never invoke these from a coding-agent session on the host, the way you run `/setup` or `/update-nanoclaw` in Claude Code/Codex/OpenCode. They're mounted into the sandbox and loaded by the NanoClaw agent itself, shaping how it behaves when you chat with it.
|
||||
|
||||
|
||||
@@ -92,6 +92,41 @@ agent-browser wait --url "**/dashboard" # Wait for URL pattern
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
```
|
||||
|
||||
### Waiting for a custom condition — ALWAYS bound it
|
||||
|
||||
Prefer the built-in `wait` subcommands above. Only fall back to `eval`-polling
|
||||
when you must wait on a custom JS condition (e.g. a spinner disappearing or a
|
||||
"Send" button re-enabling in a chat UI).
|
||||
|
||||
**Never write an unbounded wait loop.** A bare `until … do sleep; done` that
|
||||
polls a page condition will loop *forever* if the condition never becomes true
|
||||
(page failed to load, selector changed, network stalled). That does not just
|
||||
fail the command — it wedges the entire agent turn: the runner keeps the model
|
||||
stream open, later messages get silently swallowed, and the container can hang
|
||||
for hours without the host's stuck-detection firing.
|
||||
|
||||
Always cap the wait with BOTH a wall-clock `timeout` and a max-attempts counter,
|
||||
and always exit the loop (never leave a `sleep` loop as the last thing running):
|
||||
|
||||
```bash
|
||||
# Bounded wait: succeeds when the condition is met, gives up after ~90s.
|
||||
timeout 90 bash -c '
|
||||
for i in $(seq 1 30); do
|
||||
if agent-browser eval "document.querySelector(\".loading\") === null" 2>/dev/null | grep -q true; then
|
||||
echo READY; exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo TIMEOUT; exit 1
|
||||
'
|
||||
# Check the exit status / output: on TIMEOUT, snapshot the page and decide —
|
||||
# do NOT re-enter another unbounded wait.
|
||||
```
|
||||
|
||||
If the wait times out, treat it as a real failure: take a `snapshot -i` or
|
||||
`screenshot` to see the actual page state, report what you found, and move on.
|
||||
Retrying the same unbounded wait is what causes the hang.
|
||||
|
||||
### Semantic locators (alternative to refs)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
name: slack-formatting
|
||||
description: Format messages for Slack using mrkdwn syntax. Use when responding to Slack channels (folder starts with "slack_" or JID contains slack identifiers).
|
||||
---
|
||||
|
||||
# Slack Message Formatting (mrkdwn)
|
||||
|
||||
When responding to Slack channels, use Slack's mrkdwn syntax instead of standard Markdown.
|
||||
|
||||
## How to detect Slack context
|
||||
|
||||
Check your group folder name or workspace path:
|
||||
- Folder starts with `slack_` (e.g., `slack_engineering`, `slack_general`)
|
||||
- Or check `/workspace/group/` path for `slack_` prefix
|
||||
|
||||
## Formatting reference
|
||||
|
||||
### Text styles
|
||||
|
||||
| Style | Syntax | Example |
|
||||
|-------|--------|---------|
|
||||
| Bold | `*text*` | *bold text* |
|
||||
| Italic | `_text_` | _italic text_ |
|
||||
| Strikethrough | `~text~` | ~strikethrough~ |
|
||||
| Code (inline) | `` `code` `` | `inline code` |
|
||||
| Code block | ` ```code``` ` | Multi-line code |
|
||||
|
||||
### Links and mentions
|
||||
|
||||
```
|
||||
<https://example.com|Link text> # Named link
|
||||
<https://example.com> # Auto-linked URL
|
||||
<@U1234567890> # Mention user by ID
|
||||
<#C1234567890> # Mention channel by ID
|
||||
<!here> # @here
|
||||
<!channel> # @channel
|
||||
```
|
||||
|
||||
### Lists
|
||||
|
||||
Slack supports simple bullet lists but NOT numbered lists:
|
||||
|
||||
```
|
||||
• First item
|
||||
• Second item
|
||||
• Third item
|
||||
```
|
||||
|
||||
Use `•` (bullet character) or `- ` or `* ` for bullets.
|
||||
|
||||
### Block quotes
|
||||
|
||||
```
|
||||
> This is a block quote
|
||||
> It can span multiple lines
|
||||
```
|
||||
|
||||
### Emoji
|
||||
|
||||
Use standard emoji shortcodes: `:white_check_mark:`, `:x:`, `:rocket:`, `:tada:`
|
||||
|
||||
## What NOT to use
|
||||
|
||||
- **NO** `##` headings (use `*Bold text*` for headers instead)
|
||||
- **NO** `**double asterisks**` for bold (use `*single asterisks*`)
|
||||
- **NO** `[text](url)` links (use `<url|text>` instead)
|
||||
- **NO** `1.` numbered lists (use bullets with numbers: `• 1. First`)
|
||||
- **NO** tables (use code blocks or plain text alignment)
|
||||
- **NO** `---` horizontal rules
|
||||
|
||||
## Example message
|
||||
|
||||
```
|
||||
*Daily Standup Summary*
|
||||
|
||||
_March 21, 2026_
|
||||
|
||||
• *Completed:* Fixed authentication bug in login flow
|
||||
• *In Progress:* Building new dashboard widgets
|
||||
• *Blocked:* Waiting on API access from DevOps
|
||||
|
||||
> Next sync: Monday 10am
|
||||
|
||||
:white_check_mark: All tests passing | <https://ci.example.com/builds/123|View Build>
|
||||
```
|
||||
|
||||
## Quick rules
|
||||
|
||||
1. Use `*bold*` not `**bold**`
|
||||
2. Use `<url|text>` not `[text](url)`
|
||||
3. Use `•` bullets, avoid numbered lists
|
||||
4. Use `:emoji:` shortcodes
|
||||
5. Quote blocks with `>`
|
||||
6. Skip headings — use bold text instead
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
name: whatsapp-formatting
|
||||
description: Format messages for WhatsApp, including mentions that render as real WhatsApp tags. Use when responding in a WhatsApp conversation (platform_id / chatJid ends with @s.whatsapp.net or @g.us).
|
||||
---
|
||||
|
||||
# WhatsApp Message Formatting
|
||||
|
||||
WhatsApp uses its own lightweight markup and a phone-number-based mention syntax. The host's WhatsApp adapter (Baileys) handles markdown conversion automatically, but **mentions are only protocol-level mentions if you use the right syntax** — otherwise they render as plain text and don't notify the recipient.
|
||||
|
||||
## How to detect WhatsApp context
|
||||
|
||||
You're in a WhatsApp conversation when any of these are true:
|
||||
- The chat JID / platform id ends with `@s.whatsapp.net` (1-on-1 DM)
|
||||
- The chat JID / platform id ends with `@g.us` (group)
|
||||
- Your inbound message metadata has `chatJid` matching the above
|
||||
|
||||
## Mentions — the important part
|
||||
|
||||
To tag a user so their name appears **bold and clickable** in WhatsApp and they get a push notification, write the `@` followed by their phone number digits (no `+`, no spaces, no display name):
|
||||
|
||||
```
|
||||
@15551234567 can you confirm?
|
||||
```
|
||||
|
||||
The adapter scans your outgoing text for `@<digits>` (5–15 digits, optional leading `+` is stripped) and tells WhatsApp to render them as real mention tags.
|
||||
|
||||
**The sender's phone JID is always in your inbound message metadata.** When a user writes to you, inbound `content.sender` looks like `15551234567@s.whatsapp.net`. The part before the `@` is exactly what you put after `@` when tagging them back.
|
||||
|
||||
### Wrong vs right
|
||||
|
||||
| You write | What recipients see |
|
||||
|-----------|---------------------|
|
||||
| `@Adam can you...` | Plain text `@Adam`. No tag, no notification. |
|
||||
| `@15551234567 can you...` | Bold/blue **@Adam** (or whatever name they're saved as), notification fires. |
|
||||
| `@+15551234567 ...` | Same as above — adapter strips the `+`. |
|
||||
|
||||
### Picking who to tag
|
||||
|
||||
- In a DM, there's no real need to tag the recipient (they already see every message), but tagging still works if you want emphasis.
|
||||
- In a group, look at the `participants` / inbound `content.sender` to find the JID of the person you mean. Don't guess from display names — pushNames can collide and are not reliable.
|
||||
- If you don't know the JID, just refer to the person by name in plain prose. Don't write `@<name>` — it won't tag and it will look like a tag that failed.
|
||||
|
||||
## Text styles
|
||||
|
||||
WhatsApp uses single-character delimiters, *not* doubled like standard Markdown.
|
||||
|
||||
| Style | Syntax | Renders as |
|
||||
|-------|--------|------------|
|
||||
| Bold | `*bold*` | **bold** |
|
||||
| Italic | `_italic_` | *italic* |
|
||||
| Strikethrough | `~strike~` | ~strike~ |
|
||||
| Monospace | `` `code` `` | `code` |
|
||||
| Block monospace | ```` ```block``` ```` | preformatted block |
|
||||
|
||||
The adapter converts standard Markdown (`**bold**`, `[link](url)`, `# heading`) to the WhatsApp-native form automatically, so you don't have to think about it — but be aware that single asterisks become italics, not bold.
|
||||
|
||||
## What not to do
|
||||
|
||||
- Don't write `<@U123>` (that's Slack), `<@!123>` (Discord), or any other channel's mention syntax.
|
||||
- Don't paste a full JID like `@15551234567@s.whatsapp.net` in the text — only the digits before the JID's `@` go after your `@`.
|
||||
- Don't try to tag display names. WhatsApp has no display-name-based mention API.
|
||||
@@ -1,19 +0,0 @@
|
||||
## WhatsApp mentions — always use phone digits
|
||||
|
||||
When you are replying in a WhatsApp conversation (the inbound message's `chatJid` ends with `@s.whatsapp.net` for a DM or `@g.us` for a group), and you want to tag a person so their name appears **bold and clickable** with a push notification, write `@` followed by their phone-number digits — never the display name.
|
||||
|
||||
**The sender's phone JID is in your inbound message metadata** at `content.sender` (e.g. `15551234567@s.whatsapp.net`). The part before the `@` is exactly what you put after `@` when tagging them.
|
||||
|
||||
| You write | What recipients see |
|
||||
|-----------|---------------------|
|
||||
| `@Adam, can you...` | Plain text. No tag, no notification. |
|
||||
| `@15551234567, can you...` | Bold/blue **@Adam** (whatever name they're saved as), notification fires. |
|
||||
| `@+15551234567 ...` | Same as above — the adapter strips the `+` automatically. |
|
||||
|
||||
The host adapter scans your outbound text for `@<5–15 digits>` (with optional leading `+`) and tells WhatsApp to render those as real mention tags. If the digits aren't in the text, the tag doesn't render — no exceptions.
|
||||
|
||||
### In groups
|
||||
|
||||
Tag the person you're addressing using their JID from inbound metadata (look at the most recent message from them). Don't guess — pushNames collide and aren't reliable.
|
||||
|
||||
If you don't know someone's JID, refer to them by name in plain prose. Do not write `@<displayname>` hoping it works.
|
||||
@@ -59,6 +59,52 @@ interface OutboundMessage {
|
||||
}
|
||||
```
|
||||
|
||||
### Channel Defaults
|
||||
|
||||
Each adapter can declare static wiring-time defaults. There are exactly two levels: the adapter declaration, and the per-wiring/per-messaging-group values chosen at creation. There is no DB config table for defaults — install-wide changes mean editing the adapter copy (skill-installed, user-owned).
|
||||
|
||||
```typescript
|
||||
// src/channels/adapter.ts
|
||||
interface ChannelContextDefaults {
|
||||
engageMode: 'pattern' | 'mention' | 'mention-sticky';
|
||||
engagePattern?: string; // required iff engageMode='pattern'; may contain the
|
||||
// literal token {name} — creation helpers substitute the
|
||||
// regex-escaped agent_group name
|
||||
threads: boolean; // whether thread ids are honored in this context by default;
|
||||
// must be false when the adapter's supportsThreads is false
|
||||
unknownSenderPolicy: 'strict' | 'request_approval' | 'public';
|
||||
}
|
||||
|
||||
interface ChannelDefaults {
|
||||
dm: ChannelContextDefaults;
|
||||
group: ChannelContextDefaults;
|
||||
mentions: 'platform' | 'dm-only' | 'never';
|
||||
// 'platform' — platform-confirmed mentions in groups, DMs flagged too
|
||||
// 'dm-only' — only DMs flagged (no group mention metadata)
|
||||
// 'never' — isMention never set: no auto-create card, mention wirings never engage
|
||||
}
|
||||
|
||||
// ChannelAdapter and ChannelRegistration both carry an optional `defaults` field.
|
||||
// The registration-level copy lets offline creation paths (setup/register.ts,
|
||||
// scripts/init-first-agent.ts) resolve declarations without instantiating the
|
||||
// adapter. ChatSdkBridgeConfig.defaults is copied verbatim onto the bridged
|
||||
// adapter, like supportsThreads.
|
||||
```
|
||||
|
||||
**Resolution chain** (`getChannelDefaults(key, channelType?)` in `src/channels/channel-registry.ts`, key = `mg.instance ?? mg.channel_type`, same discipline as `getChannelAdapter`):
|
||||
|
||||
1. Live adapter's `defaults`, instance-exact (lets an instance carry env-computed declarations, e.g. WhatsApp shared-number mode)
|
||||
2. Live adapter of that channelType
|
||||
3. Registration entry under the key
|
||||
4. Registration entry under the channelType (from the live adapter's channelType, else the caller-supplied hint)
|
||||
5. `fallbackChannelDefaults(supportsThreads)` — behavior-faithful to the pre-declaration router: dm `{ pattern '.', threads: supportsThreads, request_approval }`, group `{ mention-sticky, threads: supportsThreads, request_approval }`, mentions `'platform'`. `supportsThreads` is `false` when no adapter is live.
|
||||
|
||||
Never returns undefined. `hasDeclaredChannelDefaults()` reports whether tiers 1–4 hit; manual creation surfaces (`ncl`) gate declaration-derived defaults on it so stale (undeclared) adapters keep the legacy static schema defaults — a trunk update alone changes no behavior.
|
||||
|
||||
**Creation helpers** (`src/channels/channel-defaults.ts`): every wiring-creation path calls `resolveWiringDefaults(channelKey, isGroup, agentGroupName)` — it picks `decl.group` vs `decl.dm` by `isGroup = event.message.isGroup ?? (mg.is_group === 1)` (never `threadId !== null`), substitutes `{name}`, and downgrades `mention-sticky` → `mention` when the context's resolved threads value is false. `resolveUnknownSenderPolicy` does the same for auto-created messaging_groups rows.
|
||||
|
||||
**Runtime thread policy**: engage mode and sender policy are creation-time snapshots; threading is the one setting consulted live. `messaging_group_agents.threads` (migration 019) is the per-wiring override: `NULL` = inherit the adapter declaration for the wiring's context, `1`/`0` = explicit. `resolveThreadPolicy(wiring.threads, decl, isGroup, supportsThreads)` hard-ANDs the result with the adapter's raw capability — a wiring can opt out of threads on a threaded platform, never opt in on a non-threaded one. When the policy is off, event-derived thread ids are nulled at router fanout (sessions collapse, replies land top-level); `event.replyTo` is operator intent from the CLI transport and is never nulled.
|
||||
|
||||
### Chat SDK Bridge
|
||||
|
||||
Wraps a Chat SDK adapter + Chat instance to conform to the NanoClaw ChannelAdapter interface. Trunk ships the bridge and the channel registry only — platform-specific Chat SDK adapters (Discord, Slack, Telegram, etc.) and native adapters (WhatsApp/Baileys) are installed by the `/add-<channel>` skills from the `channels` branch.
|
||||
|
||||
@@ -80,9 +80,11 @@ agent_groups (workspace, memory, CLAUDE.md, personality)
|
||||
↕ many-to-many
|
||||
messaging_groups (a specific channel/chat/group on a platform)
|
||||
via
|
||||
messaging_group_agents (session_mode, engage_mode, engage_pattern, sender_scope, ignored_message_policy, priority)
|
||||
messaging_group_agents (session_mode, engage_mode, engage_pattern, sender_scope, ignored_message_policy, priority, threads)
|
||||
```
|
||||
|
||||
Wiring-creation defaults for engage mode/pattern, thread policy, and unknown-sender policy come from the channel adapter's declaration (per DM/group context), overridable per wiring at creation — see [setup-wiring.md](setup-wiring.md#channel-defaults-two-level-model) and [api-details.md](api-details.md#channel-defaults).
|
||||
|
||||
- **Shared session:** multiple messaging_groups → same agent_group, `session_mode = 'agent-shared'`
|
||||
- **Same agent, separate sessions:** multiple messaging_groups → same agent_group, `session_mode = 'shared'`
|
||||
- **Separate agents:** each messaging_group → different agent_group
|
||||
|
||||
+15
-2
@@ -1,6 +1,6 @@
|
||||
# Setup Wiring — Status & Remaining Work
|
||||
|
||||
Last updated: 2026-04-09
|
||||
Last updated: 2026-07-10
|
||||
|
||||
## What's Done
|
||||
|
||||
@@ -35,6 +35,19 @@ Last updated: 2026-04-09
|
||||
### Router Logging
|
||||
- `src/router.ts` logs `MESSAGE DROPPED` at WARN level when no agents wired, with actionable guidance
|
||||
|
||||
### Channel Defaults (two-level model)
|
||||
|
||||
Each adapter declares its own wiring-time defaults (`ChannelDefaults`, see [api-details.md](api-details.md#channel-defaults)): per-context (DM vs group) engage mode, engage pattern, thread policy, and unknown-sender policy, plus how the platform signals mentions (`'platform' | 'dm-only' | 'never'`). Exactly two levels exist:
|
||||
|
||||
1. **Adapter declaration** — a static const in the adapter module (and its `ChannelRegistration`, so offline scripts resolve it without credentials). Adapters are skill-installed and user-owned; install-wide changes mean editing the adapter copy. No DB config table.
|
||||
2. **Per-wiring override** — the explicit value chosen at creation (`ncl` flag, wizard answer, card-flow value), stored on the row. Existing rows are never re-resolved; declarations are consulted only at creation, except threading, which stays live via `messaging_group_agents.threads` (`NULL` = inherit).
|
||||
|
||||
All creation paths (`ncl wirings`/`messaging-groups`, `setup/register.ts`, the router's auto-create, the channel-approval card flow, bootstrap scripts) go through the shared helpers in `src/channels/channel-defaults.ts`, so a platform's defaults are declared once and apply everywhere.
|
||||
|
||||
**Shared-identity pattern.** When the platform identity the adapter connects as belongs to a human (WhatsApp shared-number mode: `ASSISTANT_HAS_OWN_NUMBER` unset), the adapter itself suppresses mention signals — it never sets `isMention` and declares `mentions: 'never'`, group defaults of a name-pattern (`\b{name}\b`) and `strict` sender policy. With no mention signal, the router never auto-creates messaging groups or fires approval cards for the human's own conversations — the spam dies at the source, with zero core conditionals. The pattern is entirely channel-local and reusable by any adapter riding a personal identity (iMessage, Signal) with no core involvement.
|
||||
|
||||
**Back-compat contract.** A trunk update alone changes no behavior: stale (undeclared) adapters resolve through a behavior-faithful fallback at runtime, `ncl` keeps its legacy static defaults for them (gated on `hasDeclaredChannelDefaults`), existing DB rows are untouched, and the new `threads` column ships `NULL` everywhere — which reproduces today's `supportsThreads`-derived routing exactly. Updating an adapter copy (via its `/add-<channel>` skill) is what opts an install into that channel's new defaults, and even then only for wirings created afterwards. The one deliberate exception is the isGroup bugfix: card-approved groups on non-threaded platforms now wire via the group default instead of pattern `'.'` (group-ness comes from `event.message.isGroup ?? mg.is_group`, never `threadId !== null`).
|
||||
|
||||
---
|
||||
|
||||
## Previously Open — Now Resolved
|
||||
@@ -69,7 +82,7 @@ agent_groups (id, name, folder, agent_provider)
|
||||
↕ many-to-many (container runtime config lives in the separate container_configs table)
|
||||
messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, denied_at)
|
||||
via
|
||||
messaging_group_agents (messaging_group_id, agent_group_id, engage_mode, engage_pattern, sender_scope, ignored_message_policy, session_mode, priority)
|
||||
messaging_group_agents (messaging_group_id, agent_group_id, engage_mode, engage_pattern, sender_scope, ignored_message_policy, session_mode, priority, threads)
|
||||
|
||||
users (id, kind, display_name) -- namespaced as "<channel>:<handle>"
|
||||
user_roles (user_id, role, agent_group_id) -- owner / admin (global or scoped)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.43",
|
||||
"version": "2.1.46",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="224k tokens, 112% of context window">
|
||||
<title>224k tokens, 112% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="232k tokens, 116% of context window">
|
||||
<title>232k tokens, 116% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">224k</text>
|
||||
<text x="71" y="14">224k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">232k</text>
|
||||
<text x="71" y="14">232k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -10,7 +10,8 @@
|
||||
* CLAUDE.md.
|
||||
*
|
||||
* Runs alongside the service (WAL-mode sqlite) — does NOT initialize
|
||||
* channel adapters, so there's no Gateway conflict.
|
||||
* channel adapters, so there's no Gateway conflict. (The channels barrel
|
||||
* import below only registers factories + declarations; nothing connects.)
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/init-cli-agent.ts \
|
||||
@@ -19,6 +20,13 @@
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
// Registration-only: makes the in-tree cli adapter's declared defaults
|
||||
// (pattern '.', no threads, 'public') resolvable below.
|
||||
import '../src/channels/index.js';
|
||||
import {
|
||||
resolveUnknownSenderPolicy,
|
||||
resolveWiringDefaults,
|
||||
} from '../src/channels/channel-defaults.js';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
||||
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
|
||||
@@ -139,7 +147,9 @@ async function main(): Promise<void> {
|
||||
platform_id: CLI_PLATFORM_ID,
|
||||
name: 'Local CLI',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'public',
|
||||
// cli declares 'public' for DMs: the socket is chmod 0600, so
|
||||
// "connected" ≈ "is the owner".
|
||||
unknown_sender_policy: resolveUnknownSenderPolicy(CLI_CHANNEL, false),
|
||||
created_at: now,
|
||||
};
|
||||
createMessagingGroup(cliMg);
|
||||
@@ -148,12 +158,15 @@ async function main(): Promise<void> {
|
||||
|
||||
const existing = getMessagingGroupAgentByPair(cliMg.id, ag.id);
|
||||
if (!existing) {
|
||||
// cli declares pattern '.' for DMs — every line the operator types is
|
||||
// for the agent. Identical to the pre-declaration hardcodes.
|
||||
const engage = resolveWiringDefaults(CLI_CHANNEL, false, ag.name);
|
||||
createMessagingGroupAgent({
|
||||
id: generateId('mga'),
|
||||
messaging_group_id: cliMg.id,
|
||||
agent_group_id: ag.id,
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
engage_mode: engage.engage_mode,
|
||||
engage_pattern: engage.engage_pattern,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
* --display-name "Gavriel" \
|
||||
* [--agent-name "Andy"] \
|
||||
* [--welcome "System instruction: ..."] \
|
||||
* [--role owner|admin|member] # default: owner
|
||||
* [--role owner|admin|member] \ # default: owner
|
||||
* [--engage-pattern "."] # explicit DM engage regex override
|
||||
*
|
||||
* For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
|
||||
* is typically the same as the handle in --user-id, with the channel prefix.
|
||||
@@ -34,6 +35,16 @@ import fs from 'fs';
|
||||
import net from 'net';
|
||||
import path from 'path';
|
||||
|
||||
// Registration-only barrel import: channel modules call
|
||||
// registerChannelAdapter() at module scope (factories are NOT invoked, no
|
||||
// adapter connects — no Gateway conflict with the running service), so
|
||||
// declared channel defaults resolve here without live adapters.
|
||||
import '../src/channels/index.js';
|
||||
import {
|
||||
resolveUnknownSenderPolicy,
|
||||
resolveWiringDefaults,
|
||||
} from '../src/channels/channel-defaults.js';
|
||||
import { hasDeclaredChannelDefaults } from '../src/channels/channel-registry.js';
|
||||
import { DATA_DIR, GROUPS_DIR } from '../src/config.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
@@ -62,6 +73,8 @@ interface Args {
|
||||
agentName: string;
|
||||
welcome: string;
|
||||
role: Role;
|
||||
/** Explicit engage regex for the DM wiring; omitted = channel declaration / '.'. */
|
||||
engagePattern?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_WELCOME =
|
||||
@@ -99,6 +112,10 @@ function parseArgs(argv: string[]): Args {
|
||||
out.welcome = val;
|
||||
i++;
|
||||
break;
|
||||
case '--engage-pattern':
|
||||
out.engagePattern = val;
|
||||
i++;
|
||||
break;
|
||||
case '--role': {
|
||||
const raw = (val ?? '').toLowerCase();
|
||||
if (raw !== 'owner' && raw !== 'admin' && raw !== 'member') {
|
||||
@@ -132,6 +149,7 @@ function parseArgs(argv: string[]): Args {
|
||||
agentName: out.agentName?.trim() || out.displayName!,
|
||||
welcome: out.welcome?.trim() || DEFAULT_WELCOME,
|
||||
role: out.role ?? DEFAULT_ROLE,
|
||||
engagePattern: out.engagePattern?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,21 +161,41 @@ function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: string): void {
|
||||
function wireIfMissing(
|
||||
mg: MessagingGroup,
|
||||
ag: AgentGroup,
|
||||
now: string,
|
||||
label: string,
|
||||
engagePattern?: string,
|
||||
): void {
|
||||
const existing = getMessagingGroupAgentByPair(mg.id, ag.id);
|
||||
if (existing) {
|
||||
console.log(`Wiring already exists: ${existing.id} (${label})`);
|
||||
return;
|
||||
}
|
||||
// Engage defaults, first hit wins: explicit --engage-pattern → the
|
||||
// channel's declared defaults → the legacy heuristic for stale
|
||||
// (undeclared) adapters: DMs (is_group=0) respond to everything via a '.'
|
||||
// regex, group chats are mention-only; admins can reconfigure via
|
||||
// /manage-channels once the agent is in use.
|
||||
const isGroup = mg.is_group === 1;
|
||||
const channelKey = mg.instance ?? mg.channel_type;
|
||||
const engage = engagePattern
|
||||
? { engage_mode: 'pattern' as const, engage_pattern: engagePattern }
|
||||
: hasDeclaredChannelDefaults(channelKey, mg.channel_type)
|
||||
? resolveWiringDefaults(channelKey, isGroup, ag.name, mg.channel_type)
|
||||
: isGroup
|
||||
? { engage_mode: 'mention' as const, engage_pattern: null }
|
||||
: { engage_mode: 'pattern' as const, engage_pattern: '.' };
|
||||
createMessagingGroupAgent({
|
||||
id: generateId('mga'),
|
||||
messaging_group_id: mg.id,
|
||||
agent_group_id: ag.id,
|
||||
// DM / CLI (is_group=0) default to "respond to everything" via a '.' regex.
|
||||
// Group chats default to mention-only; admins can upgrade to mention-sticky
|
||||
// via /manage-channels once the agent is in use.
|
||||
engage_mode: mg.is_group === 0 ? 'pattern' : 'mention',
|
||||
engage_pattern: mg.is_group === 0 ? '.' : null,
|
||||
engage_mode: engage.engage_mode,
|
||||
engage_pattern: engage.engage_pattern,
|
||||
// Deliberate owner-bootstrap choices, not channel defaults: the operator
|
||||
// wires their own DM, so every sender is trusted ('all') and ignored
|
||||
// messages carry no value ('drop').
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
@@ -277,13 +315,18 @@ async function main(): Promise<void> {
|
||||
let dmMg = getMessagingGroupByPlatform(args.channel, platformId);
|
||||
if (!dmMg) {
|
||||
const mgId = generateId('mg');
|
||||
// Policy from the channel declaration (DM context); legacy 'strict' for
|
||||
// stale (undeclared) adapters so a trunk update alone changes nothing.
|
||||
const unknownSenderPolicy = hasDeclaredChannelDefaults(args.channel)
|
||||
? resolveUnknownSenderPolicy(args.channel, false)
|
||||
: 'strict';
|
||||
createMessagingGroup({
|
||||
id: mgId,
|
||||
channel_type: args.channel,
|
||||
platform_id: platformId,
|
||||
name: args.displayName,
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'strict',
|
||||
unknown_sender_policy: unknownSenderPolicy,
|
||||
created_at: now,
|
||||
});
|
||||
dmMg = getMessagingGroupByPlatform(args.channel, platformId)!;
|
||||
@@ -293,7 +336,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// 4. Wire DM messaging group to the agent.
|
||||
wireIfMissing(dmMg, ag, now, 'dm');
|
||||
wireIfMissing(dmMg, ag, now, 'dm', args.engagePattern);
|
||||
|
||||
// 5. Welcome delivery over the CLI socket. Router picks up the line,
|
||||
// writes the message into the DM session's inbound.db, and wakes the
|
||||
|
||||
@@ -60,6 +60,9 @@ try {
|
||||
agent_group_id: AGENT_GROUP_ID,
|
||||
// Discord group channel → mention-sticky default. Mention once, stay
|
||||
// subscribed to the thread. Admins can tune via /manage-channels.
|
||||
// Kept manually in sync with DISCORD_DEFAULTS.group on the channels
|
||||
// branch (the adapter is skill-installed, so the declaration can't be
|
||||
// imported here) — re-check this hardcode if that declaration changes.
|
||||
engage_mode: 'mention-sticky',
|
||||
engage_pattern: null,
|
||||
sender_scope: 'all',
|
||||
|
||||
@@ -51,6 +51,7 @@ fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/slack.ts ] && return 0
|
||||
[ ! -f container/skills/slack-formatting/SKILL.md ] && return 0
|
||||
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
@@ -67,6 +68,10 @@ if need_install; then
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
|
||||
|
||||
# Slack formatting container skill — reaches agents via ~/.claude/skills.
|
||||
mkdir -p container/skills/slack-formatting
|
||||
git show "${CHANNELS_BRANCH}:container/skills/slack-formatting/SKILL.md" > container/skills/slack-formatting/SKILL.md
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
|
||||
echo "import './slack.js';" >> src/channels/index.ts
|
||||
|
||||
@@ -43,6 +43,7 @@ log() { echo "[add-whatsapp] $*" >&2; }
|
||||
need_install() {
|
||||
[ ! -f src/channels/whatsapp.ts ] && return 0
|
||||
[ ! -f setup/groups.ts ] && return 0
|
||||
[ ! -f container/skills/whatsapp-formatting/instructions.md ] && return 0
|
||||
! grep -q "^import './whatsapp.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
! grep -q "'whatsapp-auth':" setup/index.ts 2>/dev/null && return 0
|
||||
! grep -q "^ groups:" setup/index.ts 2>/dev/null && return 0
|
||||
@@ -64,6 +65,12 @@ if need_install; then
|
||||
git show "${CHANNELS_BRANCH}:src/channels/whatsapp.ts" > src/channels/whatsapp.ts
|
||||
git show "${CHANNELS_BRANCH}:setup/groups.ts" > setup/groups.ts
|
||||
|
||||
# WhatsApp formatting container skill — feeds the composed CLAUDE.md
|
||||
# (skill-whatsapp-formatting.md fragment) and ~/.claude/skills.
|
||||
mkdir -p container/skills/whatsapp-formatting
|
||||
git show "${CHANNELS_BRANCH}:container/skills/whatsapp-formatting/SKILL.md" > container/skills/whatsapp-formatting/SKILL.md
|
||||
git show "${CHANNELS_BRANCH}:container/skills/whatsapp-formatting/instructions.md" > container/skills/whatsapp-formatting/instructions.md
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './whatsapp.js';" src/channels/index.ts; then
|
||||
echo "import './whatsapp.js';" >> src/channels/index.ts
|
||||
|
||||
+1
-1
@@ -1236,7 +1236,7 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
||||
options: [
|
||||
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||||
{ value: 'discord', label: 'Yes, connect Discord' },
|
||||
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
|
||||
{ value: 'whatsapp', label: 'Yes, connect WhatsApp', hint: 'best with a dedicated number' },
|
||||
{
|
||||
value: 'signal',
|
||||
label: 'Yes, connect Signal',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
brightSelect: vi.fn(),
|
||||
note: vi.fn(),
|
||||
userInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/bright-select.js', () => ({
|
||||
brightSelect: mocks.brightSelect,
|
||||
}));
|
||||
|
||||
vi.mock('../lib/theme.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../lib/theme.js')>()),
|
||||
note: mocks.note,
|
||||
}));
|
||||
|
||||
vi.mock('../logs.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../logs.js')>()),
|
||||
userInput: mocks.userInput,
|
||||
}));
|
||||
|
||||
import { BACK_TO_CHANNEL_SELECTION } from '../lib/back-nav.js';
|
||||
import { runWhatsAppChannel } from './whatsapp.js';
|
||||
|
||||
describe('WhatsApp shared-number risk gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows the warning and requires explicit acknowledgement for a shared number', async () => {
|
||||
mocks.brightSelect.mockResolvedValueOnce('shared').mockResolvedValueOnce('continue').mockResolvedValueOnce('back');
|
||||
|
||||
const result = await runWhatsAppChannel('Daniel');
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(mocks.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining('temporarily suspend or permanently ban that number'),
|
||||
'Risk to your WhatsApp account',
|
||||
);
|
||||
expect(mocks.userInput).toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', 'true');
|
||||
});
|
||||
|
||||
it('does not show the warning for a dedicated number', async () => {
|
||||
mocks.brightSelect.mockResolvedValueOnce('dedicated').mockResolvedValueOnce('back');
|
||||
|
||||
const result = await runWhatsAppChannel('Daniel');
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(mocks.note).not.toHaveBeenCalled();
|
||||
expect(mocks.userInput).not.toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', expect.anything());
|
||||
});
|
||||
|
||||
it('switches to dedicated mode when the user declines the shared-number risk', async () => {
|
||||
mocks.brightSelect.mockResolvedValueOnce('shared').mockResolvedValueOnce('dedicated').mockResolvedValueOnce('back');
|
||||
|
||||
const result = await runWhatsAppChannel('Daniel');
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(mocks.note).toHaveBeenCalledOnce();
|
||||
expect(mocks.userInput).not.toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { selfChatEngagePattern, writeEnvVar } from './whatsapp.js';
|
||||
|
||||
describe('selfChatEngagePattern', () => {
|
||||
it('matches messages starting with @<name> and nothing else', () => {
|
||||
const re = new RegExp(selfChatEngagePattern('Nano'));
|
||||
expect(re.test('@Nano what time is it?')).toBe(true);
|
||||
expect(re.test('@Nano')).toBe(true);
|
||||
expect(re.test('hey @Nano')).toBe(false);
|
||||
expect(re.test('grocery list')).toBe(false);
|
||||
// \b guard: name must end at a word boundary, not prefix a longer word.
|
||||
expect(re.test('@Nanobot hello')).toBe(false);
|
||||
});
|
||||
|
||||
it('escapes regex metacharacters in the agent name', () => {
|
||||
const re = new RegExp(selfChatEngagePattern('C-3PO (backup)'));
|
||||
expect(re.test('@C-3PO (backup) status?')).toBe(true);
|
||||
expect(re.test('@C-3PO Xbackup) status?')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the trailing \\b for names ending in non-word characters', () => {
|
||||
const pattern = selfChatEngagePattern('Nano!');
|
||||
expect(pattern.endsWith('\\b')).toBe(false);
|
||||
expect(new RegExp(pattern).test('@Nano! do the thing')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeEnvVar', () => {
|
||||
let dir: string;
|
||||
let envPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-'));
|
||||
envPath = path.join(dir, '.env');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates the file when missing', () => {
|
||||
writeEnvVar('ASSISTANT_NAME', 'Nano', envPath);
|
||||
expect(fs.readFileSync(envPath, 'utf-8')).toBe('ASSISTANT_NAME=Nano\n');
|
||||
});
|
||||
|
||||
it('appends to an existing file, adding a newline if needed', () => {
|
||||
fs.writeFileSync(envPath, 'TZ=UTC');
|
||||
writeEnvVar('ASSISTANT_HAS_OWN_NUMBER', 'true', envPath);
|
||||
expect(fs.readFileSync(envPath, 'utf-8')).toBe(
|
||||
'TZ=UTC\nASSISTANT_HAS_OWN_NUMBER=true\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces an existing line in place without touching neighbors', () => {
|
||||
fs.writeFileSync(envPath, 'ASSISTANT_NAME=Andy\nTZ=UTC\n');
|
||||
writeEnvVar('ASSISTANT_NAME', 'Nano', envPath);
|
||||
expect(fs.readFileSync(envPath, 'utf-8')).toBe('ASSISTANT_NAME=Nano\nTZ=UTC\n');
|
||||
});
|
||||
|
||||
it('keeps $-sequences in the value literal', () => {
|
||||
fs.writeFileSync(envPath, 'ASSISTANT_NAME=Andy\n');
|
||||
writeEnvVar('ASSISTANT_NAME', "$& $' $1", envPath);
|
||||
expect(fs.readFileSync(envPath, 'utf-8')).toBe("ASSISTANT_NAME=$& $' $1\n");
|
||||
});
|
||||
});
|
||||
+199
-45
@@ -1,24 +1,32 @@
|
||||
/**
|
||||
* WhatsApp (community/Baileys) channel flow for setup:auto.
|
||||
*
|
||||
* `runWhatsAppChannel(displayName)` owns the full branch from auth-method
|
||||
* picker through the welcome DM:
|
||||
* `runWhatsAppChannel(displayName)` owns the full branch from number-
|
||||
* ownership picker through the welcome DM:
|
||||
*
|
||||
* 1. Ask how to authenticate (QR code in terminal, default, or pairing code)
|
||||
* 2. If pairing-code: collect the phone number
|
||||
* 3. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
|
||||
* 4. Run the whatsapp-auth step, rendering status blocks as clack UI:
|
||||
* 1. Ask whether the agent gets a dedicated number or shares the
|
||||
* operator's personal one. Personal ⇒ interception screen that spells
|
||||
* out the account-ban risk and self-chat-only mode (default is switching
|
||||
* to a dedicated number)
|
||||
* 2. Ask how to authenticate (QR code in terminal, default, or pairing code)
|
||||
* 3. If pairing-code: collect the phone number
|
||||
* 4. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
|
||||
* 5. Run the whatsapp-auth step, rendering status blocks as clack UI:
|
||||
* - WHATSAPP_AUTH_QR (repeating): render the QR as terminal block art
|
||||
* inside a clack note. On rotation we clear the previous QR in-place
|
||||
* via ANSI escapes so the terminal doesn't fill up with stale codes.
|
||||
* - WHATSAPP_AUTH_PAIRING_CODE (one-shot): centred code card.
|
||||
* 5. Read store/auth/creds.json → extract the authenticated (bot) phone
|
||||
* 6. Kick the service so the adapter picks up the new credentials
|
||||
* 7. Ask the operator for the phone they'll chat from (defaults to the
|
||||
* authed number). Different number ⇒ dedicated mode ⇒ also writes
|
||||
* ASSISTANT_HAS_OWN_NUMBER=true so outbound replies aren't prefixed
|
||||
* 8. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 9. Wire the agent via scripts/init-first-agent.ts; the existing welcome
|
||||
* 6. Read store/auth/creds.json → extract the authenticated (bot) phone
|
||||
* 7. Dedicated: ask for the operator's personal number (the one they'll
|
||||
* chat from) and write ASSISTANT_HAS_OWN_NUMBER=true so outbound
|
||||
* replies aren't prefixed. Entering the bot's own number routes back
|
||||
* through the interception screen. Shared: chat number = bot number
|
||||
* 8. Ask for the messaging-agent name (defaulting to "Nano"); persist it
|
||||
* as ASSISTANT_NAME so the adapter's outbound prefix matches. Shared
|
||||
* mode also offers an @<name>-only engage pattern for the self-chat
|
||||
* 9. Kick the service — AFTER the env writes, since the adapter reads
|
||||
* ASSISTANT_HAS_OWN_NUMBER / ASSISTANT_NAME once at module load
|
||||
* 10. Wire the agent via scripts/init-first-agent.ts; the existing welcome
|
||||
* DM path delivers the greeting through the adapter
|
||||
*
|
||||
* All output obeys the three-level contract: clack UI for the user, structured
|
||||
@@ -55,6 +63,14 @@ const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
|
||||
type AuthMethod = 'qr' | 'pairing-code';
|
||||
|
||||
export async function runWhatsAppChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const ownership = await askNumberOwnership();
|
||||
if (ownership === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
let mode: 'dedicated' | 'shared' = ownership;
|
||||
if (mode === 'shared') {
|
||||
const proceed = await confirmSharedNumber();
|
||||
if (proceed === 'dedicated') mode = 'dedicated';
|
||||
}
|
||||
|
||||
const method = await askAuthMethod();
|
||||
if (method === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined;
|
||||
@@ -98,18 +114,35 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
);
|
||||
}
|
||||
|
||||
await restartService();
|
||||
|
||||
const chatPhone = await askChatPhone(botPhone);
|
||||
const isDedicated = chatPhone !== botPhone;
|
||||
if (isDedicated) {
|
||||
writeAssistantHasOwnNumber();
|
||||
let chatPhone = botPhone;
|
||||
if (mode === 'dedicated') {
|
||||
chatPhone = await askChatPhone(botPhone);
|
||||
if (chatPhone === botPhone) {
|
||||
// Chatting from the bot's own number IS the shared-number setup —
|
||||
// route through the same interception screen as the up-front pick.
|
||||
const proceed = await confirmSharedNumber();
|
||||
if (proceed === 'dedicated') return BACK_TO_CHANNEL_SELECTION;
|
||||
mode = 'shared';
|
||||
}
|
||||
}
|
||||
// Written in both modes so a re-run that switches dedicated → shared
|
||||
// doesn't leave a stale `true` behind.
|
||||
writeEnvVar('ASSISTANT_HAS_OWN_NUMBER', mode === 'dedicated' ? 'true' : 'false');
|
||||
|
||||
const role = await askOperatorRole('WhatsApp');
|
||||
setupLog.userInput('whatsapp_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
// Both modes: keep the adapter's outbound prefix / mention normalization
|
||||
// in sync with the chosen agent name (config default is 'Andy' otherwise).
|
||||
writeEnvVar('ASSISTANT_NAME', agentName);
|
||||
|
||||
const engagePattern = mode === 'shared' ? await askSelfChatEngage(agentName) : undefined;
|
||||
|
||||
// Restart only after ASSISTANT_HAS_OWN_NUMBER / ASSISTANT_NAME land in
|
||||
// .env — the adapter computes its shared/dedicated mode and name once at
|
||||
// module load, so restarting earlier would leave it running with defaults.
|
||||
await restartService();
|
||||
|
||||
const platformId = `${chatPhone}@s.whatsapp.net`;
|
||||
|
||||
@@ -124,10 +157,11 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
...(engagePattern ? ['--engage-pattern', engagePattern] : []),
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to WhatsApp…`,
|
||||
done: isDedicated
|
||||
done: mode === 'dedicated'
|
||||
? `${agentName} is ready. Check WhatsApp for a welcome message.`
|
||||
: `${agentName} is ready. Look in your "You" chat on WhatsApp for the welcome.`,
|
||||
},
|
||||
@@ -136,7 +170,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
CHANNEL: 'whatsapp',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: platformId,
|
||||
MODE: isDedicated ? 'dedicated' : 'shared',
|
||||
MODE: mode,
|
||||
ROLE: role,
|
||||
},
|
||||
},
|
||||
@@ -148,6 +182,128 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
'You can retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
if (mode === 'shared') {
|
||||
note(
|
||||
[
|
||||
'Only your self-chat is connected. Messages other people send to your',
|
||||
'number are ignored — never seen, never asked about.',
|
||||
'',
|
||||
k.dim('Wire a specific chat later with /manage-channels.'),
|
||||
].join('\n'),
|
||||
'Self-chat mode',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askNumberOwnership(): Promise<'dedicated' | 'shared' | 'back'> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'Which WhatsApp number will your agent use?',
|
||||
options: [
|
||||
{
|
||||
value: 'dedicated',
|
||||
label: 'A dedicated number just for the agent',
|
||||
hint: 'recommended — spare SIM, eSIM, or old phone',
|
||||
},
|
||||
{
|
||||
value: 'shared',
|
||||
label: 'My own personal number',
|
||||
},
|
||||
{
|
||||
value: 'back',
|
||||
label: '← Back to channel selection',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'dedicated' | 'shared' | 'back';
|
||||
if (choice !== 'back') setupLog.userInput('whatsapp_number_ownership', choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interception screen for the shared-number path: make the account-ban risk
|
||||
* and self-chat-only tradeoff explicit before any install or auth happens.
|
||||
* Default is switching to a dedicated number.
|
||||
*/
|
||||
async function confirmSharedNumber(): Promise<'continue' | 'dedicated'> {
|
||||
note(
|
||||
[
|
||||
'Connecting your shared or personal number could cause WhatsApp to',
|
||||
'temporarily suspend or permanently ban that number. You could lose access',
|
||||
'to the WhatsApp account, chats, and groups you rely on.',
|
||||
'',
|
||||
'We strongly recommend using a separate, dedicated number for NanoClaw.',
|
||||
'',
|
||||
'On your personal number, the agent lives only in your "You" / self-chat.',
|
||||
'Messages other people send you are ignored entirely — never read, never',
|
||||
'answered, never flagged for approval. Nobody else can talk to the agent.',
|
||||
'',
|
||||
'If you want the agent reachable as its own contact, consider:',
|
||||
'',
|
||||
` • ${brandBold('Telegram')} — a bot takes ~2 minutes to set up`,
|
||||
` • ${brandBold('a dedicated WhatsApp number')} — spare SIM, eSIM, or old phone`,
|
||||
` • ${brandBold('/add-whatsapp-cloud')} — the official Meta Business API`,
|
||||
].join('\n'),
|
||||
'Risk to your WhatsApp account',
|
||||
);
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'How would you like to proceed?',
|
||||
options: [
|
||||
{
|
||||
value: 'dedicated',
|
||||
label: 'Go back and use a dedicated number',
|
||||
hint: 'recommended',
|
||||
},
|
||||
{
|
||||
value: 'continue',
|
||||
label: 'I understand the risk — continue with my shared number',
|
||||
},
|
||||
],
|
||||
initialValue: 'dedicated',
|
||||
}),
|
||||
) as 'continue' | 'dedicated';
|
||||
setupLog.userInput('whatsapp_shared_confirm', choice);
|
||||
if (choice === 'continue') {
|
||||
setupLog.userInput('whatsapp_shared_risk_acknowledged', 'true');
|
||||
}
|
||||
return choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared mode only: choose whether the agent answers everything in the
|
||||
* self-chat or only messages addressed to it by name. Returns the engage
|
||||
* regex for init-first-agent's --engage-pattern, or undefined for the
|
||||
* respond-to-everything default.
|
||||
*/
|
||||
async function askSelfChatEngage(agentName: string): Promise<string | undefined> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: `Respond to every self-chat message, or only messages starting with @${agentName}?`,
|
||||
options: [
|
||||
{
|
||||
value: 'all',
|
||||
label: 'Every message',
|
||||
hint: "the self-chat becomes the agent's inbox",
|
||||
},
|
||||
{
|
||||
value: 'mention',
|
||||
label: `Only messages starting with @${agentName}`,
|
||||
hint: 'keep the self-chat for your own notes too',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'all' | 'mention';
|
||||
setupLog.userInput('whatsapp_selfchat_engage', choice);
|
||||
return choice === 'all' ? undefined : selfChatEngagePattern(agentName);
|
||||
}
|
||||
|
||||
/** Engage regex for "only messages starting with @<name>". Exported for tests. */
|
||||
export function selfChatEngagePattern(agentName: string): string {
|
||||
const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// \b only terminates a match after a word character — skip it for names
|
||||
// ending in punctuation, where it would never match.
|
||||
return /\w$/.test(agentName) ? `^@${escaped}\\b` : `^@${escaped}`;
|
||||
}
|
||||
|
||||
async function askAuthMethod(): Promise<AuthMethod | 'back'> {
|
||||
@@ -359,7 +515,7 @@ function readAuthedPhone(): string {
|
||||
|
||||
async function restartService(): Promise<void> {
|
||||
const s = p.spinner();
|
||||
s.start('Restarting NanoClaw so it sees your WhatsApp credentials…');
|
||||
s.start('Restarting NanoClaw so it sees your WhatsApp credentials and settings…');
|
||||
const start = Date.now();
|
||||
const platform = process.platform;
|
||||
try {
|
||||
@@ -402,25 +558,20 @@ async function restartService(): Promise<void> {
|
||||
async function askChatPhone(authedPhone: string): Promise<string> {
|
||||
note(
|
||||
[
|
||||
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
|
||||
`The agent is signed in as ${k.cyan('+' + authedPhone)}.`,
|
||||
'',
|
||||
"What's the phone number you'll chat with your agent from?",
|
||||
'',
|
||||
k.dim(
|
||||
'Same number = messages will land in your "You" / self-chat on WhatsApp\n' +
|
||||
"(you won't be able to reply to yourself — use a different number for a\n" +
|
||||
'two-way chat).',
|
||||
),
|
||||
"Now, your personal number — the one you'll chat with the agent from.",
|
||||
"It'll show up as a normal two-way conversation with the agent's contact.",
|
||||
].join('\n'),
|
||||
'Your chat number',
|
||||
'Your personal number',
|
||||
);
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Your personal phone number',
|
||||
placeholder: authedPhone,
|
||||
defaultValue: authedPhone,
|
||||
message: "Your personal number, where you'll chat from",
|
||||
placeholder: 'e.g. 14155551234',
|
||||
validate: (v) => {
|
||||
const t = (v ?? authedPhone).trim();
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Phone number is required';
|
||||
if (!/^\d{8,15}$/.test(t)) {
|
||||
return 'Digits only, country code included.';
|
||||
}
|
||||
@@ -428,28 +579,31 @@ async function askChatPhone(authedPhone: string): Promise<string> {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const phone = ((answer as string) || authedPhone).trim();
|
||||
const phone = (answer as string).trim();
|
||||
setupLog.userInput('whatsapp_chat_phone', phone);
|
||||
return phone;
|
||||
}
|
||||
|
||||
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env. */
|
||||
function writeAssistantHasOwnNumber(): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
/** Persist KEY=value to .env, replacing any existing KEY line. Exported for tests. */
|
||||
export function writeEnvVar(
|
||||
key: string,
|
||||
value: string,
|
||||
envPath: string = path.join(process.cwd(), '.env'),
|
||||
): void {
|
||||
let contents = '';
|
||||
try {
|
||||
contents = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
contents = '';
|
||||
}
|
||||
if (/^ASSISTANT_HAS_OWN_NUMBER=/m.test(contents)) {
|
||||
contents = contents.replace(
|
||||
/^ASSISTANT_HAS_OWN_NUMBER=.*$/m,
|
||||
'ASSISTANT_HAS_OWN_NUMBER=true',
|
||||
);
|
||||
const line = `${key}=${value}`;
|
||||
const existing = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (existing.test(contents)) {
|
||||
// Replacement via callback so `$`-sequences in the value stay literal.
|
||||
contents = contents.replace(existing, () => line);
|
||||
} else {
|
||||
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
|
||||
contents += 'ASSISTANT_HAS_OWN_NUMBER=true\n';
|
||||
contents += line + '\n';
|
||||
}
|
||||
fs.writeFileSync(envPath, contents);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ echo "=== NANOCLAW SETUP: INSTALL_SLACK ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/slack.ts ]] || needs_install=true
|
||||
[[ -f container/skills/slack-formatting/SKILL.md ]] || needs_install=true
|
||||
grep -q "import './slack.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/slack"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/slack ]] || needs_install=true
|
||||
@@ -30,6 +31,8 @@ git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
mkdir -p container/skills/slack-formatting
|
||||
git show origin/channels:container/skills/slack-formatting/SKILL.md > container/skills/slack-formatting/SKILL.md
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './slack.js';" src/channels/index.ts; then
|
||||
|
||||
@@ -20,6 +20,8 @@ CHANNEL_FILES=(
|
||||
src/channels/whatsapp.ts
|
||||
setup/whatsapp-auth.ts
|
||||
setup/groups.ts
|
||||
container/skills/whatsapp-formatting/SKILL.md
|
||||
container/skills/whatsapp-formatting/instructions.md
|
||||
)
|
||||
|
||||
needs_install=false
|
||||
@@ -45,6 +47,7 @@ git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
for f in "${CHANNEL_FILES[@]}"; do
|
||||
mkdir -p "$(dirname "$f")"
|
||||
git show "origin/channels:$f" > "$f"
|
||||
done
|
||||
|
||||
|
||||
+100
-12
@@ -7,6 +7,16 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Registration-only barrel import: each channel module calls
|
||||
// registerChannelAdapter() at module scope (factories are NOT invoked, no
|
||||
// adapter connects), so declared channel defaults resolve without the service.
|
||||
import '../src/channels/index.js';
|
||||
import {
|
||||
resolveUnknownSenderPolicy,
|
||||
resolveWiringDefaults,
|
||||
validateEngageAgainstChannel,
|
||||
} from '../src/channels/channel-defaults.js';
|
||||
import { hasDeclaredChannelDefaults } from '../src/channels/channel-registry.js';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
@@ -33,7 +43,7 @@ interface RegisterArgs {
|
||||
trigger: string;
|
||||
/** Agent group folder name */
|
||||
folder: string;
|
||||
/** Channel type (discord, slack, telegram, etc.) */
|
||||
/** Channel type (discord, slack, telegram, etc.) — required */
|
||||
channel: string;
|
||||
/** Whether messages require the trigger pattern to activate */
|
||||
requiresTrigger: boolean;
|
||||
@@ -41,18 +51,28 @@ interface RegisterArgs {
|
||||
assistantName: string;
|
||||
/** Session mode: 'shared' (one session per channel) or 'per-thread' */
|
||||
sessionMode: string;
|
||||
/** Whether the messaging group is a multi-user chat (default: true) */
|
||||
isGroup: boolean;
|
||||
/** Explicit engage mode override; omitted = channel declaration / heuristic */
|
||||
engageMode?: 'pattern' | 'mention' | 'mention-sticky';
|
||||
/** Explicit unknown_sender_policy override; omitted = channel declaration / 'strict' */
|
||||
unknownSenderPolicy?: 'strict' | 'request_approval' | 'public';
|
||||
}
|
||||
|
||||
const ENGAGE_MODES = ['pattern', 'mention', 'mention-sticky'] as const;
|
||||
const SENDER_POLICIES = ['strict', 'request_approval', 'public'] as const;
|
||||
|
||||
function parseArgs(args: string[]): RegisterArgs {
|
||||
const result: RegisterArgs = {
|
||||
platformId: '',
|
||||
name: '',
|
||||
trigger: '',
|
||||
folder: '',
|
||||
channel: 'discord',
|
||||
channel: '',
|
||||
requiresTrigger: false,
|
||||
assistantName: 'Andy',
|
||||
sessionMode: 'shared',
|
||||
isGroup: true,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -81,6 +101,32 @@ function parseArgs(args: string[]): RegisterArgs {
|
||||
case '--session-mode':
|
||||
result.sessionMode = args[++i] || 'shared';
|
||||
break;
|
||||
case '--is-group': {
|
||||
const raw = (args[++i] || '').toLowerCase();
|
||||
if (!['true', 'false', '1', '0'].includes(raw)) {
|
||||
throw new Error(`--is-group must be true or false, got "${raw}"`);
|
||||
}
|
||||
result.isGroup = raw === 'true' || raw === '1';
|
||||
break;
|
||||
}
|
||||
case '--engage-mode': {
|
||||
const raw = (args[++i] || '').toLowerCase() as RegisterArgs['engageMode'];
|
||||
if (!raw || !ENGAGE_MODES.includes(raw)) {
|
||||
throw new Error(`--engage-mode must be one of ${ENGAGE_MODES.join('|')}, got "${raw}"`);
|
||||
}
|
||||
result.engageMode = raw;
|
||||
break;
|
||||
}
|
||||
case '--unknown-sender-policy': {
|
||||
const raw = (args[++i] || '').toLowerCase() as RegisterArgs['unknownSenderPolicy'];
|
||||
if (!raw || !SENDER_POLICIES.includes(raw)) {
|
||||
throw new Error(
|
||||
`--unknown-sender-policy must be one of ${SENDER_POLICIES.join('|')}, got "${raw}"`,
|
||||
);
|
||||
}
|
||||
result.unknownSenderPolicy = raw;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +141,19 @@ export async function run(args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const parsed = parseArgs(args);
|
||||
|
||||
if (!parsed.channel) {
|
||||
// No silent platform default: the channel decides platform-id namespacing
|
||||
// and the wiring/policy defaults below, so guessing one wires the chat to
|
||||
// the wrong adapter.
|
||||
emitStatus('REGISTER_CHANNEL', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'missing_channel',
|
||||
MESSAGE: '--channel is required (the channel type this chat lives on, e.g. discord, slack, telegram, whatsapp)',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
if (!parsed.platformId || !parsed.name || !parsed.folder) {
|
||||
emitStatus('REGISTER_CHANNEL', {
|
||||
STATUS: 'failed',
|
||||
@@ -150,13 +209,20 @@ export async function run(args: string[]): Promise<void> {
|
||||
let messagingGroup = getMessagingGroupByPlatform(parsed.channel, parsed.platformId);
|
||||
if (!messagingGroup) {
|
||||
const mgId = generateId('mg');
|
||||
// Policy: explicit flag → channel declaration → legacy 'strict' (stale
|
||||
// adapters without a declaration must keep pre-declaration behavior).
|
||||
const unknownSenderPolicy =
|
||||
parsed.unknownSenderPolicy ??
|
||||
(hasDeclaredChannelDefaults(parsed.channel)
|
||||
? resolveUnknownSenderPolicy(parsed.channel, parsed.isGroup)
|
||||
: 'strict');
|
||||
createMessagingGroup({
|
||||
id: mgId,
|
||||
channel_type: parsed.channel,
|
||||
platform_id: parsed.platformId,
|
||||
name: parsed.name,
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'strict',
|
||||
is_group: parsed.isGroup ? 1 : 0,
|
||||
unknown_sender_policy: unknownSenderPolicy,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
messagingGroup = getMessagingGroupByPlatform(parsed.channel, parsed.platformId)!;
|
||||
@@ -170,19 +236,41 @@ export async function run(args: string[]): Promise<void> {
|
||||
if (!existing) {
|
||||
newlyWired = true;
|
||||
const mgaId = generateId('mga');
|
||||
// Mirrors scripts/init-first-agent.ts:wireIfMissing so both setup paths
|
||||
// create rows with the same shape. Groups default to 'mention' (bot only
|
||||
// responds when addressed); DMs default to 'pattern'/'.' (respond to
|
||||
// every message). An explicit --trigger overrides the pattern regex.
|
||||
// Engage defaults, first hit wins: explicit --engage-mode → explicit
|
||||
// --trigger (pattern regex, the historical override) → the channel's
|
||||
// declared defaults → the legacy heuristic for stale (undeclared)
|
||||
// adapters, so a trunk update alone changes nothing for them: groups get
|
||||
// 'mention' (respond when addressed), DMs 'pattern'/'.' (every message).
|
||||
const isGroup = messagingGroup.is_group === 1;
|
||||
const engageMode: 'pattern' | 'mention' = isGroup && !parsed.trigger ? 'mention' : 'pattern';
|
||||
const engagePattern: string | null = engageMode === 'pattern' ? parsed.trigger || '.' : null;
|
||||
const channelKey = messagingGroup.instance ?? messagingGroup.channel_type;
|
||||
let engage: { engage_mode: 'pattern' | 'mention' | 'mention-sticky'; engage_pattern: string | null };
|
||||
if (parsed.engageMode) {
|
||||
if (parsed.engageMode === 'pattern' && !parsed.trigger) {
|
||||
throw new Error(`--engage-mode pattern requires --trigger (use "." to match every message)`);
|
||||
}
|
||||
engage = {
|
||||
engage_mode: parsed.engageMode,
|
||||
engage_pattern: parsed.engageMode === 'pattern' ? parsed.trigger : null,
|
||||
};
|
||||
} else if (parsed.trigger) {
|
||||
engage = { engage_mode: 'pattern', engage_pattern: parsed.trigger };
|
||||
} else if (hasDeclaredChannelDefaults(channelKey, messagingGroup.channel_type)) {
|
||||
engage = resolveWiringDefaults(channelKey, isGroup, agentGroup.name, messagingGroup.channel_type);
|
||||
} else {
|
||||
engage = isGroup
|
||||
? { engage_mode: 'mention', engage_pattern: null }
|
||||
: { engage_mode: 'pattern', engage_pattern: '.' };
|
||||
}
|
||||
// Same cross-checks as `ncl wirings create`: rejects mention modes on
|
||||
// channels declaring mentions:'never'; coerces mention-sticky→mention
|
||||
// when the channel context has no thread ids.
|
||||
validateEngageAgainstChannel(engage, messagingGroup);
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: messagingGroup.id,
|
||||
agent_group_id: agentGroup.id,
|
||||
engage_mode: engageMode,
|
||||
engage_pattern: engagePattern,
|
||||
engage_mode: engage.engage_mode,
|
||||
engage_pattern: engage.engage_pattern,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
|
||||
|
||||
+70
-1
@@ -83,7 +83,8 @@ export interface InboundMessage {
|
||||
* display name (e.g. `@Andy`).
|
||||
*
|
||||
* Adapters that don't set it (native / legacy) leave it undefined — the
|
||||
* router falls back to text-match against agent_group_name.
|
||||
* router treats undefined as "not a mention" (`isMention === true` check,
|
||||
* src/router.ts). There is no text-match fallback.
|
||||
*/
|
||||
isMention?: boolean;
|
||||
/** True when the source is a group/channel thread, false for DMs. */
|
||||
@@ -110,6 +111,57 @@ export interface ConversationInfo {
|
||||
isGroup: boolean;
|
||||
}
|
||||
|
||||
/** Wiring/mg defaults for one conversation context (DM vs group/channel). */
|
||||
export interface ChannelContextDefaults {
|
||||
/** Default engage_mode for wirings created in this context. */
|
||||
engageMode: 'pattern' | 'mention' | 'mention-sticky';
|
||||
/**
|
||||
* Default engage_pattern when engageMode === 'pattern'. May contain the
|
||||
* literal token `{name}`: creation helpers replace it with the regex-escaped
|
||||
* agent_group name (for platforms with no group-mention metadata, e.g.
|
||||
* iMessage/DeltaChat groups, WhatsApp shared-number mode). Required iff
|
||||
* engageMode === 'pattern'.
|
||||
*/
|
||||
engagePattern?: string;
|
||||
/**
|
||||
* Whether thread ids are honored in this context by default.
|
||||
* true — inbound thread ids flow into messages_in and (in groups) force
|
||||
* per-thread session identity; replies, typing, and cards land
|
||||
* in-thread.
|
||||
* false — thread ids are nulled per-wiring at router fanout; sessions
|
||||
* collapse; replies land top-level.
|
||||
* MUST be false when `supportsThreads` is false (capability bound; the
|
||||
* router treats supportsThreads=false as a hard pre-strip regardless).
|
||||
* Per-wiring override: messaging_group_agents.threads (NULL = inherit).
|
||||
*/
|
||||
threads: boolean;
|
||||
/**
|
||||
* unknown_sender_policy stamped on messaging_groups rows auto-created by
|
||||
* the router or created by wizard/CLI paths in this context.
|
||||
*/
|
||||
unknownSenderPolicy: 'strict' | 'request_approval' | 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Static per-channel declaration of wiring-time defaults. Exactly two levels
|
||||
* exist: this declaration, and the per-wiring/per-mg values chosen at
|
||||
* creation. Install-wide changes = edit the adapter copy (skill-installed,
|
||||
* user-owned). Never persisted to the central DB.
|
||||
*/
|
||||
export interface ChannelDefaults {
|
||||
dm: ChannelContextDefaults;
|
||||
group: ChannelContextDefaults;
|
||||
/**
|
||||
* Which mention signal the adapter emits (InboundMessage.isMention):
|
||||
* 'platform' — platform-confirmed mentions in groups; DMs flagged too.
|
||||
* 'dm-only' — only DMs flagged (no group mention metadata).
|
||||
* 'never' — isMention never set: auto-create/registration card never
|
||||
* fires; 'mention'/'mention-sticky' wirings never engage.
|
||||
* Creation surfaces must reject/warn on mention modes that can never fire.
|
||||
*/
|
||||
mentions: 'platform' | 'dm-only' | 'never';
|
||||
}
|
||||
|
||||
/** The v2 channel adapter contract. */
|
||||
export interface ChannelAdapter {
|
||||
name: string;
|
||||
@@ -176,6 +228,15 @@ export interface ChannelAdapter {
|
||||
* Returning the same platform_id on repeated calls is expected.
|
||||
*/
|
||||
openDM?(userHandle: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Declared wiring-time defaults for this channel. Optional for backward
|
||||
* compatibility with stale adapter copies; absent → core fallback
|
||||
* (fallbackChannelDefaults(supportsThreads), see channel-registry.ts).
|
||||
* May be computed from adapter-internal env at module load (e.g. WhatsApp
|
||||
* shared-number mode), but is immutable for the process lifetime.
|
||||
*/
|
||||
defaults?: ChannelDefaults;
|
||||
}
|
||||
|
||||
/** Factory function that creates a channel adapter (returns null if credentials missing). */
|
||||
@@ -184,6 +245,14 @@ export type ChannelAdapterFactory = () => ChannelAdapter | Promise<ChannelAdapte
|
||||
/** Registration entry for a channel adapter. */
|
||||
export interface ChannelRegistration {
|
||||
factory: ChannelAdapterFactory;
|
||||
/**
|
||||
* Same declaration as ChannelAdapter.defaults, resolvable WITHOUT
|
||||
* instantiating the adapter — offline creation paths (setup/register.ts,
|
||||
* scripts/init-first-agent.ts, ncl against a host where the factory
|
||||
* returned null for missing creds) read it from the registry. Channel
|
||||
* modules pass the same const here and to the adapter/bridge.
|
||||
*/
|
||||
defaults?: ChannelDefaults;
|
||||
containerConfig?: {
|
||||
mounts?: Array<{ hostPath: string; containerPath: string; readonly: boolean }>;
|
||||
env?: Record<string, string>;
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Tests for channel default declarations: getChannelDefaults tiered lookup,
|
||||
* the behavior-faithful fallback, and the wiring-creation helpers.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import type { ChannelAdapter, ChannelDefaults, ChannelSetup } from './adapter.js';
|
||||
|
||||
function makeDefaults(marker: string, threads = true): ChannelDefaults {
|
||||
return {
|
||||
dm: { engageMode: 'pattern', engagePattern: marker, threads, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention', threads, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
}
|
||||
|
||||
function makeAdapter(
|
||||
channelType: string,
|
||||
opts: { instance?: string; supportsThreads?: boolean; defaults?: ChannelDefaults } = {},
|
||||
): ChannelAdapter {
|
||||
return {
|
||||
name: opts.instance ?? channelType,
|
||||
channelType,
|
||||
instance: opts.instance,
|
||||
supportsThreads: opts.supportsThreads ?? false,
|
||||
defaults: opts.defaults,
|
||||
async setup(_config: ChannelSetup) {},
|
||||
async teardown() {},
|
||||
isConnected: () => true,
|
||||
async deliver() {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mockSetup = () => ({
|
||||
onInbound: () => {},
|
||||
onInboundEvent: () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: () => {},
|
||||
});
|
||||
|
||||
describe('getChannelDefaults — tiered lookup', () => {
|
||||
// The registry and activeAdapters maps are module-level; fresh module per
|
||||
// test so registrations don't leak across arms.
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { teardownChannelAdapters } = await import('./channel-registry.js');
|
||||
await teardownChannelAdapters();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('live adapter declaration wins over the registration declaration', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const liveDecl = makeDefaults('live');
|
||||
const regDecl = makeDefaults('registration');
|
||||
reg.registerChannelAdapter('mock', {
|
||||
factory: () => makeAdapter('mock', { defaults: liveDecl }),
|
||||
defaults: regDecl,
|
||||
});
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('mock')).toBe(liveDecl);
|
||||
});
|
||||
|
||||
it('falls through a live channelType scan for a channelType key (live tier instance→channelType)', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const decl = makeDefaults('named-instance');
|
||||
reg.registerChannelAdapter('slack-tester', {
|
||||
factory: () => makeAdapter('slack', { instance: 'slack-tester', defaults: decl }),
|
||||
});
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
// Key is the bare channelType; only a named instance is live.
|
||||
expect(reg.getChannelDefaults('slack')).toBe(decl);
|
||||
});
|
||||
|
||||
it('falls through to the registration entry when the factory returned null', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const decl = makeDefaults('registration');
|
||||
reg.registerChannelAdapter('mock', { factory: () => null, defaults: decl });
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('mock')).toBe(decl);
|
||||
});
|
||||
|
||||
it('resolves a stale live instance through its channelType registration (registration tier instance→channelType)', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const decl = makeDefaults('platform-registration');
|
||||
// Stale adapter copy: live under a named-instance key with NO declaration;
|
||||
// the platform's registration (keyed by channelType) carries one.
|
||||
reg.registerChannelAdapter('slack-tester', {
|
||||
factory: () => makeAdapter('slack', { instance: 'slack-tester' }),
|
||||
});
|
||||
reg.registerChannelAdapter('slack', { factory: () => null, defaults: decl });
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('slack-tester')).toBe(decl);
|
||||
});
|
||||
|
||||
it('resolves a dead named instance through the channelType hint (registration tier instance→channelType)', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const decl = makeDefaults('platform-registration');
|
||||
// Nothing live at all: the named instance's factory returned null and its
|
||||
// registration has no declaration — only mg.channel_type can bridge.
|
||||
reg.registerChannelAdapter('slack-tester', { factory: () => null });
|
||||
reg.registerChannelAdapter('slack', { factory: () => null, defaults: decl });
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('slack-tester', 'slack')).toBe(decl);
|
||||
// Without the hint there is no instance→channelType mapping in the registry.
|
||||
expect(reg.getChannelDefaults('slack-tester')).toEqual(reg.fallbackChannelDefaults(false));
|
||||
});
|
||||
|
||||
it('uses the live adapter supportsThreads for the fallback tier', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
reg.registerChannelAdapter('mock', {
|
||||
factory: () => makeAdapter('mock', { supportsThreads: true }),
|
||||
});
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('mock')).toEqual(reg.fallbackChannelDefaults(true));
|
||||
});
|
||||
|
||||
it('unknown channel type resolves the conservative fallback', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
expect(reg.getChannelDefaults('no-such-channel')).toEqual(reg.fallbackChannelDefaults(false));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallbackChannelDefaults — behavior-faithful values', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('reproduces trunk behavior for undeclared adapters', async () => {
|
||||
const { fallbackChannelDefaults } = await import('./channel-registry.js');
|
||||
expect(fallbackChannelDefaults(true)).toEqual({
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
});
|
||||
// threads track the raw capability in BOTH contexts so NULL-inherit
|
||||
// wirings behave exactly like today's supportsThreads-derived routing.
|
||||
const nonThreaded = fallbackChannelDefaults(false);
|
||||
expect(nonThreaded.dm.threads).toBe(false);
|
||||
expect(nonThreaded.group.threads).toBe(false);
|
||||
expect(nonThreaded.group.engageMode).toBe('mention-sticky');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWiringDefaults', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { teardownChannelAdapters } = await import('./channel-registry.js');
|
||||
await teardownChannelAdapters();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function withDeclaration(defaults: ChannelDefaults) {
|
||||
const reg = await import('./channel-registry.js');
|
||||
reg.registerChannelAdapter('mock', { factory: () => null, defaults });
|
||||
return import('./channel-defaults.js');
|
||||
}
|
||||
|
||||
it('substitutes {name} with the regex-escaped agent group name', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'dm-only',
|
||||
});
|
||||
|
||||
// Name ends in ')' (non-word) — the trailing declared \b could never
|
||||
// match there, so it is dropped; the leading \b stays.
|
||||
expect(resolveWiringDefaults('mock', true, 'C-3PO (dev)')).toEqual({
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '\\bC-3PO \\(dev\\)',
|
||||
});
|
||||
// DM context: no token, pattern passes through untouched.
|
||||
expect(resolveWiringDefaults('mock', false, 'C-3PO (dev)')).toEqual({
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps both \\b boundaries for a plain word name and produces a matching regex', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'dm-only',
|
||||
});
|
||||
|
||||
const word = resolveWiringDefaults('mock', true, 'Andy');
|
||||
expect(word.engage_pattern).toBe('\\bAndy\\b');
|
||||
expect(new RegExp(word.engage_pattern!).test('@Andy status')).toBe(true);
|
||||
expect(new RegExp(word.engage_pattern!).test('@Andyboy status')).toBe(false);
|
||||
|
||||
// Trailing non-word char: '@Andy (backup) status' must still engage.
|
||||
const punct = resolveWiringDefaults('mock', true, 'Andy (backup)');
|
||||
expect(new RegExp(punct.engage_pattern!).test('@Andy (backup) status')).toBe(true);
|
||||
|
||||
// Leading non-word char: the leading \b is dropped instead.
|
||||
const lead = resolveWiringDefaults('mock', true, '!Nano');
|
||||
expect(lead.engage_pattern).toBe('!Nano\\b');
|
||||
expect(new RegExp(lead.engage_pattern!).test('hey !Nano status')).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces mention-sticky to mention when the context threads=false', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'mention-sticky', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
});
|
||||
|
||||
expect(resolveWiringDefaults('mock', true, 'Andy')).toEqual({
|
||||
engage_mode: 'mention',
|
||||
engage_pattern: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps mention-sticky when the context threads=true', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
});
|
||||
|
||||
expect(resolveWiringDefaults('mock', true, 'Andy')).toEqual({
|
||||
engage_mode: 'mention-sticky',
|
||||
engage_pattern: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on a pattern-mode declaration without a pattern', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'pattern', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
});
|
||||
|
||||
expect(() => resolveWiringDefaults('mock', false, 'Andy')).toThrow(/without an engagePattern/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveUnknownSenderPolicy', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('selects the context policy from the declaration', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
reg.registerChannelAdapter('mock', {
|
||||
factory: () => null,
|
||||
defaults: {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
},
|
||||
});
|
||||
const { resolveUnknownSenderPolicy } = await import('./channel-defaults.js');
|
||||
|
||||
expect(resolveUnknownSenderPolicy('mock', false)).toBe('public');
|
||||
expect(resolveUnknownSenderPolicy('mock', true)).toBe('strict');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveThreadPolicy', () => {
|
||||
it('ANDs the resolved value with the raw capability', async () => {
|
||||
vi.resetModules();
|
||||
const { resolveThreadPolicy } = await import('./channel-defaults.js');
|
||||
const decl: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
|
||||
// NULL = inherit the declaration for the context.
|
||||
expect(resolveThreadPolicy(null, decl, true, true)).toBe(true);
|
||||
expect(resolveThreadPolicy(null, decl, false, true)).toBe(false);
|
||||
// Explicit wiring value beats the declaration…
|
||||
expect(resolveThreadPolicy(1, decl, false, true)).toBe(true);
|
||||
expect(resolveThreadPolicy(0, decl, true, true)).toBe(false);
|
||||
// …but never the capability: no opt-in on a non-threaded platform.
|
||||
expect(resolveThreadPolicy(1, decl, true, false)).toBe(false);
|
||||
expect(resolveThreadPolicy(null, decl, true, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Wiring-creation helpers over channel default declarations.
|
||||
*
|
||||
* Every path that creates a messaging_group_agents row (ncl, setup wizard,
|
||||
* card-approval flow, bootstrap scripts) resolves its engage defaults through
|
||||
* resolveWiringDefaults; every path that auto-creates a messaging_groups row
|
||||
* resolves its policy through resolveUnknownSenderPolicy. The router's fanout
|
||||
* consults resolveThreadPolicy at runtime — threading is the one per-wiring
|
||||
* setting that stays live (NULL = inherit the declaration) rather than being
|
||||
* snapshotted at creation.
|
||||
*
|
||||
* Context selection everywhere: isGroup = event.message.isGroup ??
|
||||
* (mg.is_group === 1) — NEVER `threadId !== null` (DM sub-threads exist on
|
||||
* Slack/Discord, and non-threaded group platforms have null threadIds).
|
||||
*/
|
||||
import type { ChannelDefaults } from './adapter.js';
|
||||
import { getChannelDefaults, hasDeclaredChannelDefaults } from './channel-registry.js';
|
||||
import { log } from '../log.js';
|
||||
import type { MessagingGroup } from '../types.js';
|
||||
|
||||
function escapeRegex(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute the (regex-escaped) agent name for `{name}` in a declared
|
||||
* pattern. A `\b` adjacent to a non-word character can never match, so when
|
||||
* the name starts/ends with one (e.g. "Nano!", "Andy (backup)") the adjacent
|
||||
* declared boundary is dropped — mirrors selfChatEngagePattern in
|
||||
* setup/channels/whatsapp.ts.
|
||||
*/
|
||||
function substituteName(pattern: string, name: string): string {
|
||||
let out = pattern;
|
||||
if (!/^\w/.test(name)) out = out.replaceAll('\\b{name}', '{name}');
|
||||
if (!/\w$/.test(name)) out = out.replaceAll('{name}\\b', '{name}');
|
||||
return out.replaceAll('{name}', escapeRegex(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the engage defaults a new wiring should be created with.
|
||||
*
|
||||
* @param channelKey mg.instance ?? mg.channel_type (getChannelAdapter key discipline)
|
||||
* @param isGroup event.message.isGroup ?? mg.is_group === 1 — never derived from threadId
|
||||
* @param agentGroupName substituted (regex-escaped) for the `{name}` token in declared patterns
|
||||
* @param channelType mg.channel_type — pass when channelKey may be a named
|
||||
* instance so a dead instance still resolves its platform's declaration
|
||||
* (getChannelDefaults' second-arg discipline)
|
||||
*
|
||||
* mention-sticky is downgraded to mention when the context's declared threads
|
||||
* value is false: sticky engagement is keyed on per-thread session existence,
|
||||
* so without thread ids it could engage once and never disengage.
|
||||
*/
|
||||
export function resolveWiringDefaults(
|
||||
channelKey: string,
|
||||
isGroup: boolean,
|
||||
agentGroupName: string,
|
||||
channelType?: string,
|
||||
): { engage_mode: 'pattern' | 'mention' | 'mention-sticky'; engage_pattern: string | null } {
|
||||
const decl = getChannelDefaults(channelKey, channelType);
|
||||
const ctx = isGroup ? decl.group : decl.dm;
|
||||
|
||||
let mode = ctx.engageMode;
|
||||
if (mode === 'mention-sticky' && !ctx.threads) mode = 'mention';
|
||||
|
||||
if (mode !== 'pattern') return { engage_mode: mode, engage_pattern: null };
|
||||
|
||||
if (!ctx.engagePattern) {
|
||||
throw new Error(
|
||||
`Channel '${channelKey}' declares engageMode 'pattern' without an engagePattern (${isGroup ? 'group' : 'dm'} context)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: substituteName(ctx.engagePattern, agentGroupName),
|
||||
};
|
||||
}
|
||||
|
||||
/** unknown_sender_policy for a messaging_groups row created in this context.
|
||||
* `channelType` follows the same dead-named-instance discipline as
|
||||
* resolveWiringDefaults. */
|
||||
export function resolveUnknownSenderPolicy(
|
||||
channelKey: string,
|
||||
isGroup: boolean,
|
||||
channelType?: string,
|
||||
): 'strict' | 'request_approval' | 'public' {
|
||||
const decl = getChannelDefaults(channelKey, channelType);
|
||||
return (isGroup ? decl.group : decl.dm).unknownSenderPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime thread policy for one wiring: does its event-derived address keep
|
||||
* thread ids? wiring.threads (0/1, NULL = inherit the declaration) hard-ANDed
|
||||
* with the adapter's raw capability — a wiring can opt out of threads on a
|
||||
* threaded platform, never opt in on a non-threaded one.
|
||||
*
|
||||
* Applies ONLY to event-derived addresses. `event.replyTo` is operator intent
|
||||
* from the CLI admin transport (src/channels/adapter.ts) and must never be
|
||||
* nulled through this policy.
|
||||
*/
|
||||
export function resolveThreadPolicy(
|
||||
wiringThreads: number | null,
|
||||
decl: ChannelDefaults,
|
||||
isGroup: boolean,
|
||||
supportsThreads: boolean,
|
||||
): boolean {
|
||||
const inherited = (isGroup ? decl.group : decl.dm).threads;
|
||||
const wanted = wiringThreads === null ? inherited : wiringThreads !== 0;
|
||||
return wanted && supportsThreads;
|
||||
}
|
||||
|
||||
export interface EngageValues {
|
||||
engage_mode?: unknown;
|
||||
engage_pattern?: unknown;
|
||||
threads?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-column validation against the channel's declaration. Shared by every
|
||||
* wiring-creation surface (`ncl wirings` create/update, the setup wizard's
|
||||
* register step) so a partial update or an explicit flag can't produce a
|
||||
* combination create would reject. May mutate `w.engage_mode`: the
|
||||
* mention-sticky→mention coercion when the effective thread policy is off —
|
||||
* sticky engagement is keyed on per-thread session existence, so without
|
||||
* thread ids it would engage once and never disengage.
|
||||
*
|
||||
* Declaration-derived checks are gated on hasDeclaredChannelDefaults: stale
|
||||
* (undeclared) adapters keep the legacy lenient behavior — the fallback
|
||||
* declaration is permissive on mentions but its threads value is false when
|
||||
* no adapter is live, which would wrongly coerce offline-created wirings.
|
||||
*/
|
||||
export function validateEngageAgainstChannel(w: EngageValues, mg: MessagingGroup): void {
|
||||
if (
|
||||
w.engage_mode === 'pattern' &&
|
||||
(w.engage_pattern === undefined || w.engage_pattern === null || w.engage_pattern === '')
|
||||
) {
|
||||
throw new Error(`engage_mode 'pattern' requires --engage-pattern (use "." to match every message)`);
|
||||
}
|
||||
if (w.engage_mode !== 'mention' && w.engage_mode !== 'mention-sticky') return;
|
||||
|
||||
const channelKey = mg.instance ?? mg.channel_type;
|
||||
if (!hasDeclaredChannelDefaults(channelKey, mg.channel_type)) return;
|
||||
|
||||
const decl = getChannelDefaults(channelKey, mg.channel_type);
|
||||
if (decl.mentions === 'never') {
|
||||
throw new Error(
|
||||
`engage_mode '${w.engage_mode}' can never engage on channel '${channelKey}' — its adapter declares mentions: 'never' (no mention signal is emitted; use --engage-mode pattern)`,
|
||||
);
|
||||
}
|
||||
if (w.engage_mode === 'mention-sticky') {
|
||||
const ctx = mg.is_group === 1 ? decl.group : decl.dm;
|
||||
const threads = w.threads === undefined || w.threads === null ? ctx.threads : w.threads !== 0;
|
||||
if (!threads) {
|
||||
log.warn('mention-sticky requires thread ids — coerced to mention', {
|
||||
channel: channelKey,
|
||||
messagingGroupId: mg.id,
|
||||
});
|
||||
w.engage_mode = 'mention';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Channels self-register on import. The host calls initChannelAdapters() at startup
|
||||
* to instantiate and set up all registered adapters.
|
||||
*/
|
||||
import type { ChannelAdapter, ChannelRegistration, ChannelSetup, OutboundFile } from './adapter.js';
|
||||
import type { ChannelAdapter, ChannelDefaults, ChannelRegistration, ChannelSetup, OutboundFile } from './adapter.js';
|
||||
import type { ChannelDeliveryAdapter } from '../delivery.js';
|
||||
import { log } from '../log.js';
|
||||
|
||||
@@ -102,6 +102,97 @@ export function createChannelDeliveryAdapter(): ChannelDeliveryAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior-faithful fallback for adapters with no `defaults` declaration
|
||||
* (stale skill-installed copies, unknown channel types). Values reproduce
|
||||
* what trunk did before declarations existed, so a trunk update alone
|
||||
* changes nothing for undeclared adapters:
|
||||
* - dm: pattern '.' (every DM message engages), router auto-create policy
|
||||
* 'request_approval' (src/router.ts auto-create branch).
|
||||
* - group: mention-sticky (what the card-approval flow stamped on group
|
||||
* channels), same 'request_approval' policy.
|
||||
* - threads follow the raw capability in BOTH contexts — a NULL (inherit)
|
||||
* wiring resolved through this fallback behaves exactly like today's
|
||||
* supportsThreads-derived routing.
|
||||
* - mentions 'platform': never blocks a mention wiring at creation time.
|
||||
*/
|
||||
export function fallbackChannelDefaults(supportsThreads: boolean): ChannelDefaults {
|
||||
return {
|
||||
dm: {
|
||||
engageMode: 'pattern',
|
||||
engagePattern: '.',
|
||||
threads: supportsThreads,
|
||||
unknownSenderPolicy: 'request_approval',
|
||||
},
|
||||
group: {
|
||||
engageMode: 'mention-sticky',
|
||||
threads: supportsThreads,
|
||||
unknownSenderPolicy: 'request_approval',
|
||||
},
|
||||
mentions: 'platform',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a channel's declared wiring defaults. Never returns undefined.
|
||||
*
|
||||
* `key` follows the same discipline as getChannelAdapter: mg.instance ??
|
||||
* mg.channel_type. Tiers, first hit wins:
|
||||
* 1. live adapter, instance-exact — lets an instance carry env-computed
|
||||
* declarations (e.g. WhatsApp shared-number mode);
|
||||
* 2. live adapter of that channelType (mirrors getChannelAdapter's scan);
|
||||
* 3. registration entry under the key — covers offline scripts and
|
||||
* factories that returned null for missing creds;
|
||||
* 4. registration entry under the channelType — resolved from the live
|
||||
* adapter found in tiers 1-2 (a stale adapter copy without a declaration
|
||||
* whose registration has one), else from the optional `channelType`
|
||||
* hint, which callers holding a named-instance mg row should pass so a
|
||||
* dead instance still resolves its platform's declaration;
|
||||
* 5. fallbackChannelDefaults on the live adapter's capability (false when
|
||||
* no adapter is live — conservative, reachable only from manual creation
|
||||
* surfaces since the router never sees events for unregistered channels).
|
||||
*/
|
||||
export function getChannelDefaults(key: string, channelType?: string): ChannelDefaults {
|
||||
const { live, decl } = lookupDeclaredDefaults(key, channelType);
|
||||
return decl ?? fallbackChannelDefaults(live?.supportsThreads ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff getChannelDefaults would resolve from an actual declaration (tiers
|
||||
* 1-4) rather than fallbackChannelDefaults. Manual creation surfaces (`ncl`)
|
||||
* gate declaration-derived defaults on this: for stale (undeclared) adapters
|
||||
* they keep the legacy static schema defaults — engage_mode 'mention',
|
||||
* unknown_sender_policy 'strict' — so a trunk update alone changes nothing.
|
||||
* The faithful fallback exists for the ROUTER's auto-create/runtime paths,
|
||||
* whose historical behavior it reproduces; it is not what `ncl` did.
|
||||
*/
|
||||
export function hasDeclaredChannelDefaults(key: string, channelType?: string): boolean {
|
||||
return lookupDeclaredDefaults(key, channelType).decl !== undefined;
|
||||
}
|
||||
|
||||
/** Shared tiers 1-4 of getChannelDefaults (see its doc); `decl` undefined
|
||||
* means only tier 5 (fallback) remains. */
|
||||
function lookupDeclaredDefaults(
|
||||
key: string,
|
||||
channelType?: string,
|
||||
): { live: ChannelAdapter | undefined; decl: ChannelDefaults | undefined } {
|
||||
let live = activeAdapters.get(key);
|
||||
if (!live) {
|
||||
for (const adapter of activeAdapters.values()) {
|
||||
if (adapter.channelType === key) {
|
||||
live = adapter;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (live?.defaults) return { live, decl: live.defaults };
|
||||
|
||||
const typeKey = live?.channelType ?? channelType;
|
||||
const registered =
|
||||
registry.get(key)?.defaults ?? (typeKey !== undefined ? registry.get(typeKey)?.defaults : undefined);
|
||||
return { live, decl: registered };
|
||||
}
|
||||
|
||||
/** Get all active adapters. */
|
||||
export function getActiveAdapters(): ChannelAdapter[] {
|
||||
return [...activeAdapters.values()];
|
||||
|
||||
@@ -23,7 +23,7 @@ import { SqliteStateAdapter } from '../state-sqlite.js';
|
||||
import { registerWebhookAdapter } from '../webhook-server.js';
|
||||
import { getAskQuestionRender } from '../db/sessions.js';
|
||||
import { normalizeOptions, type NormalizedOption } from './ask-question.js';
|
||||
import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js';
|
||||
import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage } from './adapter.js';
|
||||
|
||||
/** Adapter with optional gateway support (e.g., Discord). */
|
||||
interface GatewayAdapter extends Adapter {
|
||||
@@ -68,6 +68,12 @@ export interface ChatSdkBridgeConfig {
|
||||
* way and the default depends on installation style.
|
||||
*/
|
||||
supportsThreads: boolean;
|
||||
/**
|
||||
* Declared wiring-time defaults for this channel. Copied verbatim onto the
|
||||
* returned ChannelAdapter, exactly like supportsThreads. See
|
||||
* `ChannelAdapter.defaults`.
|
||||
*/
|
||||
defaults?: ChannelDefaults;
|
||||
/**
|
||||
* Optional transform applied to outbound text/markdown before it reaches the
|
||||
* adapter. Used by channels that need to sanitize for a platform-specific
|
||||
@@ -220,6 +226,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
instance: config.instance, // undefined ⇒ default instance
|
||||
|
||||
supportsThreads: config.supportsThreads,
|
||||
defaults: config.defaults,
|
||||
|
||||
async setup(hostConfig: ChannelSetup) {
|
||||
setupConfig = hostConfig;
|
||||
@@ -265,9 +272,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
});
|
||||
|
||||
// DMs — by definition addressed to the bot. Thread id flows through
|
||||
// so sub-thread context reaches delivery (Slack users can open threads
|
||||
// inside a DM). Router collapses DM sub-threads to one session via
|
||||
// is_group=0 short-circuit.
|
||||
// unmodified (Slack users can open sub-threads inside a DM); whether it
|
||||
// is honored is policy, not transport: the channel's declared
|
||||
// dm.threads default (ChannelDefaults) or a per-wiring threads override
|
||||
// decides at router fanout whether replies land in-thread or all DM
|
||||
// sub-threads collapse into the one DM session.
|
||||
chat.onDirectMessage(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
log.info('Inbound DM received', {
|
||||
|
||||
+22
-2
@@ -39,11 +39,30 @@ import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../config.js';
|
||||
import { log } from '../log.js';
|
||||
import type { ChannelAdapter, ChannelSetup, DeliveryAddress, InboundEvent, OutboundMessage } from './adapter.js';
|
||||
import type {
|
||||
ChannelAdapter,
|
||||
ChannelDefaults,
|
||||
ChannelSetup,
|
||||
DeliveryAddress,
|
||||
InboundEvent,
|
||||
OutboundMessage,
|
||||
} from './adapter.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
const PLATFORM_ID = 'local';
|
||||
|
||||
/**
|
||||
* Terminal transport: every line the operator types is for the agent
|
||||
* (pattern '.'), the socket is owner-only so senders are trusted ('public'),
|
||||
* there is no thread or mention concept. Matches what
|
||||
* scripts/init-cli-agent.ts has always created.
|
||||
*/
|
||||
const CLI_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
mentions: 'never',
|
||||
};
|
||||
|
||||
function socketPath(): string {
|
||||
return path.join(DATA_DIR, 'cli.sock');
|
||||
}
|
||||
@@ -56,6 +75,7 @@ function createAdapter(): ChannelAdapter {
|
||||
name: 'cli',
|
||||
channelType: 'cli',
|
||||
supportsThreads: false,
|
||||
defaults: CLI_DEFAULTS,
|
||||
|
||||
async setup(config: ChannelSetup): Promise<void> {
|
||||
const sock = socketPath();
|
||||
@@ -273,4 +293,4 @@ function extractText(message: OutboundMessage): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
registerChannelAdapter('cli', { factory: createAdapter });
|
||||
registerChannelAdapter('cli', { factory: createAdapter, defaults: CLI_DEFAULTS });
|
||||
|
||||
+63
-1
@@ -29,10 +29,11 @@ vi.mock('../modules/agent-to-agent/write-destinations.js', () => ({
|
||||
writeDestinations: (...args: unknown[]) => writeDestinationsSpy(...args),
|
||||
}));
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup } from '../db/index.js';
|
||||
import { initTestDb, closeDb, getDb, runMigrations, createAgentGroup, createMessagingGroup } from '../db/index.js';
|
||||
import { createSession } from '../db/sessions.js';
|
||||
import { getContainerConfig } from '../db/container-configs.js';
|
||||
import { getDestinations } from '../modules/agent-to-agent/db/agent-destinations.js';
|
||||
import { registerResource } from './crud.js';
|
||||
import { lookup } from './registry.js';
|
||||
|
||||
// Importing these for side effects: each calls `registerResource` at
|
||||
@@ -43,6 +44,31 @@ import '../cli/resources/wirings.js';
|
||||
|
||||
const hostCtx = { caller: 'host' as const };
|
||||
|
||||
// Synthetic resource exercising the two-pass create: pass 1 collects explicit
|
||||
// args, pass 2 runs the resolveDefaults hook, pass 3 fills static defaults.
|
||||
// Registered once at module load like the real resources above; its table is
|
||||
// created per-test in the describe's beforeEach.
|
||||
const hookCalls: Record<string, unknown>[] = [];
|
||||
registerResource({
|
||||
name: 'hooktest',
|
||||
plural: 'hooktests',
|
||||
table: 'hooktest_rows',
|
||||
description: 'Synthetic resource for resolveDefaults hook-ordering tests.',
|
||||
idColumn: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{ name: 'kind', type: 'string', description: 'test input', required: true },
|
||||
{ name: 'mode', type: 'string', description: 'hook-fillable column', default: 'static' },
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { create: 'open' },
|
||||
resolveDefaults: (values) => {
|
||||
hookCalls.push({ ...values });
|
||||
if (values.kind === 'boom') throw new Error('hook rejected');
|
||||
if (values.mode === undefined && values.kind === 'fill') values.mode = 'hooked';
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
@@ -174,3 +200,39 @@ describe('genericCreate postCommit hook', () => {
|
||||
expect(writeDestinationsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('genericCreate resolveDefaults hook (two-pass create)', () => {
|
||||
beforeEach(() => {
|
||||
getDb().exec(
|
||||
`CREATE TABLE hooktest_rows (id TEXT PRIMARY KEY, kind TEXT NOT NULL, mode TEXT, created_at TEXT NOT NULL)`,
|
||||
);
|
||||
hookCalls.length = 0;
|
||||
});
|
||||
|
||||
it('runs between explicit args and static defaults — a hook fill beats the static default', async () => {
|
||||
const row = (await lookup('hooktests-create')!.handler({ kind: 'fill' }, hostCtx)) as { mode: string };
|
||||
expect(row.mode).toBe('hooked');
|
||||
// The hook saw the pre-static-default state: mode still unset. Were the
|
||||
// static default applied first, the hook could never fill it.
|
||||
expect(hookCalls[0].mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('static default still applies when the hook leaves the column unset', async () => {
|
||||
const row = (await lookup('hooktests-create')!.handler({ kind: 'plain' }, hostCtx)) as { mode: string };
|
||||
expect(row.mode).toBe('static');
|
||||
});
|
||||
|
||||
it('explicit args always win over the hook', async () => {
|
||||
const row = (await lookup('hooktests-create')!.handler({ kind: 'fill', mode: 'explicit' }, hostCtx)) as {
|
||||
mode: string;
|
||||
};
|
||||
expect(row.mode).toBe('explicit');
|
||||
expect(hookCalls[0].mode).toBe('explicit');
|
||||
});
|
||||
|
||||
it('a hook throw rejects the create and nothing is inserted', async () => {
|
||||
await expect(lookup('hooktests-create')!.handler({ kind: 'boom' }, hostCtx)).rejects.toThrow('hook rejected');
|
||||
const count = getDb().prepare('SELECT COUNT(*) AS n FROM hooktest_rows').get() as { n: number };
|
||||
expect(count.n).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+43
-2
@@ -83,6 +83,26 @@ export interface ResourceDef {
|
||||
};
|
||||
/** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */
|
||||
customOperations?: Record<string, CustomOperation>;
|
||||
/**
|
||||
* Runs on `create` between explicit-arg collection and static column
|
||||
* defaults (two-pass create): fills omitted columns with context-aware
|
||||
* values (e.g. channel adapter declarations) and cross-validates the
|
||||
* combination, throwing an actionable Error to reject. Mutates `values`
|
||||
* in place. Explicit caller args are already present and must win — only
|
||||
* fill what's still undefined. Static `col.default` / `defaultFrom` apply
|
||||
* afterwards, only to columns the hook left unset, so a static default can
|
||||
* never pre-empt context-aware resolution.
|
||||
*/
|
||||
resolveDefaults?: (values: Record<string, unknown>) => void;
|
||||
/**
|
||||
* Runs on `update` after the update set is built, before the UPDATE
|
||||
* executes. `current` is the existing row; `updates` holds only the
|
||||
* changed columns and is mutable (coercions land here). Throw to reject.
|
||||
* Mirror of the create-side validation in `resolveDefaults` for resources
|
||||
* whose column combinations need cross-checks — a partial update must not
|
||||
* be able to produce a combination `create` would have rejected.
|
||||
*/
|
||||
preUpdate?: (updates: Record<string, unknown>, current: Record<string, unknown>) => void;
|
||||
/**
|
||||
* Runs after a successful `create` INSERT, with the row that was just
|
||||
* written. Used to wire in side effects that the central row alone
|
||||
@@ -172,6 +192,10 @@ function genericCreate(def: ResourceDef) {
|
||||
return async (args: Record<string, unknown>) => {
|
||||
const values: Record<string, unknown> = {};
|
||||
|
||||
// Pass 1: generated columns + explicit caller args only. Static defaults
|
||||
// wait until after resolveDefaults so the hook sees exactly what the
|
||||
// caller provided and a static default never pre-empts context-aware
|
||||
// resolution.
|
||||
for (const col of def.columns) {
|
||||
if (col.generated) {
|
||||
if (col.name === def.idColumn) {
|
||||
@@ -190,7 +214,16 @@ function genericCreate(def: ResourceDef) {
|
||||
values[col.name] = col.type === 'number' ? Number(v) : v;
|
||||
} else if (col.required) {
|
||||
throw new Error(`--${col.name.replace(/_/g, '-')} is required`);
|
||||
} else if (col.default !== undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: context-aware defaults + cross-column validation.
|
||||
if (def.resolveDefaults) def.resolveDefaults(values);
|
||||
|
||||
// Pass 3: static defaults for whatever is still unset.
|
||||
for (const col of def.columns) {
|
||||
if (col.generated || values[col.name] !== undefined) continue;
|
||||
if (col.default !== undefined) {
|
||||
values[col.name] = col.default;
|
||||
} else if (col.defaultFrom !== undefined && values[col.defaultFrom] !== undefined) {
|
||||
values[col.name] = values[col.defaultFrom];
|
||||
@@ -217,6 +250,7 @@ function genericCreate(def: ResourceDef) {
|
||||
|
||||
function genericUpdate(def: ResourceDef) {
|
||||
const updatableCols = def.columns.filter((c) => c.updatable);
|
||||
const cols = visibleColumns(def).join(', ');
|
||||
return async (args: Record<string, unknown>) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error(`${def.name} id is required`);
|
||||
@@ -237,6 +271,14 @@ function genericUpdate(def: ResourceDef) {
|
||||
);
|
||||
}
|
||||
|
||||
if (def.preUpdate) {
|
||||
const current = getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!current) throw new Error(`${def.name} not found: ${id}`);
|
||||
def.preUpdate(updates, current);
|
||||
}
|
||||
|
||||
const setClause = Object.keys(updates)
|
||||
.map((k) => `${k} = @${k}`)
|
||||
.join(', ');
|
||||
@@ -245,7 +287,6 @@ function genericUpdate(def: ResourceDef) {
|
||||
.run({ ...updates, _id: id });
|
||||
if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`);
|
||||
|
||||
const cols = visibleColumns(def).join(', ');
|
||||
return getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,12 +24,23 @@ vi.mock('../../config.js', async () => {
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-msggroups';
|
||||
|
||||
import type { ChannelDefaults } from '../../channels/adapter.js';
|
||||
import { registerChannelAdapter } from '../../channels/channel-registry.js';
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
// Side-effect import: registers the `messaging-groups-create` command.
|
||||
import './messaging-groups.js';
|
||||
|
||||
// Registration-tier declaration (no live adapter) — the environment `ncl`
|
||||
// sees for offline instances and setup scripts.
|
||||
const declared: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
registerChannelAdapter('declchan-mg', { factory: () => null, defaults: declared });
|
||||
|
||||
describe('messaging-groups CLI create defaults instance to channel_type', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
@@ -73,3 +84,46 @@ describe('messaging-groups CLI create defaults instance to channel_type', () =>
|
||||
expect(getMessagingGroupByPlatform('telegram', '67890', 'work')?.instance).toBe('work');
|
||||
});
|
||||
});
|
||||
|
||||
describe('messaging-groups CLI create resolves unknown_sender_policy from the channel declaration', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
const create = (args: Record<string, unknown>, id: string) =>
|
||||
dispatch({ id, command: 'messaging-groups-create', args }, { caller: 'host' });
|
||||
|
||||
it('DM context takes the declared dm policy', async () => {
|
||||
const resp = await create({ channel_type: 'declchan-mg', platform_id: 'dm-1' }, 'req-d1');
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(getMessagingGroupByPlatform('declchan-mg', 'dm-1')?.unknown_sender_policy).toBe('public');
|
||||
});
|
||||
|
||||
it('group context takes the declared group policy', async () => {
|
||||
const resp = await create({ channel_type: 'declchan-mg', platform_id: 'g-1', is_group: '1' }, 'req-d2');
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(getMessagingGroupByPlatform('declchan-mg', 'g-1')?.unknown_sender_policy).toBe('request_approval');
|
||||
});
|
||||
|
||||
it('explicit --unknown-sender-policy wins over the declaration', async () => {
|
||||
const resp = await create(
|
||||
{ channel_type: 'declchan-mg', platform_id: 'dm-2', unknown_sender_policy: 'strict' },
|
||||
'req-d3',
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(getMessagingGroupByPlatform('declchan-mg', 'dm-2')?.unknown_sender_policy).toBe('strict');
|
||||
});
|
||||
|
||||
it("undeclared channels keep the legacy static 'strict' default (back-compat)", async () => {
|
||||
const resp = await create({ channel_type: 'stalechan-mg', platform_id: 's-1' }, 'req-d4');
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(getMessagingGroupByPlatform('stalechan-mg', 's-1')?.unknown_sender_policy).toBe('strict');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { resolveUnknownSenderPolicy } from '../../channels/channel-defaults.js';
|
||||
import { hasDeclaredChannelDefaults } from '../../channels/channel-registry.js';
|
||||
import { log } from '../../log.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
registerResource({
|
||||
@@ -48,7 +51,7 @@ registerResource({
|
||||
name: 'unknown_sender_policy',
|
||||
type: 'string',
|
||||
description:
|
||||
'What happens when an unrecognized sender posts. "strict" drops silently. "request_approval" sends an approval card to an admin. "public" allows anyone.',
|
||||
'What happens when an unrecognized sender posts. "strict" drops silently. "request_approval" sends an approval card to an admin. "public" allows anyone. Default: declared by the channel adapter for this context (DM vs group); "strict" when the channel has no declaration.',
|
||||
enum: ['strict', 'request_approval', 'public'],
|
||||
default: 'strict',
|
||||
updatable: true,
|
||||
@@ -63,4 +66,21 @@ registerResource({
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||
resolveDefaults: (values) => {
|
||||
if (values.unknown_sender_policy !== undefined) return;
|
||||
const channelType = String(values.channel_type);
|
||||
const channelKey = (values.instance as string | undefined) ?? channelType;
|
||||
// Static 'strict' stays the no-declaration fallback: a trunk update alone
|
||||
// must not change ncl's creation defaults for stale (undeclared) adapters.
|
||||
if (!hasDeclaredChannelDefaults(channelKey, channelType)) {
|
||||
log.warn(
|
||||
`messaging-group create: channel '${channelKey}' has no declared defaults (adapter not installed or stale) — using legacy static defaults`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// is_group carries its static default (0) only after this hook runs, so
|
||||
// treat "not provided" as the same DM context the static default means.
|
||||
const isGroup = Number(values.is_group ?? 0) === 1;
|
||||
values.unknown_sender_policy = resolveUnknownSenderPolicy(channelKey, isGroup, channelType);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Wiring creation/update against channel declarations: the resolveDefaults
|
||||
* hook fills omitted engage defaults from the adapter declaration ({name}
|
||||
* substituted), explicit flags always win, undeclared channels keep the
|
||||
* legacy static defaults (back-compat contract), and the create/update
|
||||
* validation rejects combinations that could never engage.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../log.js', () => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
// wirings' postCommit projects destinations into live session DBs — no
|
||||
// sessions run in this test, but the module must not open on-disk DB files.
|
||||
vi.mock('../../modules/agent-to-agent/write-destinations.js', () => ({
|
||||
writeDestinations: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { ChannelDefaults } from '../../channels/adapter.js';
|
||||
import { registerChannelAdapter } from '../../channels/channel-registry.js';
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup } from '../../db/index.js';
|
||||
import { createMessagingGroupAgent, getMessagingGroupAgent } from '../../db/messaging-groups.js';
|
||||
import { lookup } from '../registry.js';
|
||||
// Side-effect import: registers wirings-create / wirings-update.
|
||||
import './wirings.js';
|
||||
|
||||
const hostCtx = { caller: 'host' as const };
|
||||
const now = () => new Date().toISOString();
|
||||
|
||||
// Registration-tier declarations only — no adapter is live, which is exactly
|
||||
// the environment `ncl` sees for offline instances and setup scripts.
|
||||
const declared: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: 'hey {name}!', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
registerChannelAdapter('declchan', { factory: () => null, defaults: declared });
|
||||
|
||||
const neverDeclared: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'pattern', engagePattern: '{name}', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'never',
|
||||
};
|
||||
registerChannelAdapter('neverchan', { factory: () => null, defaults: neverDeclared });
|
||||
|
||||
function mg(id: string, channelType: string, isGroup: number) {
|
||||
createMessagingGroup({
|
||||
id,
|
||||
channel_type: channelType,
|
||||
platform_id: `pid-${id}`,
|
||||
name: null,
|
||||
is_group: isGroup,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function create(args: Record<string, unknown>) {
|
||||
return (await lookup('wirings-create')!.handler(args, hostCtx)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function update(args: Record<string, unknown>) {
|
||||
return (await lookup('wirings-update')!.handler(args, hostCtx)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
runMigrations(initTestDb());
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Helper Bot',
|
||||
folder: 'helper-bot',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
mg('mg-dm', 'declchan', 0);
|
||||
mg('mg-group', 'declchan', 1);
|
||||
mg('mg-never', 'neverchan', 1);
|
||||
mg('mg-stale', 'stalechan', 1); // no declaration anywhere
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
describe('wirings-create — declaration-derived defaults', () => {
|
||||
it('fills DM defaults from the declaration with {name} substituted', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1' });
|
||||
expect(row.engage_mode).toBe('pattern');
|
||||
expect(row.engage_pattern).toBe('hey Helper Bot!');
|
||||
});
|
||||
|
||||
it('fills group defaults from the declaration', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1' });
|
||||
expect(row.engage_mode).toBe('mention-sticky');
|
||||
const persisted = getMessagingGroupAgent(row.id as string);
|
||||
expect(persisted!.engage_pattern).toBeNull();
|
||||
});
|
||||
|
||||
it('explicit --engage-mode wins over the declaration', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', engage_mode: 'mention' });
|
||||
expect(row.engage_mode).toBe('mention');
|
||||
});
|
||||
|
||||
it('undeclared channels keep the legacy static default (back-compat)', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-stale', agent_group_id: 'ag-1' });
|
||||
expect(row.engage_mode).toBe('mention');
|
||||
expect(row.engage_pattern).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wirings-create — validation', () => {
|
||||
it('rejects pattern mode without --engage-pattern', async () => {
|
||||
await expect(
|
||||
create({ messaging_group_id: 'mg-stale', agent_group_id: 'ag-1', engage_mode: 'pattern' }),
|
||||
).rejects.toThrow(/--engage-pattern/);
|
||||
});
|
||||
|
||||
it("rejects mention modes on a channel declaring mentions: 'never'", async () => {
|
||||
await expect(
|
||||
create({ messaging_group_id: 'mg-never', agent_group_id: 'ag-1', engage_mode: 'mention' }),
|
||||
).rejects.toThrow(/mentions: 'never'/);
|
||||
});
|
||||
|
||||
it('coerces explicit mention-sticky to mention when the declared context has threads=false', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1', engage_mode: 'mention-sticky' });
|
||||
expect(row.engage_mode).toBe('mention');
|
||||
});
|
||||
|
||||
it('coerces mention-sticky when --threads false overrides a threaded declaration', async () => {
|
||||
const row = await create({
|
||||
messaging_group_id: 'mg-group',
|
||||
agent_group_id: 'ag-1',
|
||||
engage_mode: 'mention-sticky',
|
||||
threads: 'false',
|
||||
});
|
||||
expect(row.engage_mode).toBe('mention');
|
||||
expect(row.threads).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps mention-sticky when the declared group context has threads=true', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', engage_mode: 'mention-sticky' });
|
||||
expect(row.engage_mode).toBe('mention-sticky');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wirings — threads and priority columns', () => {
|
||||
it('omitted --threads stores NULL (inherit declaration)', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1' });
|
||||
expect(getMessagingGroupAgent(row.id as string)!.threads).toBeNull();
|
||||
});
|
||||
|
||||
it('--threads true/false stores 1/0', async () => {
|
||||
const on = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', threads: 'true' });
|
||||
expect(getMessagingGroupAgent(on.id as string)!.threads).toBe(1);
|
||||
const off = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1', threads: 'false' });
|
||||
expect(getMessagingGroupAgent(off.id as string)!.threads).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a non-boolean --threads value', async () => {
|
||||
await expect(create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', threads: 'bogus' })).rejects.toThrow(
|
||||
/--threads must be true or false/,
|
||||
);
|
||||
});
|
||||
|
||||
it('--priority is settable on create and defaults to 0', async () => {
|
||||
const dflt = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1' });
|
||||
expect(dflt.priority).toBe(0);
|
||||
const high = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', priority: '5' });
|
||||
expect(high.priority).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wirings-update — same validation as create', () => {
|
||||
it('rejects switching to pattern mode when no engage_pattern exists', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1' }); // sticky, no pattern
|
||||
await expect(update({ id: row.id, engage_mode: 'pattern' })).rejects.toThrow(/--engage-pattern/);
|
||||
});
|
||||
|
||||
it("rejects switching to a mention mode on a mentions:'never' channel", async () => {
|
||||
const row = await create({
|
||||
messaging_group_id: 'mg-never',
|
||||
agent_group_id: 'ag-1',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
});
|
||||
await expect(update({ id: row.id, engage_mode: 'mention' })).rejects.toThrow(/mentions: 'never'/);
|
||||
});
|
||||
|
||||
it('coerces an existing sticky wiring to mention when --threads is turned off', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1' }); // mention-sticky
|
||||
const updated = (await update({ id: row.id, threads: 'false' })) as { engage_mode: string; threads: number };
|
||||
expect(updated.threads).toBe(0);
|
||||
expect(updated.engage_mode).toBe('mention');
|
||||
});
|
||||
|
||||
it('updates threads and priority', async () => {
|
||||
const row = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1' });
|
||||
const updated = (await update({ id: row.id, threads: 'true', priority: '3' })) as {
|
||||
threads: number;
|
||||
priority: number;
|
||||
};
|
||||
expect(updated.threads).toBe(1);
|
||||
expect(updated.priority).toBe(3);
|
||||
});
|
||||
|
||||
it('allows unrelated updates to a legacy pattern row with NULL engage_pattern', async () => {
|
||||
// Rows created on main before engage_pattern defaults existed: pattern
|
||||
// mode + NULL pattern, which the router evaluates as match-all.
|
||||
createMessagingGroupAgent({
|
||||
id: 'mga-legacy',
|
||||
messaging_group_id: 'mg-stale',
|
||||
agent_group_id: 'ag-1',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: null,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
const updated = (await update({ id: 'mga-legacy', priority: '5' })) as { priority: number };
|
||||
expect(updated.priority).toBe(5);
|
||||
// The pattern fields stay untouched — no silent backfill.
|
||||
expect(getMessagingGroupAgent('mga-legacy')!.engage_pattern).toBeNull();
|
||||
|
||||
// But actually changing the pattern fields to an invalid combination
|
||||
// still rejects.
|
||||
await expect(update({ id: 'mga-legacy', engage_pattern: '' })).rejects.toThrow(/--engage-pattern/);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,30 @@
|
||||
import { ensureAgentDestinationForWiring } from '../../db/messaging-groups.js';
|
||||
import type { MessagingGroupAgent } from '../../types.js';
|
||||
import {
|
||||
resolveWiringDefaults,
|
||||
validateEngageAgainstChannel,
|
||||
type EngageValues,
|
||||
} from '../../channels/channel-defaults.js';
|
||||
import { hasDeclaredChannelDefaults } from '../../channels/channel-registry.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { ensureAgentDestinationForWiring, getMessagingGroup } from '../../db/messaging-groups.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
import { projectDestinationsToSessions } from './destinations.js';
|
||||
|
||||
function requireMessagingGroup(id: unknown): MessagingGroup {
|
||||
const mg = getMessagingGroup(String(id));
|
||||
if (!mg) throw new Error(`messaging group not found: ${id}`);
|
||||
return mg;
|
||||
}
|
||||
|
||||
/** --threads accepts true/false (or 1/0); stored as INTEGER 1/0. Omitted =
|
||||
* column NULL = inherit the channel declaration. */
|
||||
function normalizeThreads(v: unknown): number {
|
||||
if (v === true || v === 'true' || v === '1' || v === 1) return 1;
|
||||
if (v === false || v === 'false' || v === '0' || v === 0) return 0;
|
||||
throw new Error(`--threads must be true or false, got "${v}"`);
|
||||
}
|
||||
|
||||
registerResource({
|
||||
name: 'wiring',
|
||||
plural: 'wirings',
|
||||
@@ -28,7 +50,7 @@ registerResource({
|
||||
name: 'engage_mode',
|
||||
type: 'string',
|
||||
description:
|
||||
'When the agent engages. "mention" — only when @mentioned or in DMs. "mention-sticky" — once mentioned in a thread, the agent subscribes and responds to all subsequent messages in that thread without needing further mentions. "pattern" — matches every message against engage_pattern regex.',
|
||||
'When the agent engages. "mention" — only when @mentioned or in DMs. "mention-sticky" — once mentioned in a thread, the agent subscribes and responds to all subsequent messages in that thread without needing further mentions. "pattern" — matches every message against engage_pattern regex. Default: declared by the channel adapter for the target chat (DM vs group); "mention" when the channel has no declaration.',
|
||||
enum: ['pattern', 'mention', 'mention-sticky'],
|
||||
default: 'mention',
|
||||
updatable: true,
|
||||
@@ -67,9 +89,70 @@ registerResource({
|
||||
default: 'shared',
|
||||
updatable: true,
|
||||
},
|
||||
{
|
||||
name: 'threads',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Per-wiring thread override: honor platform thread ids for this wiring (per-thread sessions in groups; replies, typing, and cards land in-thread). NULL = inherit channel default. Can disable threads on a threaded platform, never enable them on a non-threaded one.',
|
||||
updatable: true,
|
||||
},
|
||||
{
|
||||
name: 'priority',
|
||||
type: 'number',
|
||||
description: 'Fanout order when multiple agents are wired to the same messaging group — higher priority first.',
|
||||
default: 0,
|
||||
updatable: true,
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||
resolveDefaults: (values) => {
|
||||
const mg = requireMessagingGroup(values.messaging_group_id);
|
||||
if (values.threads !== undefined) values.threads = normalizeThreads(values.threads);
|
||||
|
||||
const channelKey = mg.instance ?? mg.channel_type;
|
||||
// Undeclared (stale) channels: leave engage_mode unset so the static
|
||||
// 'mention' default applies afterwards — a trunk update alone must not
|
||||
// change ncl's creation defaults for adapters without a declaration.
|
||||
if (values.engage_mode === undefined) {
|
||||
if (hasDeclaredChannelDefaults(channelKey, mg.channel_type)) {
|
||||
const ag = getAgentGroup(String(values.agent_group_id));
|
||||
if (!ag) throw new Error(`agent group not found: ${values.agent_group_id}`);
|
||||
const resolved = resolveWiringDefaults(channelKey, mg.is_group === 1, ag.name, mg.channel_type);
|
||||
values.engage_mode = resolved.engage_mode;
|
||||
if (values.engage_pattern === undefined && resolved.engage_pattern !== null) {
|
||||
values.engage_pattern = resolved.engage_pattern;
|
||||
}
|
||||
} else {
|
||||
log.warn(
|
||||
`wiring create: channel '${channelKey}' has no declared defaults (adapter not installed or stale) — using legacy static defaults`,
|
||||
);
|
||||
}
|
||||
}
|
||||
validateEngageAgainstChannel(values, mg);
|
||||
},
|
||||
preUpdate: (updates, current) => {
|
||||
const mg = requireMessagingGroup(current.messaging_group_id);
|
||||
if (updates.threads !== undefined) updates.threads = normalizeThreads(updates.threads);
|
||||
|
||||
const merged: EngageValues = { ...current, ...updates };
|
||||
// Legacy rows can be engage_mode='pattern' with a NULL pattern (the
|
||||
// router treats that as match-all). Don't reject unrelated updates to
|
||||
// them — only enforce the pairing when the pattern fields change.
|
||||
if (
|
||||
updates.engage_mode === undefined &&
|
||||
updates.engage_pattern === undefined &&
|
||||
merged.engage_mode === 'pattern' &&
|
||||
(merged.engage_pattern === undefined || merged.engage_pattern === null)
|
||||
) {
|
||||
merged.engage_pattern = '.';
|
||||
}
|
||||
validateEngageAgainstChannel(merged, mg);
|
||||
// Carry the sticky→mention coercion (if any) back into the update set.
|
||||
if (merged.engage_mode !== (updates.engage_mode ?? current.engage_mode)) {
|
||||
updates.engage_mode = merged.engage_mode;
|
||||
}
|
||||
},
|
||||
postCreate: (row) => {
|
||||
// Create the companion `agent_destinations` row so the agent has a
|
||||
// local name it can address this chat by. Without this, the agent
|
||||
|
||||
@@ -19,7 +19,17 @@ const envConfig = readEnvFile([
|
||||
'ONECLI_GATEWAY_CONTAINER',
|
||||
]);
|
||||
|
||||
/**
|
||||
* @deprecated WhatsApp adapter copies now read the ASSISTANT_NAME .env key
|
||||
* directly. Re-export retained one release for stale adapter copies
|
||||
* (origin/channels whatsapp.ts:42 imports it); scheduled for deletion.
|
||||
*/
|
||||
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||
/**
|
||||
* @deprecated WhatsApp adapter copies now read the ASSISTANT_HAS_OWN_NUMBER
|
||||
* .env key directly. Re-export retained one release for stale adapter copies
|
||||
* (origin/channels whatsapp.ts:42 imports it); scheduled for deletion.
|
||||
*/
|
||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
|
||||
|
||||
|
||||
@@ -55,6 +55,22 @@ describe('migrations', () => {
|
||||
// Running again should not throw
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
it('adds messaging_group_agents.threads as a nullable, default-free override column (019)', () => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
const col = db
|
||||
.prepare(
|
||||
`SELECT type, "notnull", dflt_value FROM pragma_table_info('messaging_group_agents') WHERE name = 'threads'`,
|
||||
)
|
||||
.get() as { type: string; notnull: number; dflt_value: unknown } | undefined;
|
||||
expect(col).toBeDefined();
|
||||
// NULL must remain expressible (= inherit the adapter declaration) with
|
||||
// no default — a backfill would freeze today's behavior into rows.
|
||||
expect(col!.type).toBe('INTEGER');
|
||||
expect(col!.notnull).toBe(0);
|
||||
expect(col!.dflt_value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent Groups ──
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
/**
|
||||
* Per-wiring thread-policy override on `messaging_group_agents`.
|
||||
*
|
||||
* NULL = inherit the channel adapter's declared default for the wiring's
|
||||
* context (DM vs group); 1/0 = explicit per-wiring override, hard-ANDed with
|
||||
* the adapter's raw thread capability at router fanout (resolveThreadPolicy
|
||||
* in src/channels/channel-defaults.ts). Deliberately no backfill: existing
|
||||
* rows stay NULL and resolve through the declaration — or, for undeclared
|
||||
* adapters, the behavior-faithful fallback whose threads value tracks
|
||||
* supportsThreads — reproducing pre-migration routing exactly.
|
||||
*/
|
||||
export const migration019: Migration = {
|
||||
version: 19,
|
||||
name: 'wiring-threads-override',
|
||||
up(db) {
|
||||
db.exec(`ALTER TABLE messaging_group_agents ADD COLUMN threads INTEGER;`);
|
||||
},
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import { migration016 } from './016-messaging-group-instance.js';
|
||||
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||
import { migration018 } from './018-approvals-approver-user-id.js';
|
||||
import { migration019 } from './019-wiring-threads.js';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
@@ -50,6 +51,7 @@ export const migrations: Migration[] = [
|
||||
migration014,
|
||||
migration015,
|
||||
migration016,
|
||||
migration019,
|
||||
];
|
||||
|
||||
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
|
||||
|
||||
@@ -55,6 +55,9 @@ CREATE TABLE messaging_group_agents (
|
||||
ignored_message_policy TEXT NOT NULL DEFAULT 'drop', -- 'drop' | 'accumulate'
|
||||
session_mode TEXT DEFAULT 'shared',
|
||||
priority INTEGER DEFAULT 0,
|
||||
threads INTEGER, -- NULL = inherit the channel adapter's declared
|
||||
-- thread default; 1/0 = per-wiring override
|
||||
-- (migration 019)
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(messaging_group_id, agent_group_id)
|
||||
);
|
||||
|
||||
+221
-2
@@ -780,8 +780,198 @@ describe('router — channel instances', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('routing metadata preservation', () => {
|
||||
describe('router — per-wiring thread policy', () => {
|
||||
// Slack-like threaded adapter on a unique channel type (the registry maps
|
||||
// are module-global; unique names avoid cross-test collisions).
|
||||
const makeThreadedAdapter = () => ({
|
||||
name: 'tp-slack',
|
||||
channelType: 'tp-slack',
|
||||
supportsThreads: true,
|
||||
async setup() {},
|
||||
async teardown() {},
|
||||
isConnected: () => true,
|
||||
async deliver() {
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
createAgentGroup({
|
||||
id: 'ag-tp',
|
||||
name: 'Thread Agent',
|
||||
folder: 'thread-agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-tp',
|
||||
channel_type: 'tp-slack',
|
||||
platform_id: 'tp:C1',
|
||||
name: 'Threaded chat',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroupAgent({
|
||||
id: 'mga-tp',
|
||||
messaging_group_id: 'mg-tp',
|
||||
agent_group_id: 'ag-tp',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
});
|
||||
});
|
||||
|
||||
async function withThreadedAdapter(fn: () => Promise<void>): Promise<void> {
|
||||
const { registerChannelAdapter, initChannelAdapters, teardownChannelAdapters } =
|
||||
await import('./channels/channel-registry.js');
|
||||
registerChannelAdapter('tp-slack', { factory: makeThreadedAdapter });
|
||||
await initChannelAdapters(() => ({
|
||||
onInbound: () => {},
|
||||
onInboundEvent: () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: () => {},
|
||||
}));
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
await teardownChannelAdapters();
|
||||
}
|
||||
}
|
||||
|
||||
const threadedEvent = (id: string): InboundEvent => ({
|
||||
channelType: 'tp-slack',
|
||||
platformId: 'tp:C1',
|
||||
threadId: 'thread-42',
|
||||
message: {
|
||||
id,
|
||||
kind: 'chat',
|
||||
content: JSON.stringify({ sender: 'U', text: 'hi' }),
|
||||
timestamp: now(),
|
||||
isGroup: true,
|
||||
},
|
||||
});
|
||||
|
||||
it('NULL-threads wiring (inherit) on a threaded adapter keeps thread routing as before', async () => {
|
||||
await withThreadedAdapter(async () => {
|
||||
const { routeInbound } = await import('./router.js');
|
||||
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
|
||||
|
||||
await routeInbound(threadedEvent('msg-null-threads'));
|
||||
|
||||
// threads=NULL inherits the (fallback) declaration → supportsThreads →
|
||||
// per-thread session with the platform thread id, message addressed
|
||||
// in-thread. Identical to pre-declaration routing.
|
||||
const sessions = getSessionsByAgentGroup('ag-tp');
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].thread_id).toBe('thread-42');
|
||||
|
||||
const db = new Database(inboundDbPath('ag-tp', sessions[0].id));
|
||||
const row = db.prepare('SELECT thread_id FROM messages_in').get() as { thread_id: string | null };
|
||||
db.close();
|
||||
expect(row.thread_id).toBe('thread-42');
|
||||
});
|
||||
});
|
||||
|
||||
it('wiring threads=0 nulls the event-derived thread for session and delivery', async () => {
|
||||
getDb().prepare("UPDATE messaging_group_agents SET threads = 0 WHERE id = 'mga-tp'").run();
|
||||
|
||||
await withThreadedAdapter(async () => {
|
||||
const { routeInbound } = await import('./router.js');
|
||||
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
|
||||
|
||||
await routeInbound(threadedEvent('msg-opt-out'));
|
||||
|
||||
// Session collapses (no per-thread force, thread id stripped) and the
|
||||
// reply address is top-level.
|
||||
const sessions = getSessionsByAgentGroup('ag-tp');
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].thread_id).toBeNull();
|
||||
|
||||
const db = new Database(inboundDbPath('ag-tp', sessions[0].id));
|
||||
const row = db.prepare('SELECT thread_id FROM messages_in').get() as { thread_id: string | null };
|
||||
db.close();
|
||||
expect(row.thread_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('wiring threads=0 never strips replyTo (operator intent)', async () => {
|
||||
getDb().prepare("UPDATE messaging_group_agents SET threads = 0 WHERE id = 'mga-tp'").run();
|
||||
|
||||
await withThreadedAdapter(async () => {
|
||||
const { routeInbound } = await import('./router.js');
|
||||
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
|
||||
|
||||
await routeInbound({
|
||||
...threadedEvent('msg-replyto'),
|
||||
replyTo: { channelType: 'cli', platformId: 'cli:operator', threadId: 'term-1' },
|
||||
});
|
||||
|
||||
const sessions = getSessionsByAgentGroup('ag-tp');
|
||||
expect(sessions).toHaveLength(1);
|
||||
const db = new Database(inboundDbPath('ag-tp', sessions[0].id));
|
||||
const row = db.prepare('SELECT channel_type, thread_id FROM messages_in').get() as {
|
||||
channel_type: string;
|
||||
thread_id: string | null;
|
||||
};
|
||||
db.close();
|
||||
// The reply address is the operator's, thread id intact — only the
|
||||
// event-derived address is policy-stripped.
|
||||
expect(row.channel_type).toBe('cli');
|
||||
expect(row.thread_id).toBe('term-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-create takes unknown_sender_policy from the declaration and falls back faithfully', async () => {
|
||||
const { registerChannelAdapter } = await import('./channels/channel-registry.js');
|
||||
const { routeInbound } = await import('./router.js');
|
||||
const { getMessagingGroupByPlatform } = await import('./db/messaging-groups.js');
|
||||
|
||||
// Registration-tier declaration is enough — no live adapter needed.
|
||||
registerChannelAdapter('tp-declared', {
|
||||
factory: () => null,
|
||||
defaults: {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'public' },
|
||||
mentions: 'platform',
|
||||
},
|
||||
});
|
||||
|
||||
const mention = (channelType: string, platformId: string, isGroup: boolean): InboundEvent => ({
|
||||
channelType,
|
||||
platformId,
|
||||
threadId: null,
|
||||
message: {
|
||||
id: `msg-${platformId}`,
|
||||
kind: 'chat',
|
||||
content: JSON.stringify({ sender: 'U', text: '@bot hi' }),
|
||||
timestamp: now(),
|
||||
isMention: true,
|
||||
isGroup,
|
||||
},
|
||||
});
|
||||
|
||||
// Declared adapter: group context reads the group declaration...
|
||||
await routeInbound(mention('tp-declared', 'tp:G1', true));
|
||||
expect(getMessagingGroupByPlatform('tp-declared', 'tp:G1')!.unknown_sender_policy).toBe('public');
|
||||
|
||||
// ...and DM context reads the dm declaration.
|
||||
await routeInbound(mention('tp-declared', 'tp:D1', false));
|
||||
expect(getMessagingGroupByPlatform('tp-declared', 'tp:D1')!.unknown_sender_policy).toBe('strict');
|
||||
|
||||
// Undeclared channel: the behavior-faithful fallback reproduces the
|
||||
// historical hardcoded 'request_approval'.
|
||||
await routeInbound(mention('tp-undeclared', 'tp:U1', true));
|
||||
expect(getMessagingGroupByPlatform('tp-undeclared', 'tp:U1')!.unknown_sender_policy).toBe('request_approval');
|
||||
});
|
||||
});
|
||||
|
||||
describe('routing metadata preservation', () => {
|
||||
beforeEach(async () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Test Agent',
|
||||
@@ -810,6 +1000,34 @@ describe('routing metadata preservation', () => {
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
});
|
||||
// A live threaded adapter, matching real Discord routing — inbound
|
||||
// platform events always have their receiving adapter live, and the
|
||||
// per-wiring thread policy hard-ANDs the live capability.
|
||||
const { registerChannelAdapter, initChannelAdapters } = await import('./channels/channel-registry.js');
|
||||
registerChannelAdapter('discord', {
|
||||
factory: () => ({
|
||||
name: 'discord',
|
||||
channelType: 'discord',
|
||||
supportsThreads: true,
|
||||
async setup() {},
|
||||
async teardown() {},
|
||||
isConnected: () => true,
|
||||
async deliver() {
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
});
|
||||
await initChannelAdapters(() => ({
|
||||
onInbound: () => {},
|
||||
onInboundEvent: () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: () => {},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { teardownChannelAdapters } = await import('./channels/channel-registry.js');
|
||||
await teardownChannelAdapters();
|
||||
});
|
||||
|
||||
it('routed message carries platformId, channelType, threadId on the messages_in row', async () => {
|
||||
@@ -822,7 +1040,8 @@ describe('routing metadata preservation', () => {
|
||||
message: { id: 'msg-r1', kind: 'chat', content: JSON.stringify({ sender: 'A', text: 'hi' }), timestamp: now() },
|
||||
});
|
||||
|
||||
const session = findSession('mg-1', null);
|
||||
// Threaded adapter in a group chat forces a per-thread session.
|
||||
const session = findSession('mg-1', 'thread-42');
|
||||
const db = new Database(inboundDbPath('ag-1', session!.id));
|
||||
const row = db
|
||||
.prepare('SELECT platform_id, channel_type, thread_id FROM messages_in WHERE id LIKE ?')
|
||||
|
||||
@@ -19,9 +19,23 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { createMessagingGroup, getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
|
||||
import { registerChannelAdapter } from '../../channels/channel-registry.js';
|
||||
import type { ChannelDefaults } from '../../channels/adapter.js';
|
||||
import { upsertUser } from './db/users.js';
|
||||
import { grantRole } from './db/user-roles.js';
|
||||
|
||||
// Registration-tier declaration for the fixture channel — a threaded platform
|
||||
// whose declared defaults match the historical card-flow behavior
|
||||
// (mention-sticky groups, pattern '.' DMs). Without it, the no-live-adapter
|
||||
// fallback resolves threads=false and coerces sticky → mention.
|
||||
// Registry maps are module-global; keep channel names unique per test file.
|
||||
const telegramDefaults: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
registerChannelAdapter('telegram', { factory: () => null, defaults: telegramDefaults });
|
||||
|
||||
// Mock container runner — prevent actual docker spawn.
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -113,13 +127,14 @@ function groupMention(platformId: string, text = '@bot hello') {
|
||||
return {
|
||||
channelType: 'telegram',
|
||||
platformId,
|
||||
threadId: 'thread-1', // non-null → is_group=true per channel-approval default-picker logic
|
||||
threadId: 'thread-1',
|
||||
message: {
|
||||
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat' as const,
|
||||
content: JSON.stringify({ senderId: 'caller', senderName: 'Caller', text }),
|
||||
timestamp: now(),
|
||||
isMention: true,
|
||||
isGroup: true, // group context comes from the adapter flag, never threadId
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -153,6 +168,8 @@ describe('unknown-channel registration flow', () => {
|
||||
expect(kind).toBe('chat-sdk');
|
||||
const payload = JSON.parse(content as string);
|
||||
expect(payload.type).toBe('ask_question');
|
||||
// Card tells the approver the resolved engage rule.
|
||||
expect(payload.question).toContain('will respond to @-mentions in this group');
|
||||
// Single-agent card offers a direct "Connect to <name>" button.
|
||||
const connectOption = payload.options.find((o: { value: string }) => o.value.startsWith('connect:'));
|
||||
expect(connectOption).toBeDefined();
|
||||
@@ -171,6 +188,8 @@ describe('unknown-channel registration flow', () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(deliverMock).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse(deliverMock.mock.calls[0][4] as string) as { question: string };
|
||||
expect(payload.question).toContain('will respond to all messages');
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
|
||||
expect(count).toBe(1);
|
||||
@@ -228,7 +247,7 @@ describe('unknown-channel registration flow', () => {
|
||||
agent_group_id: string;
|
||||
};
|
||||
expect(mga).toBeDefined();
|
||||
expect(mga.engage_mode).toBe('mention-sticky'); // group (threadId != null)
|
||||
expect(mga.engage_mode).toBe('mention-sticky'); // declared group default (threads:true keeps sticky)
|
||||
expect(mga.engage_pattern).toBeNull();
|
||||
expect(mga.sender_scope).toBe('known');
|
||||
expect(mga.ignored_message_policy).toBe('accumulate');
|
||||
@@ -279,6 +298,135 @@ describe('unknown-channel registration flow', () => {
|
||||
expect(mga.engage_pattern).toBe('.');
|
||||
});
|
||||
|
||||
// WhatsApp-like platform: groups exist but thread ids don't (threadId is
|
||||
// always null), and the adapter is undeclared (stale skill-installed copy)
|
||||
// so resolution goes through the behavior-faithful fallback. This is the
|
||||
// one deliberate behavior change of the defaults work: the old
|
||||
// `threadId !== null` heuristic misread these groups as DMs and wired
|
||||
// pattern '.'.
|
||||
function waGroupMention(platformId: string) {
|
||||
return {
|
||||
channelType: 'wamock',
|
||||
platformId,
|
||||
threadId: null,
|
||||
message: {
|
||||
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat' as const,
|
||||
content: JSON.stringify({ senderId: 'caller', senderName: 'Caller', text: '@bot hi' }),
|
||||
timestamp: now(),
|
||||
isMention: true,
|
||||
isGroup: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function approvePending(agentGroupId = 'ag-1') {
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
|
||||
messaging_group_id: string;
|
||||
};
|
||||
expect(pending).toBeDefined();
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: `connect:${agentGroupId}`,
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
return pending.messaging_group_id;
|
||||
}
|
||||
|
||||
it('non-threaded group (isGroup flag, null threadId) wires the GROUP default, sticky coerced to mention', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
await routeInbound(waGroupMention('wa-group-1'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const mgId = await approvePending();
|
||||
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const mga = getDb()
|
||||
.prepare('SELECT engage_mode, engage_pattern FROM messaging_group_agents WHERE messaging_group_id = ?')
|
||||
.get(mgId) as { engage_mode: string; engage_pattern: string | null };
|
||||
// Faithful fallback group default is mention-sticky, but with no live
|
||||
// adapter threads resolve false → coerced to plain mention. NOT the old
|
||||
// pattern '.' DM misclassification.
|
||||
expect(mga.engage_mode).toBe('mention');
|
||||
expect(mga.engage_pattern).toBeNull();
|
||||
});
|
||||
|
||||
it('DM on an undeclared channel stays pattern "." through the faithful fallback', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
await routeInbound({
|
||||
...waGroupMention('wa-dm-1'),
|
||||
message: { ...waGroupMention('wa-dm-1').message, isGroup: false },
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const mgId = await approvePending();
|
||||
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const mga = getDb()
|
||||
.prepare('SELECT engage_mode, engage_pattern FROM messaging_group_agents WHERE messaging_group_id = ?')
|
||||
.get(mgId) as { engage_mode: string; engage_pattern: string | null };
|
||||
expect(mga.engage_mode).toBe('pattern');
|
||||
expect(mga.engage_pattern).toBe('.');
|
||||
});
|
||||
|
||||
it('connect-existing and new-agent approve paths produce identical wirings', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
|
||||
// Path 1: connect to existing agent.
|
||||
await routeInbound(groupMention('chat-path-connect'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const mgIdConnect = await approvePending();
|
||||
|
||||
// Path 2: new agent via free-text name reply.
|
||||
await routeInbound(groupMention('chat-path-newagent'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
|
||||
messaging_group_id: string;
|
||||
};
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'new_agent',
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
// Owner replies with the agent name in their DM — interceptor wires.
|
||||
await routeInbound({
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
message: {
|
||||
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat' as const,
|
||||
content: JSON.stringify({ senderId: 'owner', text: 'Bravo' }),
|
||||
timestamp: now(),
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const select =
|
||||
'SELECT engage_mode, engage_pattern, sender_scope, ignored_message_policy, session_mode, priority ' +
|
||||
'FROM messaging_group_agents WHERE messaging_group_id = ?';
|
||||
const viaConnect = getDb().prepare(select).get(mgIdConnect);
|
||||
const viaNewAgent = getDb().prepare(select).get(pending.messaging_group_id);
|
||||
expect(viaNewAgent).toBeDefined();
|
||||
expect(viaNewAgent).toEqual(viaConnect);
|
||||
});
|
||||
|
||||
it('deny → sets denied_at; future mentions drop silently without a second card', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
* so it can be re-routed on connect/create.
|
||||
*
|
||||
* On connect (handler in index.ts):
|
||||
* - Create `messaging_group_agents` with defaults
|
||||
* (mention-sticky for groups / pattern='.' for DMs,
|
||||
* - Create `messaging_group_agents` with the channel's declared engage
|
||||
* defaults (resolveWiringDefaults, DM vs group context;
|
||||
* sender_scope='known', ignored_message_policy='accumulate')
|
||||
* - Add the triggering sender to `agent_group_members` so sender_scope
|
||||
* doesn't bounce the replayed message into a sender-approval cascade
|
||||
@@ -45,6 +45,7 @@
|
||||
* - Delivery adapter missing.
|
||||
*/
|
||||
import { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js';
|
||||
import { resolveWiringDefaults } from '../../channels/channel-defaults.js';
|
||||
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js';
|
||||
import { getChannelAdapter } from '../../channels/channel-registry.js';
|
||||
import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js';
|
||||
@@ -121,13 +122,39 @@ function buildQuestionText(
|
||||
senderName: string | undefined,
|
||||
channelName: string | null,
|
||||
channelType: string,
|
||||
ruleNote: string | null,
|
||||
): string {
|
||||
const who = senderName ?? 'Someone';
|
||||
const note = ruleNote ? ` If connected, the agent ${ruleNote}.` : '';
|
||||
if (isGroup) {
|
||||
const where = channelName ? `${channelName} on ${channelType}` : `a ${channelType} channel`;
|
||||
return `${who} mentioned your bot in ${where}. How would you like to handle this channel?`;
|
||||
return `${who} mentioned your bot in ${where}.${note} How would you like to handle this channel?`;
|
||||
}
|
||||
return `${who} sent your bot a DM on ${channelType}.${note} How would you like to handle it?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human summary of the engage rule an approval would create, from the same
|
||||
* resolution the wire step uses. Null when the declaration is unresolvable
|
||||
* (mis-declared pattern mode) — the card still works without the preview.
|
||||
*/
|
||||
function describeResolvedRule(
|
||||
channelKey: string,
|
||||
isGroup: boolean,
|
||||
agentGroupName: string,
|
||||
channelType: string,
|
||||
): string | null {
|
||||
try {
|
||||
const engage = resolveWiringDefaults(channelKey, isGroup, agentGroupName, channelType);
|
||||
if (engage.engage_mode !== 'pattern') {
|
||||
return isGroup ? 'will respond to @-mentions in this group' : 'will respond to @-mentions';
|
||||
}
|
||||
return engage.engage_pattern === '.'
|
||||
? 'will respond to all messages'
|
||||
: `will respond to messages matching ${engage.engage_pattern}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return `${who} sent your bot a DM on ${channelType}. How would you like to handle it?`;
|
||||
}
|
||||
|
||||
// ── Main flow ──
|
||||
@@ -168,9 +195,10 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
const originMg = getMessagingGroup(messagingGroupId);
|
||||
const originChannelType = originMg?.channel_type ?? '';
|
||||
|
||||
// Resolve channel name if not yet persisted.
|
||||
// Resolve channel name if not yet persisted. Key by instance so a named
|
||||
// instance's own adapter (and bot identity) does the lookup.
|
||||
if (originMg && !originMg.name) {
|
||||
const channelAdapter = getChannelAdapter(originChannelType);
|
||||
const channelAdapter = getChannelAdapter(originMg.instance ?? originMg.channel_type);
|
||||
if (channelAdapter?.resolveChannelName) {
|
||||
try {
|
||||
const name = await channelAdapter.resolveChannelName(originMg.platform_id);
|
||||
@@ -205,7 +233,16 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
|
||||
const channelName = originMg?.name ?? null;
|
||||
const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
|
||||
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
|
||||
// Preview the engage rule an approval would create. The reference group's
|
||||
// name only feeds {name} pattern substitution — a best-effort preview when
|
||||
// the approver ends up picking a different agent.
|
||||
const ruleNote = describeResolvedRule(
|
||||
originMg?.instance ?? originChannelType,
|
||||
isGroup,
|
||||
referenceGroup.name,
|
||||
originChannelType,
|
||||
);
|
||||
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType, ruleNote);
|
||||
const options = normalizeOptions(buildApprovalOptions(agentGroups, delivery.userId));
|
||||
|
||||
createPendingChannelApproval({
|
||||
|
||||
+108
-116
@@ -17,7 +17,8 @@
|
||||
*/
|
||||
import { recordDroppedMessage } from '../../db/dropped-messages.js';
|
||||
import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js';
|
||||
import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
|
||||
import { createMessagingGroupAgent, getMessagingGroup, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
|
||||
import { resolveWiringDefaults } from '../../channels/channel-defaults.js';
|
||||
import {
|
||||
routeInbound,
|
||||
setAccessGate,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
deletePendingChannelApproval,
|
||||
getPendingChannelApproval,
|
||||
updatePendingChannelApprovalCard,
|
||||
type PendingChannelApproval,
|
||||
} from './db/pending-channel-approvals.js';
|
||||
import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js';
|
||||
import { hasAdminPrivilege } from './db/user-roles.js';
|
||||
@@ -293,6 +295,104 @@ setChannelRequestGate(async (mg, event) => {
|
||||
await requestChannelApproval({ messagingGroupId: mg.id, event });
|
||||
});
|
||||
|
||||
/**
|
||||
* Wire an approved channel to an agent group and replay the stored event.
|
||||
* Shared by both approve paths (connect-existing button, free-text new-agent
|
||||
* name reply) so they produce identical wirings. Returns true when the wiring
|
||||
* was created — callers must not confirm success to the approver otherwise.
|
||||
*
|
||||
* Engage defaults come from the channel's declared defaults (DM vs group
|
||||
* context). isGroup uses the adapter's own flag with the persisted row as
|
||||
* fallback — never `threadId !== null` (DM sub-threads exist on Slack/Discord;
|
||||
* non-threaded group platforms like WhatsApp have null threadIds in groups).
|
||||
*/
|
||||
async function wireApprovedChannel(
|
||||
row: PendingChannelApproval,
|
||||
agentGroupId: string,
|
||||
approverId: string,
|
||||
): Promise<boolean> {
|
||||
let event: InboundEvent;
|
||||
try {
|
||||
event = JSON.parse(row.original_message) as InboundEvent;
|
||||
} catch (err) {
|
||||
log.error('Channel registration: failed to parse stored event', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
const mg = getMessagingGroup(row.messaging_group_id);
|
||||
const isGroup = event.message.isGroup ?? mg?.is_group === 1;
|
||||
const agentGroupName = getAgentGroup(agentGroupId)?.name ?? '';
|
||||
|
||||
let engage: { engage_mode: MessagingGroupAgent['engage_mode']; engage_pattern: string | null };
|
||||
try {
|
||||
engage = resolveWiringDefaults(
|
||||
mg?.instance ?? mg?.channel_type ?? event.channelType,
|
||||
isGroup,
|
||||
agentGroupName,
|
||||
mg?.channel_type ?? event.channelType,
|
||||
);
|
||||
} catch (err) {
|
||||
// Mis-declared adapter (pattern mode without a pattern). Drop the pending
|
||||
// row so a future mention can retry once the declaration is fixed.
|
||||
log.error('Channel registration: channel defaults unresolvable', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: row.messaging_group_id,
|
||||
agent_group_id: agentGroupId,
|
||||
engage_mode: engage.engage_mode,
|
||||
engage_pattern: engage.engage_pattern,
|
||||
// Deliberate card-flow choices, not channel defaults: the triggering
|
||||
// sender is auto-admitted below, so 'known' keeps other strangers gated;
|
||||
// 'accumulate' / 'shared' / priority 0 are the flow's fixed semantics.
|
||||
sender_scope: 'known',
|
||||
ignored_message_policy: 'accumulate',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
log.info('Channel registration approved — wiring created', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
agentGroupId,
|
||||
mgaId,
|
||||
engageMode: engage.engage_mode,
|
||||
approverId,
|
||||
});
|
||||
|
||||
const senderUserId = extractAndUpsertUser(event);
|
||||
if (senderUserId) {
|
||||
addMember({
|
||||
user_id: senderUserId,
|
||||
agent_group_id: agentGroupId,
|
||||
added_by: approverId,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
|
||||
try {
|
||||
await routeInbound(event);
|
||||
} catch (err) {
|
||||
log.error('Failed to replay message after channel approval', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response handler for the unknown-channel registration card.
|
||||
*
|
||||
@@ -455,63 +555,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
}
|
||||
|
||||
// ── Wire + replay (shared path for connect and create) ──
|
||||
let event: InboundEvent;
|
||||
try {
|
||||
event = JSON.parse(row.original_message) as InboundEvent;
|
||||
} catch (err) {
|
||||
log.error('Channel registration: failed to parse stored event', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
const isGroup = event.threadId !== null;
|
||||
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
|
||||
const engagePattern = isGroup ? null : '.';
|
||||
|
||||
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: row.messaging_group_id,
|
||||
agent_group_id: targetAgentGroupId,
|
||||
engage_mode: engageMode,
|
||||
engage_pattern: engagePattern,
|
||||
sender_scope: 'known',
|
||||
ignored_message_policy: 'accumulate',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
log.info('Channel registration approved — wiring created', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
agentGroupId: targetAgentGroupId,
|
||||
mgaId,
|
||||
engageMode,
|
||||
approverId,
|
||||
});
|
||||
|
||||
const senderUserId = extractAndUpsertUser(event);
|
||||
if (senderUserId) {
|
||||
addMember({
|
||||
user_id: senderUserId,
|
||||
agent_group_id: targetAgentGroupId,
|
||||
added_by: approverId,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
|
||||
try {
|
||||
await routeInbound(event);
|
||||
} catch (err) {
|
||||
log.error('Failed to replay message after channel approval', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
await wireApprovedChannel(row, targetAgentGroupId, approverId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -555,63 +599,7 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||
folder: ag.folder,
|
||||
});
|
||||
|
||||
let originalEvent: InboundEvent;
|
||||
try {
|
||||
originalEvent = JSON.parse(row.original_message) as InboundEvent;
|
||||
} catch (err) {
|
||||
log.error('Channel registration: failed to parse stored event', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
const isGroup = originalEvent.threadId !== null;
|
||||
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
|
||||
const engagePattern = isGroup ? null : '.';
|
||||
|
||||
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: row.messaging_group_id,
|
||||
agent_group_id: ag.id,
|
||||
engage_mode: engageMode,
|
||||
engage_pattern: engagePattern,
|
||||
sender_scope: 'known',
|
||||
ignored_message_policy: 'accumulate',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
log.info('Channel registration approved — wiring created', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
agentGroupId: ag.id,
|
||||
mgaId,
|
||||
engageMode,
|
||||
approverId: userId,
|
||||
});
|
||||
|
||||
const senderUserId = extractAndUpsertUser(originalEvent);
|
||||
if (senderUserId) {
|
||||
addMember({
|
||||
user_id: senderUserId,
|
||||
agent_group_id: ag.id,
|
||||
added_by: userId,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
|
||||
try {
|
||||
await routeInbound(originalEvent);
|
||||
} catch (err) {
|
||||
log.error('Failed to replay message after channel approval', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
const wired = await wireApprovedChannel(row, ag.id, userId);
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
if (adapter) {
|
||||
@@ -623,7 +611,11 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||
dm.platform_id,
|
||||
null,
|
||||
'chat-sdk',
|
||||
JSON.stringify({ text: `✅ Agent "${ag.name}" created and connected.` }),
|
||||
JSON.stringify({
|
||||
text: wired
|
||||
? `✅ Agent "${ag.name}" created and connected.`
|
||||
: `⚠️ Agent "${ag.name}" was created but the channel couldn't be connected — check the host logs.`,
|
||||
}),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -90,6 +90,10 @@ export async function ensureUserDm(userId: string): Promise<MessagingGroup | nul
|
||||
platform_id: dmPlatformId,
|
||||
name: user.display_name,
|
||||
is_group: 0,
|
||||
// Deliberately 'strict', NOT the channel's declared DM policy: this row
|
||||
// backs a host-initiated DM to a known privileged user (approver,
|
||||
// admin). Consulting the declaration would let a 'public' DM
|
||||
// declaration open the approval-delivery channel to strangers.
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
+61
-23
@@ -17,7 +17,8 @@
|
||||
* drops (no agent wired, no trigger match); the access gate writes rows
|
||||
* for policy refusals.
|
||||
*/
|
||||
import { getChannelAdapter } from './channels/channel-registry.js';
|
||||
import { getChannelAdapter, getChannelDefaults } from './channels/channel-registry.js';
|
||||
import { resolveThreadPolicy, resolveUnknownSenderPolicy } from './channels/channel-defaults.js';
|
||||
import { gateCommand } from './command-gate.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { recordDroppedMessage } from './db/dropped-messages.js';
|
||||
@@ -208,7 +209,15 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
instance: event.instance ?? event.channelType,
|
||||
name: null,
|
||||
is_group: event.message.isGroup ? 1 : 0,
|
||||
unknown_sender_policy: 'request_approval',
|
||||
// Policy from the receiving channel's declared defaults (DM vs group
|
||||
// context); undeclared adapters resolve through the behavior-faithful
|
||||
// fallback, which is 'request_approval' in both contexts — identical
|
||||
// to the historical hardcode.
|
||||
unknown_sender_policy: resolveUnknownSenderPolicy(
|
||||
event.instance ?? event.channelType,
|
||||
event.message.isGroup === true,
|
||||
event.channelType,
|
||||
),
|
||||
denied_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
@@ -289,6 +298,14 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
const parsed = safeParseContent(event.message.content);
|
||||
const messageText = parsed.text ?? '';
|
||||
|
||||
// Per-wiring thread policy inputs, resolved once per event. Each wiring's
|
||||
// threads override (NULL = inherit) resolves against the channel's declared
|
||||
// defaults, hard-bounded by the live adapter's raw capability. Undeclared
|
||||
// adapters resolve through the behavior-faithful fallback, so a NULL-threads
|
||||
// wiring reproduces the historical supportsThreads-derived routing exactly.
|
||||
const channelDefaults = getChannelDefaults(mg.instance ?? mg.channel_type, mg.channel_type);
|
||||
const supportsThreads = adapter?.supportsThreads === true;
|
||||
|
||||
let engagedCount = 0;
|
||||
let accumulatedCount = 0;
|
||||
let subscribed = false;
|
||||
@@ -297,33 +314,49 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
const agentGroup = getAgentGroup(agent.agent_group_id);
|
||||
if (!agentGroup) continue;
|
||||
|
||||
const engages = evaluateEngage(agent, messageText, isMention, mg, event.threadId);
|
||||
// Effective thread id for THIS wiring: the event-derived address is
|
||||
// policy-stripped when the wiring (or its channel declaration) opts out
|
||||
// of threads. event.replyTo is operator intent from the CLI admin
|
||||
// transport and is never nulled. Guard: platform thread ids must never
|
||||
// collide with the reserved 'system:%' session namespace
|
||||
// (src/db/sessions.ts) — they are platform-native identifiers, and this
|
||||
// is the only place an inbound thread id enters session resolution.
|
||||
const threadsEnabled = resolveThreadPolicy(
|
||||
agent.threads ?? null,
|
||||
channelDefaults,
|
||||
mg.is_group === 1,
|
||||
supportsThreads,
|
||||
);
|
||||
const effectiveThreadId = threadsEnabled ? event.threadId : null;
|
||||
|
||||
const engages = evaluateEngage(agent, messageText, isMention, mg, effectiveThreadId);
|
||||
|
||||
const accessOk = engages && (!accessGate || accessGate(event, userId, mg, agent.agent_group_id).allowed);
|
||||
const scopeOk = engages && (!senderScopeGate || senderScopeGate(event, userId, mg, agent).allowed);
|
||||
|
||||
if (engages && accessOk && scopeOk) {
|
||||
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, true);
|
||||
await deliverToAgent(agent, agentGroup, mg, event, userId, threadsEnabled, effectiveThreadId, true);
|
||||
engagedCount++;
|
||||
|
||||
// Mention-sticky: ask the adapter to subscribe the thread so the
|
||||
// platform's subscribed-message path carries follow-ups without
|
||||
// requiring another @mention. Threaded-adapter only; DMs and
|
||||
// non-threaded platforms skip.
|
||||
// requiring another @mention. Uses this wiring's OWN effective thread
|
||||
// id — a non-null value already implies the adapter supports threads
|
||||
// (resolveThreadPolicy hard-ANDs the capability). DMs, non-threaded
|
||||
// platforms, and thread-opted-out wirings skip.
|
||||
if (
|
||||
!subscribed &&
|
||||
agent.engage_mode === 'mention-sticky' &&
|
||||
adapter?.supportsThreads &&
|
||||
adapter.subscribe &&
|
||||
event.threadId !== null &&
|
||||
adapter?.subscribe &&
|
||||
effectiveThreadId !== null &&
|
||||
mg.is_group !== 0
|
||||
) {
|
||||
subscribed = true;
|
||||
// Fire-and-forget — subscribe is platform-side bookkeeping and
|
||||
// shouldn't block message routing. Errors are logged inside the
|
||||
// adapter (or by the promise rejection handler below).
|
||||
void adapter.subscribe(event.platformId, event.threadId).catch((err) => {
|
||||
log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: event.threadId, err });
|
||||
void adapter.subscribe(event.platformId, effectiveThreadId).catch((err) => {
|
||||
log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: effectiveThreadId, err });
|
||||
});
|
||||
}
|
||||
} else if (agent.ignored_message_policy === 'accumulate' && !(engages && (!accessOk || !scopeOk))) {
|
||||
@@ -334,7 +367,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
// message (which also stages their attachments to disk via
|
||||
// writeSessionMessage → extractAttachmentFiles) is exactly what the
|
||||
// gate is meant to prevent.
|
||||
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false);
|
||||
await deliverToAgent(agent, agentGroup, mg, event, userId, threadsEnabled, effectiveThreadId, false);
|
||||
accumulatedCount++;
|
||||
} else {
|
||||
log.debug('Message not engaged for agent (drop policy)', {
|
||||
@@ -419,28 +452,33 @@ async function deliverToAgent(
|
||||
mg: MessagingGroup,
|
||||
event: InboundEvent,
|
||||
userId: string | null,
|
||||
adapterSupportsThreads: boolean,
|
||||
threadsEnabled: boolean,
|
||||
effectiveThreadId: string | null,
|
||||
wake: boolean,
|
||||
): Promise<void> {
|
||||
// Apply the adapter thread policy: threaded adapter in a group chat →
|
||||
// per-thread session regardless of wiring. agent-shared preserved (it's
|
||||
// a cross-channel directive the adapter doesn't know about). DMs collapse
|
||||
// sub-threads to one session (is_group=0 short-circuit).
|
||||
// Apply the resolved thread policy (wiring override AND channel declaration
|
||||
// AND adapter capability — resolveThreadPolicy at fanout): thread-enabled
|
||||
// wiring in a group chat → per-thread session regardless of wiring
|
||||
// session_mode. agent-shared preserved (it's a cross-channel directive the
|
||||
// adapter doesn't know about). DMs collapse sub-threads to one session
|
||||
// (is_group=0 short-circuit).
|
||||
let effectiveSessionMode = agent.session_mode;
|
||||
if (adapterSupportsThreads && effectiveSessionMode !== 'agent-shared' && mg.is_group !== 0) {
|
||||
if (threadsEnabled && effectiveSessionMode !== 'agent-shared' && mg.is_group !== 0) {
|
||||
effectiveSessionMode = 'per-thread';
|
||||
}
|
||||
|
||||
const { session, created } = resolveSession(agent.agent_group_id, mg.id, event.threadId, effectiveSessionMode);
|
||||
const { session, created } = resolveSession(agent.agent_group_id, mg.id, effectiveThreadId, effectiveSessionMode);
|
||||
|
||||
// The inbound row's (channel_type, platform_id, thread_id) is the address
|
||||
// the agent's reply will be delivered to. Normally it mirrors the source
|
||||
// (stamped from the event). When the caller supplied `replyTo` (CLI admin
|
||||
// transport acting on operator intent), the reply is redirected there.
|
||||
// (stamped from the event, with the wiring's thread policy applied). When
|
||||
// the caller supplied `replyTo` (CLI admin transport acting on operator
|
||||
// intent), the reply is redirected there — replyTo is exempt from
|
||||
// thread-policy stripping.
|
||||
const deliveryAddr = event.replyTo ?? {
|
||||
channelType: event.channelType,
|
||||
platformId: event.platformId,
|
||||
threadId: event.threadId,
|
||||
threadId: effectiveThreadId,
|
||||
};
|
||||
|
||||
// Command gate: classify slash commands before they reach the container.
|
||||
@@ -497,7 +535,7 @@ async function deliverToAgent(
|
||||
session.agent_group_id,
|
||||
event.channelType,
|
||||
event.platformId,
|
||||
event.threadId,
|
||||
effectiveThreadId,
|
||||
mg.instance,
|
||||
);
|
||||
const freshSession = getSession(session.id);
|
||||
|
||||
@@ -124,6 +124,15 @@ export interface MessagingGroupAgent {
|
||||
ignored_message_policy: IgnoredMessagePolicy;
|
||||
session_mode: 'shared' | 'per-thread' | 'agent-shared';
|
||||
priority: number;
|
||||
/**
|
||||
* Per-wiring thread-policy override (migration 019). NULL = inherit the
|
||||
* channel adapter's declared default for the wiring's context (DM vs
|
||||
* group); 1/0 = explicit override, hard-ANDed with the adapter's raw
|
||||
* capability at router fanout (resolveThreadPolicy). Optional on the TS
|
||||
* type per the denied_at convention so pre-migration fixtures don't need
|
||||
* updating.
|
||||
*/
|
||||
threads?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user