Merge branch 'main' into setup-lazy-env-reuse
This commit is contained in:
30
assets/setup-splash.txt
Normal file
30
assets/setup-splash.txt
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡆⢸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m [2m[38;2;43;183;206m°[39m[22m
|
||||||
|
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⠀⠀⠀⠀⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀[39m
|
||||||
|
[38;2;43;183;206m⠀⠀⠀⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧[39m
|
||||||
|
[2m[38;2;43;183;206mo[39m[22m [38;2;43;183;206m⠀⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿[39m
|
||||||
|
[38;2;43;183;206m⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇[39m
|
||||||
|
[38;2;43;183;206m⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀[39m [2m[38;2;43;183;206mo[39m[22m
|
||||||
|
[38;2;43;183;206m⢠⣿⡃⠀⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀[39m
|
||||||
|
[2m[38;2;43;183;206m°[39m[22m [38;2;43;183;206m⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m [38;2;43;183;206mO[39m
|
||||||
|
[38;2;43;183;206m⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206mo[39m [38;2;43;183;206m⠀⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀[39m
|
||||||
|
[38;2;43;183;206m⠀⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀[39m
|
||||||
|
|
||||||
|
[1m _ _ [22m[38;2;43;183;206m[1m ___ _ [22m[39m
|
||||||
|
[1m| \| |__ _ _ _ ___ [22m[38;2;43;183;206m[1m / __| |__ ___ __ __[22m[39m
|
||||||
|
[1m| .` / _` | ' \/ _ \[22m[38;2;43;183;206m[1m| (__| / _` \ V V /[22m[39m
|
||||||
|
[1m|_|\_\__,_|_||_\___/[22m[38;2;43;183;206m[1m \___|_\__,_|\_/\_/ [22m[39m
|
||||||
|
|
||||||
|
[2mSmall.[22m
|
||||||
|
[2mRuns on your machine.[22m
|
||||||
|
[2mYours to modify.[22m
|
||||||
|
|
||||||
|
[38;2;5;62;165m════════════════════════════════════════[39m
|
||||||
14
nanoclaw.sh
14
nanoclaw.sh
@@ -129,10 +129,13 @@ rm -f "$PROGRESS_LOG"
|
|||||||
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
||||||
write_header
|
write_header
|
||||||
|
|
||||||
# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing,
|
# NanoClaw splash — under-the-sea lobster mascot in truecolor braille,
|
||||||
# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and
|
# with the figlet wordmark and taglines below. Pre-rendered into
|
||||||
# skips re-printing the wordmark, keeping the flow visually continuous.
|
# assets/setup-splash.txt (built from assets/nanoclaw-icon.png via chafa +
|
||||||
printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
# figlet); the bash script just streams the literal frame. clack's intro
|
||||||
|
# then carries the "let's get you set up" framing — setup:auto sees
|
||||||
|
# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark.
|
||||||
|
cat "$PROJECT_ROOT/assets/setup-splash.txt"
|
||||||
|
|
||||||
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
||||||
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
||||||
@@ -188,9 +191,6 @@ BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log"
|
|||||||
BOOTSTRAP_LABEL="Installing the basics"
|
BOOTSTRAP_LABEL="Installing the basics"
|
||||||
BOOTSTRAP_START=$(date +%s)
|
BOOTSTRAP_START=$(date +%s)
|
||||||
|
|
||||||
# One-line "why" that teaches a differentiator while the user waits.
|
|
||||||
printf '%s %s\n' "$(gray '│')" \
|
|
||||||
"$(dim "Small. Runs on your machine. Yours to modify.")"
|
|
||||||
spinner_start "$BOOTSTRAP_LABEL"
|
spinner_start "$BOOTSTRAP_LABEL"
|
||||||
|
|
||||||
# Run in the background so we can tick elapsed time. Capture exit code via
|
# Run in the background so we can tick elapsed time. Capture exit code via
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "2.0.19",
|
"version": "2.0.20",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.0",
|
"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="135k tokens, 68% of context window">
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="138k tokens, 69% of context window">
|
||||||
<title>135k tokens, 68% of context window</title>
|
<title>138k tokens, 69% of context window</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" 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">
|
<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 aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||||
<text x="26" y="14">tokens</text>
|
<text x="26" y="14">tokens</text>
|
||||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">135k</text>
|
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">138k</text>
|
||||||
<text x="71" y="14">135k</text>
|
<text x="71" y="14">138k</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -53,7 +53,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau
|
|||||||
import * as setupLog from './logs.js';
|
import * as setupLog from './logs.js';
|
||||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||||
import { emit as phEmit } from './lib/diagnostics.js';
|
import { emit as phEmit } from './lib/diagnostics.js';
|
||||||
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js';
|
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
|
||||||
import { isValidTimezone } from '../src/timezone.js';
|
import { isValidTimezone } from '../src/timezone.js';
|
||||||
|
|
||||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||||
@@ -551,18 +551,16 @@ async function confirmAssistantResponds(): Promise<PingResult> {
|
|||||||
const s = p.spinner();
|
const s = p.spinner();
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const label = 'Waking your assistant…';
|
const label = 'Waking your assistant…';
|
||||||
s.start(fitToWidth(label, ' (999s)'));
|
s.start(fitToWidth(label, ' (99m 59s)'));
|
||||||
const tick = setInterval(() => {
|
const tick = setInterval(() => {
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const result = await pingCliAgent();
|
const result = await pingCliAgent();
|
||||||
|
|
||||||
clearInterval(tick);
|
clearInterval(tick);
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
if (result === 'ok') {
|
if (result === 'ok') {
|
||||||
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
|
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ import k from 'kleur';
|
|||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { brightSelect } from '../lib/bright-select.js';
|
import { brightSelect } from '../lib/bright-select.js';
|
||||||
import { confirmThenOpen } from '../lib/browser.js';
|
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||||
import { accentGreen, brandBody, note } from '../lib/theme.js';
|
|
||||||
import { readEnvKey } from '../environment.js';
|
import { readEnvKey } from '../environment.js';
|
||||||
|
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
const DISCORD_API = 'https://discord.com/api/v10';
|
const DISCORD_API = 'https://discord.com/api/v10';
|
||||||
@@ -165,9 +165,8 @@ async function walkThroughBotCreation(): Promise<void> {
|
|||||||
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
|
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
|
||||||
' 3. On the same tab, enable "Message Content Intent"',
|
' 3. On the same tab, enable "Message Content Intent"',
|
||||||
' (under Privileged Gateway Intents)',
|
' (under Privileged Gateway Intents)',
|
||||||
'',
|
formatNoteLink(url),
|
||||||
k.dim(url),
|
].filter((line): line is string => line !== null).join('\n'),
|
||||||
].join('\n'),
|
|
||||||
'Create a Discord bot',
|
'Create a Discord bot',
|
||||||
);
|
);
|
||||||
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
|
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
|
||||||
@@ -225,9 +224,8 @@ async function walkThroughServerCreation(): Promise<void> {
|
|||||||
' 1. In Discord, click the "+" at the bottom of the server list',
|
' 1. In Discord, click the "+" at the bottom of the server list',
|
||||||
' 2. Choose "Create My Own" → "For me and my friends"',
|
' 2. Choose "Create My Own" → "For me and my friends"',
|
||||||
' 3. Give it any name (e.g. "NanoClaw")',
|
' 3. Give it any name (e.g. "NanoClaw")',
|
||||||
'',
|
formatNoteLink(url),
|
||||||
k.dim(url),
|
].filter((line): line is string => line !== null).join('\n'),
|
||||||
].join('\n'),
|
|
||||||
'Create a Discord server',
|
'Create a Discord server',
|
||||||
);
|
);
|
||||||
await confirmThenOpen(url, 'Press Enter to open Discord');
|
await confirmThenOpen(url, 'Press Enter to open Discord');
|
||||||
@@ -290,9 +288,8 @@ async function validateDiscordToken(token: string): Promise<string> {
|
|||||||
username?: string;
|
username?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
||||||
if (res.ok && data.username) {
|
if (res.ok && data.username) {
|
||||||
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
|
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
setupLog.step('discord-validate', 'success', Date.now() - start, {
|
setupLog.step('discord-validate', 'success', Date.now() - start, {
|
||||||
BOT_USERNAME: data.username,
|
BOT_USERNAME: data.username,
|
||||||
BOT_ID: data.id ?? '',
|
BOT_ID: data.id ?? '',
|
||||||
@@ -310,8 +307,7 @@ async function validateDiscordToken(token: string): Promise<string> {
|
|||||||
'Copy the token again from the Developer Portal and retry setup.',
|
'Copy the token again from the Developer Portal and retry setup.',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
@@ -339,7 +335,6 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
|||||||
team?: unknown;
|
team?: unknown;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
||||||
if (!res.ok || !data.id || !data.verify_key) {
|
if (!res.ok || !data.id || !data.verify_key) {
|
||||||
const reason = data.message ?? `HTTP ${res.status}`;
|
const reason = data.message ?? `HTTP ${res.status}`;
|
||||||
s.stop(`Couldn't read application info: ${reason}`, 1);
|
s.stop(`Couldn't read application info: ${reason}`, 1);
|
||||||
@@ -352,7 +347,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
|||||||
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
|
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
|
s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
// owner is populated for solo applications; team-owned apps return a
|
// owner is populated for solo applications; team-owned apps return a
|
||||||
// team object instead and we'll fall back to a manual user-id prompt.
|
// team object instead and we'll fall back to a manual user-id prompt.
|
||||||
const owner =
|
const owner =
|
||||||
@@ -370,8 +365,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
|||||||
owner,
|
owner,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
@@ -451,9 +445,8 @@ async function promptInviteBot(
|
|||||||
'',
|
'',
|
||||||
' 1. Pick any server you\'re in (a personal one is fine)',
|
' 1. Pick any server you\'re in (a personal one is fine)',
|
||||||
' 2. Click "Authorize"',
|
' 2. Click "Authorize"',
|
||||||
'',
|
formatNoteLink(url),
|
||||||
k.dim(url),
|
].filter((line): line is string => line !== null).join('\n'),
|
||||||
].join('\n'),
|
|
||||||
'Add bot to a server',
|
'Add bot to a server',
|
||||||
);
|
);
|
||||||
await confirmThenOpen(url, 'Press Enter to open the invite page');
|
await confirmThenOpen(url, 'Press Enter to open the invite page');
|
||||||
@@ -480,7 +473,6 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
|||||||
body: JSON.stringify({ recipient_id: userId }),
|
body: JSON.stringify({ recipient_id: userId }),
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as { id?: string; message?: string };
|
const data = (await res.json()) as { id?: string; message?: string };
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
||||||
if (!res.ok || !data.id) {
|
if (!res.ok || !data.id) {
|
||||||
const reason = data.message ?? `HTTP ${res.status}`;
|
const reason = data.message ?? `HTTP ${res.status}`;
|
||||||
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
||||||
@@ -493,14 +485,13 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
|||||||
'Make sure the bot is in a server you\'re also in, then retry setup.',
|
'Make sure the bot is in a server you\'re also in, then retry setup.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
|
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
|
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
|
||||||
DM_CHANNEL_ID: data.id,
|
DM_CHANNEL_ID: data.id,
|
||||||
});
|
});
|
||||||
return data.id;
|
return data.id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import {
|
|||||||
writeStepEntry,
|
writeStepEntry,
|
||||||
} from '../lib/runner.js';
|
} from '../lib/runner.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { accentGreen, note } from '../lib/theme.js';
|
import { accentGreen, fmtDuration, note } from '../lib/theme.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
|
|
||||||
@@ -324,8 +324,7 @@ async function restartService(): Promise<void> {
|
|||||||
// Give the adapter a moment to connect to signal-cli before
|
// Give the adapter a moment to connect to signal-cli before
|
||||||
// init-first-agent's welcome DM hits the delivery path.
|
// init-first-agent's welcome DM hits the delivery path.
|
||||||
await new Promise((r) => setTimeout(r, 5000));
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
|
||||||
setupLog.step('signal-restart', 'success', Date.now() - start, {
|
setupLog.step('signal-restart', 'success', Date.now() - start, {
|
||||||
PLATFORM: platform,
|
PLATFORM: platform,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ import * as p from '@clack/prompts';
|
|||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { confirmThenOpen } from '../lib/browser.js';
|
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||||
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
|
|
||||||
import { readEnvKey } from '../environment.js';
|
import { readEnvKey } from '../environment.js';
|
||||||
|
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
|
||||||
|
|
||||||
const SLACK_API = 'https://slack.com/api';
|
const SLACK_API = 'https://slack.com/api';
|
||||||
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
||||||
@@ -136,9 +136,8 @@ async function walkThroughAppCreation(): Promise<void> {
|
|||||||
' slash commands and messages from the messages tab"',
|
' slash commands and messages from the messages tab"',
|
||||||
' 4. Basic Information → copy the "Signing Secret"',
|
' 4. Basic Information → copy the "Signing Secret"',
|
||||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||||
'',
|
formatNoteLink(SLACK_APPS_URL),
|
||||||
k.dim(SLACK_APPS_URL),
|
].filter((line): line is string => line !== null).join('\n'),
|
||||||
].join('\n'),
|
|
||||||
'Create a Slack app',
|
'Create a Slack app',
|
||||||
);
|
);
|
||||||
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
|
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
|
||||||
@@ -242,10 +241,9 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
|||||||
user_id?: string;
|
user_id?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
||||||
if (data.ok && data.team && data.user) {
|
if (data.ok && data.team && data.user) {
|
||||||
s.stop(
|
s.stop(
|
||||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
|
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
|
||||||
);
|
);
|
||||||
const info: WorkspaceInfo = {
|
const info: WorkspaceInfo = {
|
||||||
teamName: data.team,
|
teamName: data.team,
|
||||||
@@ -274,8 +272,7 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
|||||||
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
|
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
@@ -335,9 +332,8 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
|||||||
channel?: { id?: string };
|
channel?: { id?: string };
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
||||||
if (data.ok && data.channel?.id) {
|
if (data.ok && data.channel?.id) {
|
||||||
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
|
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
|
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
|
||||||
DM_CHANNEL_ID: data.channel.id,
|
DM_CHANNEL_ID: data.channel.id,
|
||||||
});
|
});
|
||||||
@@ -361,8 +357,7 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
|||||||
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
|
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
|
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import * as p from '@clack/prompts';
|
|||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { confirmThenOpen } from '../lib/browser.js';
|
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import {
|
import {
|
||||||
type Block,
|
type Block,
|
||||||
@@ -33,8 +33,8 @@ import {
|
|||||||
spawnStep,
|
spawnStep,
|
||||||
writeStepEntry,
|
writeStepEntry,
|
||||||
} from '../lib/runner.js';
|
} from '../lib/runner.js';
|
||||||
import { accentGreen, brandBold, fitToWidth, note } from '../lib/theme.js';
|
|
||||||
import { readEnvKey } from '../environment.js';
|
import { readEnvKey } from '../environment.js';
|
||||||
|
import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
|
|
||||||
@@ -51,9 +51,8 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
|
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
|
||||||
'',
|
formatNoteLink(botUrl),
|
||||||
k.dim(botUrl),
|
].filter((line): line is string => line !== null).join('\n'),
|
||||||
].join('\n'),
|
|
||||||
'Open Telegram',
|
'Open Telegram',
|
||||||
);
|
);
|
||||||
await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
|
await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
|
||||||
@@ -192,10 +191,9 @@ async function validateTelegramToken(token: string): Promise<string> {
|
|||||||
result?: { username?: string; id?: number };
|
result?: { username?: string; id?: number };
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
||||||
if (data.ok && data.result?.username) {
|
if (data.ok && data.result?.username) {
|
||||||
const username = data.result.username;
|
const username = data.result.username;
|
||||||
s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`);
|
s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
setupLog.step('telegram-validate', 'success', Date.now() - start, {
|
setupLog.step('telegram-validate', 'success', Date.now() - start, {
|
||||||
BOT_USERNAME: username,
|
BOT_USERNAME: username,
|
||||||
BOT_ID: data.result.id ?? '',
|
BOT_ID: data.result.id ?? '',
|
||||||
@@ -213,8 +211,7 @@ async function validateTelegramToken(token: string): Promise<string> {
|
|||||||
'Copy the token again from @BotFather and try setup once more.',
|
'Copy the token again from @BotFather and try setup once more.',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||||
s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||||
ERROR: message,
|
ERROR: message,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
writeStepEntry,
|
writeStepEntry,
|
||||||
} from '../lib/runner.js';
|
} from '../lib/runner.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { accentGreen, brandBody, brandBold, note } from '../lib/theme.js';
|
import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
|
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
|
||||||
@@ -379,8 +379,7 @@ async function restartService(): Promise<void> {
|
|||||||
// Give the adapter a moment to reconnect before init-first-agent's
|
// Give the adapter a moment to reconnect before init-first-agent's
|
||||||
// welcome DM hits the delivery path.
|
// welcome DM hits the delivery path.
|
||||||
await new Promise((r) => setTimeout(r, 5000));
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
|
||||||
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
|
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
|
||||||
PLATFORM: platform,
|
PLATFORM: platform,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,12 +9,19 @@
|
|||||||
* `confirmThenOpen` pauses for the operator before triggering the open —
|
* `confirmThenOpen` pauses for the operator before triggering the open —
|
||||||
* the browser tends to steal focus when it pops, and a split-second
|
* the browser tends to steal focus when it pops, and a split-second
|
||||||
* "wait what just happened" moment is worse than letting the user hit
|
* "wait what just happened" moment is worse than letting the user hit
|
||||||
* Enter when they're ready.
|
* Enter when they're ready. On headless devices (no graphical session
|
||||||
|
* available) it skips both the prompt and the open: there's no browser
|
||||||
|
* to launch, the surrounding `note(...)` already shows the URL for
|
||||||
|
* copy-paste on another device, and the next prompt in the channel
|
||||||
|
* flow ("Got your bot token?" etc.) provides the natural completion
|
||||||
|
* confirmation.
|
||||||
*/
|
*/
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
|
import k from 'kleur';
|
||||||
|
|
||||||
|
import { isHeadless } from '../platform.js';
|
||||||
import { ensureAnswer } from './runner.js';
|
import { ensureAnswer } from './runner.js';
|
||||||
|
|
||||||
/** Best-effort open of a URL in the user's default browser. Silent on failure. */
|
/** Best-effort open of a URL in the user's default browser. Silent on failure. */
|
||||||
@@ -32,18 +39,43 @@ export function openUrl(url: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a URL for inclusion in a setup `note(...)` card. On
|
||||||
|
* headless devices we surface the URL inside the card with a
|
||||||
|
* "Get started:" label at full strength — copy-pasting onto
|
||||||
|
* another device is the actual action, not an incidental
|
||||||
|
* reference. The leading `\n` acts as a visual separator from
|
||||||
|
* the body steps above; callers `.filter(line => line !== null)`
|
||||||
|
* before joining, so on GUI we drop the line entirely (and the
|
||||||
|
* URL ends up below the next-step confirm prompt as a "if
|
||||||
|
* browser does not appear, please visit" fallback — see
|
||||||
|
* `confirmThenOpen`).
|
||||||
|
*/
|
||||||
|
export function formatNoteLink(url: string): string | null {
|
||||||
|
if (isHeadless()) return `\nGet started: ${url}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate a browser-open on a confirm so the user is ready for their browser
|
* Gate a browser-open on a confirm so the user is ready for their browser
|
||||||
* to take focus. Proceeds on cancel as well — the user can always copy the
|
* to take focus. Proceeds on cancel as well. On headless devices both the
|
||||||
* URL from the note that precedes the prompt.
|
* prompt and the open are skipped — the URL is already surfaced inside
|
||||||
|
* the surrounding note (via `formatNoteLink`).
|
||||||
|
*
|
||||||
|
* On GUI devices the confirm message includes the fallback URL on the
|
||||||
|
* lines below the action ("If browser does not appear, please visit:
|
||||||
|
* <url>" in dim) so the user has a copy-paste path right next to the
|
||||||
|
* action button without needing to scroll back up to the card.
|
||||||
*/
|
*/
|
||||||
export async function confirmThenOpen(
|
export async function confirmThenOpen(
|
||||||
url: string,
|
url: string,
|
||||||
message = 'Press Enter to open your browser',
|
message = 'Press Enter to open your browser',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (isHeadless()) return;
|
||||||
|
const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`;
|
||||||
ensureAnswer(
|
ensureAnswer(
|
||||||
await p.confirm({
|
await p.confirm({
|
||||||
message,
|
message: `${message}${fallback}`,
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import * as p from '@clack/prompts';
|
|||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import { ensureAnswer } from './runner.js';
|
import { ensureAnswer } from './runner.js';
|
||||||
import { brandBody, fitToWidth, note } from './theme.js';
|
import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
|
||||||
|
|
||||||
export interface AssistContext {
|
export interface AssistContext {
|
||||||
stepName: string;
|
stepName: string;
|
||||||
@@ -295,9 +295,8 @@ async function queryClaudeUnderSpinner(
|
|||||||
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
|
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
|
||||||
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||||
|
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
|
||||||
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
|
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
|
||||||
const suffix = ` (${elapsed}s)`;
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const header = fitToWidth('Asking Claude to diagnose…', suffix);
|
const header = fitToWidth('Asking Claude to diagnose…', suffix);
|
||||||
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
|
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
|
||||||
|
|
||||||
@@ -355,8 +354,7 @@ async function queryClaudeUnderSpinner(
|
|||||||
clearBlock();
|
clearBlock();
|
||||||
out.write(SHOW_CURSOR);
|
out.write(SHOW_CURSOR);
|
||||||
process.off('exit', restoreCursorOnExit);
|
process.off('exit', restoreCursorOnExit);
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
if (kind === 'ok') {
|
if (kind === 'ok') {
|
||||||
p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
|
p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
|
||||||
resolve(payload);
|
resolve(payload);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import k from 'kleur';
|
|||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { offerClaudeAssist } from './claude-assist.js';
|
import { offerClaudeAssist } from './claude-assist.js';
|
||||||
import { emit as phEmit } from './diagnostics.js';
|
import { emit as phEmit } from './diagnostics.js';
|
||||||
import { brandBody, fitToWidth } from './theme.js';
|
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
|
||||||
|
|
||||||
export type Fields = Record<string, string>;
|
export type Fields = Record<string, string>;
|
||||||
export type Block = { type: string; fields: Fields };
|
export type Block = { type: string; fields: Fields };
|
||||||
@@ -307,18 +307,16 @@ async function runUnderSpinner<
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const s = p.spinner();
|
const s = p.spinner();
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
s.start(fitToWidth(labels.running, ' (999s)'));
|
s.start(fitToWidth(labels.running, ' (99m 59s)'));
|
||||||
const tick = setInterval(() => {
|
const tick = setInterval(() => {
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
|
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const result = await work();
|
const result = await work();
|
||||||
|
|
||||||
clearInterval(tick);
|
clearInterval(tick);
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
||||||
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ export function accentGreen(s: string): string {
|
|||||||
return k.green(s);
|
return k.green(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an elapsed-time duration (in milliseconds) for the spinner
|
||||||
|
* suffixes setup writes everywhere. Sub-minute durations stay in plain
|
||||||
|
* seconds (`47s`); once the timer crosses 60 seconds we switch to the
|
||||||
|
* `Xm Ys` form (`2m 34s`) so a long step doesn't read as `247s` or
|
||||||
|
* similar. The format is consistent above 60s — `4m 0s` over `4m` —
|
||||||
|
* so live spinner output doesn't change shape at every whole minute.
|
||||||
|
*/
|
||||||
|
export function fmtDuration(ms: number): string {
|
||||||
|
const totalSec = Math.round(ms / 1000);
|
||||||
|
if (totalSec < 60) return `${totalSec}s`;
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brand body color for setup-flow prose. Used for card bodies (via the
|
* Brand body color for setup-flow prose. Used for card bodies (via the
|
||||||
* `note()` formatter) and `p.log.*` body arguments — anywhere the
|
* `note()` formatter) and `p.log.*` body arguments — anywhere the
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import * as p from '@clack/prompts';
|
|||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import { isValidTimezone } from '../../src/timezone.js';
|
import { isValidTimezone } from '../../src/timezone.js';
|
||||||
import { fitToWidth } from './theme.js';
|
import { fitToWidth, fmtDuration } from './theme.js';
|
||||||
|
|
||||||
export function claudeCliAvailable(): boolean {
|
export function claudeCliAvailable(): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -44,18 +44,16 @@ export async function resolveTimezoneViaClaude(
|
|||||||
const s = p.spinner();
|
const s = p.spinner();
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const label = 'Looking up that timezone…';
|
const label = 'Looking up that timezone…';
|
||||||
s.start(fitToWidth(label, ' (999s)'));
|
s.start(fitToWidth(label, ' (99m 59s)'));
|
||||||
const tick = setInterval(() => {
|
const tick = setInterval(() => {
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const reply = await queryClaude(prompt);
|
const reply = await queryClaude(prompt);
|
||||||
|
|
||||||
clearInterval(tick);
|
clearInterval(tick);
|
||||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||||
const suffix = ` (${elapsed}s)`;
|
|
||||||
|
|
||||||
const resolved = reply ? extractTimezone(reply) : null;
|
const resolved = reply ? extractTimezone(reply) : null;
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ export interface ChannelAdapter {
|
|||||||
// Optional
|
// Optional
|
||||||
setTyping?(platformId: string, threadId: string | null): Promise<void>;
|
setTyping?(platformId: string, threadId: string | null): Promise<void>;
|
||||||
syncConversations?(): Promise<ConversationInfo[]>;
|
syncConversations?(): Promise<ConversationInfo[]>;
|
||||||
|
resolveChannelName?(platformId: string): Promise<string | null>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe the bot to a thread so follow-up messages route via the
|
* Subscribe the bot to a thread so follow-up messages route via the
|
||||||
|
|||||||
@@ -153,8 +153,10 @@ describe('unknown-channel registration flow', () => {
|
|||||||
expect(kind).toBe('chat-sdk');
|
expect(kind).toBe('chat-sdk');
|
||||||
const payload = JSON.parse(content as string);
|
const payload = JSON.parse(content as string);
|
||||||
expect(payload.type).toBe('ask_question');
|
expect(payload.type).toBe('ask_question');
|
||||||
// Card names the target agent so the owner knows what they're wiring to.
|
// Single-agent card offers a direct "Connect to <name>" button.
|
||||||
expect(payload.question).toContain('Andy');
|
const connectOption = payload.options.find((o: { value: string }) => o.value.startsWith('connect:'));
|
||||||
|
expect(connectOption).toBeDefined();
|
||||||
|
expect(connectOption.label).toContain('Andy');
|
||||||
|
|
||||||
const { getDb } = await import('../../db/connection.js');
|
const { getDb } = await import('../../db/connection.js');
|
||||||
const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{
|
const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{
|
||||||
@@ -202,11 +204,11 @@ describe('unknown-channel registration flow', () => {
|
|||||||
};
|
};
|
||||||
expect(pending).toBeDefined();
|
expect(pending).toBeDefined();
|
||||||
|
|
||||||
// Owner clicks approve.
|
// Owner clicks "Connect to Andy" (single-agent card).
|
||||||
for (const handler of getResponseHandlers()) {
|
for (const handler of getResponseHandlers()) {
|
||||||
const claimed = await handler({
|
const claimed = await handler({
|
||||||
questionId: pending.messaging_group_id,
|
questionId: pending.messaging_group_id,
|
||||||
value: 'approve',
|
value: 'connect:ag-1',
|
||||||
userId: 'owner', // raw platform id — handler namespaces it
|
userId: 'owner', // raw platform id — handler namespaces it
|
||||||
channelType: 'telegram',
|
channelType: 'telegram',
|
||||||
platformId: 'dm-owner',
|
platformId: 'dm-owner',
|
||||||
@@ -215,7 +217,7 @@ describe('unknown-channel registration flow', () => {
|
|||||||
if (claimed) break;
|
if (claimed) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wiring created with MVP defaults.
|
// Wiring created with defaults.
|
||||||
const mga = getDb()
|
const mga = getDb()
|
||||||
.prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?')
|
.prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?')
|
||||||
.get(pending.messaging_group_id) as {
|
.get(pending.messaging_group_id) as {
|
||||||
@@ -261,7 +263,7 @@ describe('unknown-channel registration flow', () => {
|
|||||||
for (const handler of getResponseHandlers()) {
|
for (const handler of getResponseHandlers()) {
|
||||||
const claimed = await handler({
|
const claimed = await handler({
|
||||||
questionId: pending.messaging_group_id,
|
questionId: pending.messaging_group_id,
|
||||||
value: 'approve',
|
value: 'connect:ag-1',
|
||||||
userId: 'owner',
|
userId: 'owner',
|
||||||
channelType: 'telegram',
|
channelType: 'telegram',
|
||||||
platformId: 'dm-owner',
|
platformId: 'dm-owner',
|
||||||
|
|||||||
@@ -5,24 +5,32 @@
|
|||||||
* addressed to the bot (SDK-confirmed mention or DM), it calls
|
* addressed to the bot (SDK-confirmed mention or DM), it calls
|
||||||
* `requestChannelApproval` instead of silently dropping. The flow:
|
* `requestChannelApproval` instead of silently dropping. The flow:
|
||||||
*
|
*
|
||||||
* 1. Pick the target agent group we'd wire to (MVP: first by name).
|
* 1. Gather all existing agent groups.
|
||||||
* Multi-agent picker is a follow-up — see ACTION-ITEMS.
|
|
||||||
* 2. Pick an eligible approver (owner / admin) and a reachable DM for
|
* 2. Pick an eligible approver (owner / admin) and a reachable DM for
|
||||||
* them, reusing the same primitives the sender-approval flow uses.
|
* them, reusing the same primitives the sender-approval flow uses.
|
||||||
* 3. Deliver an Approve / Ignore card that names the target agent
|
* 3. Deliver a card with three action families:
|
||||||
* explicitly so the owner knows what they're wiring to.
|
* a. Connect to [agent] — one button per existing agent group.
|
||||||
|
* Single-agent installs get a one-click connect.
|
||||||
|
* b. Connect new agent — prompts for a free-text name, creates
|
||||||
|
* the agent immediately on reply.
|
||||||
|
* c. Reject — deny the channel.
|
||||||
* 4. Record a `pending_channel_approvals` row holding the original event
|
* 4. Record a `pending_channel_approvals` row holding the original event
|
||||||
* so it can be re-routed on approve.
|
* so it can be re-routed on connect/create.
|
||||||
*
|
*
|
||||||
* On approve (handler in index.ts):
|
* On connect (handler in index.ts):
|
||||||
* - Create `messaging_group_agents` with MVP defaults
|
* - Create `messaging_group_agents` with defaults
|
||||||
* (mention-sticky for groups / pattern='.' for DMs,
|
* (mention-sticky for groups / pattern='.' for DMs,
|
||||||
* sender_scope='known', ignored_message_policy='accumulate')
|
* sender_scope='known', ignored_message_policy='accumulate')
|
||||||
* - Add the triggering sender to `agent_group_members` so sender_scope
|
* - Add the triggering sender to `agent_group_members` so sender_scope
|
||||||
* doesn't bounce the replayed message into a sender-approval cascade
|
* doesn't bounce the replayed message into a sender-approval cascade
|
||||||
* - Delete the pending row, replay the original event
|
* - Delete the pending row, replay the original event
|
||||||
*
|
*
|
||||||
* On ignore:
|
* On connect new agent (handler in index.ts):
|
||||||
|
* - Prompt for a free-text agent name via DM
|
||||||
|
* - On reply: create the agent group + filesystem, then wire
|
||||||
|
* and replay as above
|
||||||
|
*
|
||||||
|
* On reject:
|
||||||
* - Set `messaging_groups.denied_at = now()` so the router stops
|
* - Set `messaging_groups.denied_at = now()` so the router stops
|
||||||
* escalating on this channel until an admin explicitly re-wires
|
* escalating on this channel until an admin explicitly re-wires
|
||||||
* - Delete the pending row
|
* - Delete the pending row
|
||||||
@@ -36,19 +44,81 @@
|
|||||||
* - Approver has no reachable DM.
|
* - Approver has no reachable DM.
|
||||||
* - Delivery adapter missing.
|
* - Delivery adapter missing.
|
||||||
*/
|
*/
|
||||||
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
|
import { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js';
|
||||||
import { getAllAgentGroups } from '../../db/agent-groups.js';
|
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js';
|
||||||
import { getMessagingGroup } from '../../db/messaging-groups.js';
|
import { getChannelAdapter } from '../../channels/channel-registry.js';
|
||||||
|
import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js';
|
||||||
import { getDeliveryAdapter } from '../../delivery.js';
|
import { getDeliveryAdapter } from '../../delivery.js';
|
||||||
|
import { initGroupFilesystem } from '../../group-init.js';
|
||||||
import { log } from '../../log.js';
|
import { log } from '../../log.js';
|
||||||
import type { InboundEvent } from '../../channels/adapter.js';
|
import type { InboundEvent } from '../../channels/adapter.js';
|
||||||
|
import type { AgentGroup } from '../../types.js';
|
||||||
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
|
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
|
||||||
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
|
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
|
||||||
|
|
||||||
const APPROVAL_OPTIONS: RawOption[] = [
|
// ── Value constants (response handler in index.ts parses these) ──
|
||||||
{ label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' },
|
|
||||||
{ label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' },
|
export const CONNECT_PREFIX = 'connect:';
|
||||||
];
|
export const NEW_AGENT_VALUE = 'new_agent';
|
||||||
|
export const CHOOSE_EXISTING_VALUE = 'choose_existing';
|
||||||
|
export const REJECT_VALUE = 'reject';
|
||||||
|
|
||||||
|
// ── Utilities ──
|
||||||
|
|
||||||
|
function toFolder(name: string): string {
|
||||||
|
return (
|
||||||
|
name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || 'unnamed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card builders ──
|
||||||
|
|
||||||
|
function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] {
|
||||||
|
const options: RawOption[] = [];
|
||||||
|
if (agentGroups.length === 1) {
|
||||||
|
options.push({
|
||||||
|
label: `Connect to ${agentGroups[0].name}`,
|
||||||
|
selectedLabel: `✅ Connected to ${agentGroups[0].name}`,
|
||||||
|
value: `${CONNECT_PREFIX}${agentGroups[0].id}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
options.push({
|
||||||
|
label: 'Choose existing agent',
|
||||||
|
selectedLabel: '📋 Choosing…',
|
||||||
|
value: CHOOSE_EXISTING_VALUE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
options.push({
|
||||||
|
label: 'Connect new agent',
|
||||||
|
selectedLabel: '🆕 Connecting new agent…',
|
||||||
|
value: NEW_AGENT_VALUE,
|
||||||
|
});
|
||||||
|
options.push({
|
||||||
|
label: 'Reject',
|
||||||
|
selectedLabel: '🙅 Rejected',
|
||||||
|
value: REJECT_VALUE,
|
||||||
|
});
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuestionText(
|
||||||
|
isGroup: boolean,
|
||||||
|
senderName: string | undefined,
|
||||||
|
channelName: string | null,
|
||||||
|
channelType: string,
|
||||||
|
): string {
|
||||||
|
const who = senderName ?? 'Someone';
|
||||||
|
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} sent your bot a DM on ${channelType}. How would you like to handle it?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main flow ──
|
||||||
|
|
||||||
export interface RequestChannelApprovalInput {
|
export interface RequestChannelApprovalInput {
|
||||||
messagingGroupId: string;
|
messagingGroupId: string;
|
||||||
@@ -58,17 +128,11 @@ export interface RequestChannelApprovalInput {
|
|||||||
export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise<void> {
|
export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise<void> {
|
||||||
const { messagingGroupId, event } = input;
|
const { messagingGroupId, event } = input;
|
||||||
|
|
||||||
// In-flight dedup: don't spam the owner if the same unwired channel
|
|
||||||
// gets more mentions / DMs while a card is already pending.
|
|
||||||
if (hasInFlightChannelApproval(messagingGroupId)) {
|
if (hasInFlightChannelApproval(messagingGroupId)) {
|
||||||
log.debug('Channel registration already in flight — dropping retry', {
|
log.debug('Channel registration already in flight — dropping retry', { messagingGroupId });
|
||||||
messagingGroupId,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MVP: pick the first agent group by name. Multi-agent systems will get
|
|
||||||
// a richer card later (user picks the target from a list).
|
|
||||||
const agentGroups = getAllAgentGroups();
|
const agentGroups = getAllAgentGroups();
|
||||||
if (agentGroups.length === 0) {
|
if (agentGroups.length === 0) {
|
||||||
log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', {
|
log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', {
|
||||||
@@ -76,55 +140,65 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = agentGroups[0];
|
// Use first agent group for approver resolution — owners and global admins
|
||||||
|
// are returned regardless of which group we pass.
|
||||||
|
const referenceGroup = agentGroups[0];
|
||||||
|
|
||||||
// pickApprover takes the target agent group's id — gets scoped admins +
|
const approvers = pickApprover(referenceGroup.id);
|
||||||
// global admins + owners. For fresh installs with only an owner, the
|
|
||||||
// owner is returned.
|
|
||||||
const approvers = pickApprover(target.id);
|
|
||||||
if (approvers.length === 0) {
|
if (approvers.length === 0) {
|
||||||
log.warn('Channel registration skipped — no owner or admin configured', {
|
log.warn('Channel registration skipped — no owner or admin configured', {
|
||||||
messagingGroupId,
|
messagingGroupId,
|
||||||
targetAgentGroupId: target.id,
|
targetAgentGroupId: referenceGroup.id,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const originMg = getMessagingGroup(messagingGroupId);
|
const originMg = getMessagingGroup(messagingGroupId);
|
||||||
const originChannelType = originMg?.channel_type ?? '';
|
const originChannelType = originMg?.channel_type ?? '';
|
||||||
|
|
||||||
|
// Resolve channel name if not yet persisted.
|
||||||
|
if (originMg && !originMg.name) {
|
||||||
|
const channelAdapter = getChannelAdapter(originChannelType);
|
||||||
|
if (channelAdapter?.resolveChannelName) {
|
||||||
|
try {
|
||||||
|
const name = await channelAdapter.resolveChannelName(originMg.platform_id);
|
||||||
|
if (name) {
|
||||||
|
updateMessagingGroup(originMg.id, { name });
|
||||||
|
originMg.name = name;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* non-critical */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const delivery = await pickApprovalDelivery(approvers, originChannelType);
|
const delivery = await pickApprovalDelivery(approvers, originChannelType);
|
||||||
if (!delivery) {
|
if (!delivery) {
|
||||||
log.warn('Channel registration skipped — no DM channel for any approver', {
|
log.warn('Channel registration skipped — no DM channel for any approver', {
|
||||||
messagingGroupId,
|
messagingGroupId,
|
||||||
targetAgentGroupId: target.id,
|
targetAgentGroupId: referenceGroup.id,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGroup = event.message?.isGroup ?? originMg?.is_group === 1;
|
const isGroup = event.message?.isGroup ?? originMg?.is_group === 1;
|
||||||
|
|
||||||
// Extract sender name from the event content for a human-readable card.
|
|
||||||
let senderName: string | undefined;
|
let senderName: string | undefined;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
|
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
|
||||||
senderName = (parsed.senderName ?? parsed.sender) as string | undefined;
|
senderName = (parsed.senderName ?? parsed.sender) as string | undefined;
|
||||||
} catch {
|
} catch {
|
||||||
// non-critical — fall through to generic wording
|
// non-critical
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message';
|
const channelName = originMg?.name ?? null;
|
||||||
const question = isGroup
|
const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
|
||||||
? senderName
|
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
|
||||||
? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
|
const options = normalizeOptions(buildApprovalOptions(agentGroups));
|
||||||
: `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
|
|
||||||
: senderName
|
|
||||||
? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`
|
|
||||||
: `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`;
|
|
||||||
const options = normalizeOptions(APPROVAL_OPTIONS);
|
|
||||||
|
|
||||||
createPendingChannelApproval({
|
createPendingChannelApproval({
|
||||||
messaging_group_id: messagingGroupId,
|
messaging_group_id: messagingGroupId,
|
||||||
agent_group_id: target.id,
|
agent_group_id: referenceGroup.id,
|
||||||
original_message: JSON.stringify(event),
|
original_message: JSON.stringify(event),
|
||||||
approver_user_id: delivery.userId,
|
approver_user_id: delivery.userId,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
@@ -134,9 +208,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
|||||||
|
|
||||||
const adapter = getDeliveryAdapter();
|
const adapter = getDeliveryAdapter();
|
||||||
if (!adapter) {
|
if (!adapter) {
|
||||||
log.error('Channel registration row created but no delivery adapter is wired', {
|
log.error('Channel registration row created but no delivery adapter is wired', { messagingGroupId });
|
||||||
messagingGroupId,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +220,6 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
|||||||
'chat-sdk',
|
'chat-sdk',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'ask_question',
|
type: 'ask_question',
|
||||||
// Use messaging_group_id as the questionId — it's unique per card
|
|
||||||
// (PK on pending table dedups) and lets the response handler look
|
|
||||||
// up the pending row directly without another index.
|
|
||||||
questionId: messagingGroupId,
|
questionId: messagingGroupId,
|
||||||
title,
|
title,
|
||||||
question,
|
question,
|
||||||
@@ -159,16 +228,56 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
|||||||
);
|
);
|
||||||
log.info('Channel registration card delivered', {
|
log.info('Channel registration card delivered', {
|
||||||
messagingGroupId,
|
messagingGroupId,
|
||||||
targetAgentGroupId: target.id,
|
agentGroupCount: agentGroups.length,
|
||||||
approver: delivery.userId,
|
approver: delivery.userId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('Channel registration card delivery failed', {
|
log.error('Channel registration card delivery failed', { messagingGroupId, err });
|
||||||
messagingGroupId,
|
|
||||||
err,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APPROVE_VALUE = 'approve';
|
// ── Helpers for the response handler (index.ts) ──
|
||||||
export const REJECT_VALUE = 'reject';
|
|
||||||
|
/**
|
||||||
|
* Build normalized options for the agent-selection follow-up card.
|
||||||
|
*/
|
||||||
|
export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] {
|
||||||
|
const options: RawOption[] = agentGroups.map((ag) => ({
|
||||||
|
label: ag.name,
|
||||||
|
selectedLabel: `✅ Connected to ${ag.name}`,
|
||||||
|
value: `${CONNECT_PREFIX}${ag.id}`,
|
||||||
|
}));
|
||||||
|
options.push({
|
||||||
|
label: 'Cancel',
|
||||||
|
selectedLabel: '🙅 Cancelled',
|
||||||
|
value: REJECT_VALUE,
|
||||||
|
});
|
||||||
|
return normalizeOptions(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new agent group and initialize its filesystem. Handles
|
||||||
|
* folder-name collisions with numeric suffixes.
|
||||||
|
*/
|
||||||
|
export function createNewAgentGroup(name: string): AgentGroup {
|
||||||
|
let folder = toFolder(name);
|
||||||
|
const baseFolder = folder;
|
||||||
|
let suffix = 2;
|
||||||
|
while (getAgentGroupByFolder(folder)) {
|
||||||
|
folder = `${baseFolder}-${suffix}`;
|
||||||
|
suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
createAgentGroup({
|
||||||
|
id: agId,
|
||||||
|
name,
|
||||||
|
folder,
|
||||||
|
agent_provider: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ag = getAgentGroup(agId)!;
|
||||||
|
initGroupFilesystem(ag);
|
||||||
|
return ag;
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export function hasInFlightChannelApproval(messagingGroupId: string): boolean {
|
|||||||
return row !== undefined;
|
return row !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updatePendingChannelApprovalCard(messagingGroupId: string, title: string, optionsJson: string): void {
|
||||||
|
getDb()
|
||||||
|
.prepare('UPDATE pending_channel_approvals SET title = ?, options_json = ? WHERE messaging_group_id = ?')
|
||||||
|
.run(title, optionsJson, messagingGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
export function deletePendingChannelApproval(messagingGroupId: string): void {
|
export function deletePendingChannelApproval(messagingGroupId: string): void {
|
||||||
getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId);
|
getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,27 +16,53 @@
|
|||||||
* access gate is not registered and core defaults to allow-all.
|
* access gate is not registered and core defaults to allow-all.
|
||||||
*/
|
*/
|
||||||
import { recordDroppedMessage } from '../../db/dropped-messages.js';
|
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, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
|
||||||
import {
|
import {
|
||||||
routeInbound,
|
routeInbound,
|
||||||
setAccessGate,
|
setAccessGate,
|
||||||
setChannelRequestGate,
|
setChannelRequestGate,
|
||||||
|
setMessageInterceptor,
|
||||||
setSenderResolver,
|
setSenderResolver,
|
||||||
setSenderScopeGate,
|
setSenderScopeGate,
|
||||||
type AccessGateResult,
|
type AccessGateResult,
|
||||||
} from '../../router.js';
|
} from '../../router.js';
|
||||||
import type { InboundEvent } from '../../channels/adapter.js';
|
import type { InboundEvent } from '../../channels/adapter.js';
|
||||||
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
|
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
|
||||||
|
import { getDeliveryAdapter } from '../../delivery.js';
|
||||||
import { log } from '../../log.js';
|
import { log } from '../../log.js';
|
||||||
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
|
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
|
||||||
import { canAccessAgentGroup } from './access.js';
|
import { canAccessAgentGroup } from './access.js';
|
||||||
import { requestChannelApproval } from './channel-approval.js';
|
import {
|
||||||
|
buildAgentSelectionOptions,
|
||||||
|
CHOOSE_EXISTING_VALUE,
|
||||||
|
CONNECT_PREFIX,
|
||||||
|
createNewAgentGroup,
|
||||||
|
NEW_AGENT_VALUE,
|
||||||
|
REJECT_VALUE,
|
||||||
|
requestChannelApproval,
|
||||||
|
} from './channel-approval.js';
|
||||||
import { addMember } from './db/agent-group-members.js';
|
import { addMember } from './db/agent-group-members.js';
|
||||||
import { deletePendingChannelApproval, getPendingChannelApproval } from './db/pending-channel-approvals.js';
|
import {
|
||||||
|
deletePendingChannelApproval,
|
||||||
|
getPendingChannelApproval,
|
||||||
|
updatePendingChannelApprovalCard,
|
||||||
|
} from './db/pending-channel-approvals.js';
|
||||||
import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js';
|
import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js';
|
||||||
import { hasAdminPrivilege } from './db/user-roles.js';
|
import { hasAdminPrivilege } from './db/user-roles.js';
|
||||||
import { getUser, upsertUser } from './db/users.js';
|
import { getUser, upsertUser } from './db/users.js';
|
||||||
import { requestSenderApproval } from './sender-approval.js';
|
import { requestSenderApproval } from './sender-approval.js';
|
||||||
|
import { ensureUserDm } from './user-dm.js';
|
||||||
|
|
||||||
|
// ── Free-text name input state ──
|
||||||
|
// Tracks approvers waiting for a text reply with the agent name. Keyed by
|
||||||
|
// namespaced userId (e.g. "slack:U0ABC"). Cleared on receipt or restart.
|
||||||
|
interface PendingNameInput {
|
||||||
|
channelMgId: string;
|
||||||
|
dmChannelType: string;
|
||||||
|
dmPlatformId: string;
|
||||||
|
}
|
||||||
|
const awaitingNameInput = new Map<string, PendingNameInput>();
|
||||||
|
|
||||||
function extractAndUpsertUser(event: InboundEvent): string | null {
|
function extractAndUpsertUser(event: InboundEvent): string | null {
|
||||||
let content: Record<string, unknown>;
|
let content: Record<string, unknown>;
|
||||||
@@ -271,22 +297,17 @@ setChannelRequestGate(async (mg, event) => {
|
|||||||
* by messaging_group_id). If no such row, return false so downstream
|
* by messaging_group_id). If no such row, return false so downstream
|
||||||
* handlers get a shot.
|
* handlers get a shot.
|
||||||
*
|
*
|
||||||
* Approve: create the wiring with MVP defaults (mention-sticky for
|
* Value dispatch:
|
||||||
* groups / pattern='.' for DMs; sender_scope='known';
|
* connect:<id> — wire to an existing agent group, replay the message
|
||||||
* ignored_message_policy='accumulate'), add the triggering sender as a
|
* choose_existing — send a follow-up card listing all agents
|
||||||
* member so sender_scope doesn't immediately bounce them into a
|
* new_agent — prompt for a free-text agent name (interceptor
|
||||||
* sender-approval card, then replay the original event.
|
* captures the reply and creates immediately)
|
||||||
*
|
* reject — set denied_at, delete pending row
|
||||||
* Deny: set `messaging_groups.denied_at = now()` so future mentions on
|
|
||||||
* this channel drop silently until an admin explicitly wires it.
|
|
||||||
*/
|
*/
|
||||||
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<boolean> {
|
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<boolean> {
|
||||||
const row = getPendingChannelApproval(payload.questionId);
|
const row = getPendingChannelApproval(payload.questionId);
|
||||||
if (!row) return false;
|
if (!row) return false;
|
||||||
|
|
||||||
// Click-auth: same pattern as sender-approval (see commit 68058cb).
|
|
||||||
// Raw platform userId → namespace with channelType → must match the
|
|
||||||
// designated approver OR have admin privilege over the target agent.
|
|
||||||
const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
|
const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
|
||||||
const isAuthorized =
|
const isAuthorized =
|
||||||
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
|
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
|
||||||
@@ -296,25 +317,129 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
|||||||
clickerId,
|
clickerId,
|
||||||
expectedApprover: row.approver_user_id,
|
expectedApprover: row.approver_user_id,
|
||||||
});
|
});
|
||||||
return true; // claim but take no action
|
return true;
|
||||||
}
|
}
|
||||||
const approverId = clickerId;
|
const approverId = clickerId;
|
||||||
const approved = payload.value === 'approve';
|
|
||||||
|
|
||||||
if (!approved) {
|
// ── Reject / Cancel ──
|
||||||
|
if (payload.value === REJECT_VALUE) {
|
||||||
setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString());
|
setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString());
|
||||||
deletePendingChannelApproval(row.messaging_group_id);
|
deletePendingChannelApproval(row.messaging_group_id);
|
||||||
log.info('Channel registration denied', {
|
log.info('Channel registration denied', {
|
||||||
messagingGroupId: row.messaging_group_id,
|
messagingGroupId: row.messaging_group_id,
|
||||||
agentGroupId: row.agent_group_id,
|
|
||||||
approverId,
|
approverId,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rehydrate the original event to know (a) whether it was a DM or group
|
// ── Choose existing agent — send agent-selection follow-up card ──
|
||||||
// (chooses engage_mode default), and (b) who the triggering sender was
|
if (payload.value === CHOOSE_EXISTING_VALUE) {
|
||||||
// (auto-member-add so sender_scope='known' doesn't bounce the replay).
|
const approverDm = await ensureUserDm(row.approver_user_id);
|
||||||
|
if (!approverDm) {
|
||||||
|
log.error('Channel registration: no DM channel for approver', {
|
||||||
|
messagingGroupId: row.messaging_group_id,
|
||||||
|
approverUserId: row.approver_user_id,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = getDeliveryAdapter();
|
||||||
|
if (!adapter) return true;
|
||||||
|
|
||||||
|
const agentGroups = getAllAgentGroups();
|
||||||
|
const options = buildAgentSelectionOptions(agentGroups);
|
||||||
|
const title = '📋 Choose an agent';
|
||||||
|
updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adapter.deliver(
|
||||||
|
approverDm.channel_type,
|
||||||
|
approverDm.platform_id,
|
||||||
|
null,
|
||||||
|
'chat-sdk',
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'ask_question',
|
||||||
|
questionId: row.messaging_group_id,
|
||||||
|
title,
|
||||||
|
question: 'Which agent should handle this channel?',
|
||||||
|
options,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Channel registration: agent-selection card delivery failed', {
|
||||||
|
messagingGroupId: row.messaging_group_id,
|
||||||
|
err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create new agent — prompt for free-text name ──
|
||||||
|
if (payload.value === NEW_AGENT_VALUE) {
|
||||||
|
const approverDm = await ensureUserDm(row.approver_user_id);
|
||||||
|
if (!approverDm) {
|
||||||
|
log.error('Channel registration: no DM channel for approver', {
|
||||||
|
messagingGroupId: row.messaging_group_id,
|
||||||
|
approverUserId: row.approver_user_id,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = getDeliveryAdapter();
|
||||||
|
if (!adapter) {
|
||||||
|
log.error('Channel registration: no delivery adapter for name prompt', {
|
||||||
|
messagingGroupId: row.messaging_group_id,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
awaitingNameInput.set(row.approver_user_id, {
|
||||||
|
channelMgId: row.messaging_group_id,
|
||||||
|
dmChannelType: approverDm.channel_type,
|
||||||
|
dmPlatformId: approverDm.platform_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adapter.deliver(
|
||||||
|
approverDm.channel_type,
|
||||||
|
approverDm.platform_id,
|
||||||
|
null,
|
||||||
|
'chat-sdk',
|
||||||
|
JSON.stringify({ text: 'Reply with the name for your new agent:' }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Channel registration: name prompt delivery failed', {
|
||||||
|
messagingGroupId: row.messaging_group_id,
|
||||||
|
err,
|
||||||
|
});
|
||||||
|
awaitingNameInput.delete(row.approver_user_id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve target agent group (connect to existing or create new) ──
|
||||||
|
let targetAgentGroupId: string;
|
||||||
|
|
||||||
|
if (payload.value.startsWith(CONNECT_PREFIX)) {
|
||||||
|
targetAgentGroupId = payload.value.slice(CONNECT_PREFIX.length);
|
||||||
|
const ag = getAgentGroup(targetAgentGroupId);
|
||||||
|
if (!ag) {
|
||||||
|
log.error('Channel registration: target agent group no longer exists', {
|
||||||
|
messagingGroupId: row.messaging_group_id,
|
||||||
|
targetAgentGroupId,
|
||||||
|
});
|
||||||
|
deletePendingChannelApproval(row.messaging_group_id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn('Channel registration: unknown response value', {
|
||||||
|
messagingGroupId: row.messaging_group_id,
|
||||||
|
value: payload.value,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire + replay (shared path for connect and create) ──
|
||||||
let event: InboundEvent;
|
let event: InboundEvent;
|
||||||
try {
|
try {
|
||||||
event = JSON.parse(row.original_message) as InboundEvent;
|
event = JSON.parse(row.original_message) as InboundEvent;
|
||||||
@@ -327,15 +452,6 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide engage_mode from the original event. DMs (`isMention=true` &
|
|
||||||
// not in a group) get `pattern='.'` (always respond). Group mentions
|
|
||||||
// get `mention-sticky` (respond now + follow the thread).
|
|
||||||
//
|
|
||||||
// We can't read `mg.is_group` reliably here because we only auto-create
|
|
||||||
// the mg with `is_group=0` on first sight — the adapter hasn't told us
|
|
||||||
// yet whether it's actually a group. Fall back to the InboundEvent's
|
|
||||||
// `threadId`: a non-null threadId implies a threaded platform (Slack
|
|
||||||
// channel thread, Discord thread), which we treat as a group.
|
|
||||||
const isGroup = event.threadId !== null;
|
const isGroup = event.threadId !== null;
|
||||||
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
|
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
|
||||||
const engagePattern = isGroup ? null : '.';
|
const engagePattern = isGroup ? null : '.';
|
||||||
@@ -344,7 +460,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
|||||||
createMessagingGroupAgent({
|
createMessagingGroupAgent({
|
||||||
id: mgaId,
|
id: mgaId,
|
||||||
messaging_group_id: row.messaging_group_id,
|
messaging_group_id: row.messaging_group_id,
|
||||||
agent_group_id: row.agent_group_id,
|
agent_group_id: targetAgentGroupId,
|
||||||
engage_mode: engageMode,
|
engage_mode: engageMode,
|
||||||
engage_pattern: engagePattern,
|
engage_pattern: engagePattern,
|
||||||
sender_scope: 'known',
|
sender_scope: 'known',
|
||||||
@@ -355,28 +471,22 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
|||||||
});
|
});
|
||||||
log.info('Channel registration approved — wiring created', {
|
log.info('Channel registration approved — wiring created', {
|
||||||
messagingGroupId: row.messaging_group_id,
|
messagingGroupId: row.messaging_group_id,
|
||||||
agentGroupId: row.agent_group_id,
|
agentGroupId: targetAgentGroupId,
|
||||||
mgaId,
|
mgaId,
|
||||||
engageMode,
|
engageMode,
|
||||||
approverId,
|
approverId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-admit the triggering sender. Without this, the replay below
|
|
||||||
// would bounce through sender-approval (sender_scope='known' +
|
|
||||||
// sender-is-not-a-member).
|
|
||||||
const senderUserId = extractAndUpsertUser(event);
|
const senderUserId = extractAndUpsertUser(event);
|
||||||
if (senderUserId) {
|
if (senderUserId) {
|
||||||
addMember({
|
addMember({
|
||||||
user_id: senderUserId,
|
user_id: senderUserId,
|
||||||
agent_group_id: row.agent_group_id,
|
agent_group_id: targetAgentGroupId,
|
||||||
added_by: approverId,
|
added_by: approverId,
|
||||||
added_at: new Date().toISOString(),
|
added_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the pending row BEFORE replay so the gate check on the second
|
|
||||||
// attempt sees a wired channel (agentCount > 0) and takes the fan-out
|
|
||||||
// path normally.
|
|
||||||
deletePendingChannelApproval(row.messaging_group_id);
|
deletePendingChannelApproval(row.messaging_group_id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -391,3 +501,117 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerResponseHandler(handleChannelApprovalResponse);
|
registerResponseHandler(handleChannelApprovalResponse);
|
||||||
|
|
||||||
|
// ── Free-text name interceptor ──
|
||||||
|
// Captures the next DM from an approver who clicked "Create new agent",
|
||||||
|
// creates the agent immediately, wires the channel, and replays.
|
||||||
|
|
||||||
|
setMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||||
|
const userId = extractAndUpsertUser(event);
|
||||||
|
if (!userId) return false;
|
||||||
|
|
||||||
|
const pending = awaitingNameInput.get(userId);
|
||||||
|
if (!pending) return false;
|
||||||
|
if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return false;
|
||||||
|
|
||||||
|
awaitingNameInput.delete(userId);
|
||||||
|
|
||||||
|
let text: string | undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
|
||||||
|
text = (typeof parsed.text === 'string' ? parsed.text : undefined)?.trim();
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
log.warn('Channel registration: empty name reply, ignoring', { userId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = getPendingChannelApproval(pending.channelMgId);
|
||||||
|
if (!row) return true;
|
||||||
|
|
||||||
|
const ag = createNewAgentGroup(text);
|
||||||
|
log.info('Channel registration: new agent group created', {
|
||||||
|
messagingGroupId: row.messaging_group_id,
|
||||||
|
agentGroupId: ag.id,
|
||||||
|
agentName: ag.name,
|
||||||
|
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 adapter = getDeliveryAdapter();
|
||||||
|
if (adapter) {
|
||||||
|
const dm = await ensureUserDm(row.approver_user_id);
|
||||||
|
if (dm) {
|
||||||
|
adapter
|
||||||
|
.deliver(
|
||||||
|
dm.channel_type,
|
||||||
|
dm.platform_id,
|
||||||
|
null,
|
||||||
|
'chat-sdk',
|
||||||
|
JSON.stringify({ text: `✅ Agent "${ag.name}" created and connected.` }),
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|||||||
@@ -108,6 +108,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
|
|||||||
senderScopeGate = fn;
|
senderScopeGate = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message-interceptor hook. Runs at the very top of routeInbound, before
|
||||||
|
* messaging-group resolution. When the interceptor returns true the message
|
||||||
|
* is consumed and routing stops. Used by the permissions module to capture
|
||||||
|
* free-text replies during multi-step approval flows (e.g. agent naming).
|
||||||
|
*/
|
||||||
|
export type MessageInterceptorFn = (event: InboundEvent) => Promise<boolean>;
|
||||||
|
|
||||||
|
let messageInterceptor: MessageInterceptorFn | null = null;
|
||||||
|
|
||||||
|
export function setMessageInterceptor(fn: MessageInterceptorFn): void {
|
||||||
|
messageInterceptor = fn;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Channel-registration hook. Runs when the router sees a mention/DM on a
|
* Channel-registration hook. Runs when the router sees a mention/DM on a
|
||||||
* messaging group that has no wirings AND hasn't been denied. The hook is
|
* messaging group that has no wirings AND hasn't been denied. The hook is
|
||||||
@@ -142,6 +156,10 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender
|
|||||||
* Creates messaging group + session if they don't exist yet.
|
* Creates messaging group + session if they don't exist yet.
|
||||||
*/
|
*/
|
||||||
export async function routeInbound(event: InboundEvent): Promise<void> {
|
export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||||
|
// Pre-route interceptor — lets modules consume messages before any routing
|
||||||
|
// (e.g. free-text replies during multi-step approval flows).
|
||||||
|
if (messageInterceptor && (await messageInterceptor(event))) return;
|
||||||
|
|
||||||
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
|
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
|
||||||
// WhatsApp, iMessage, email) collapse threads to the channel.
|
// WhatsApp, iMessage, email) collapse threads to the channel.
|
||||||
const adapter = getChannelAdapter(event.channelType);
|
const adapter = getChannelAdapter(event.channelType);
|
||||||
|
|||||||
Reference in New Issue
Block a user