Merge branch 'main' into setup-telegram-no-telegram-fallback
This commit is contained in:
@@ -44,7 +44,7 @@ import './discord.js';
|
|||||||
### 4. Install the adapter package (pinned)
|
### 4. Install the adapter package (pinned)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install @chat-adapter/discord@4.26.0
|
pnpm install @chat-adapter/discord@4.27.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Build
|
### 5. Build
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import './gchat.js';
|
|||||||
### 4. Install the adapter package (pinned)
|
### 4. Install the adapter package (pinned)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install @chat-adapter/gchat@4.26.0
|
pnpm install @chat-adapter/gchat@4.27.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Build
|
### 5. Build
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import './github.js';
|
|||||||
### 4. Install the adapter package (pinned)
|
### 4. Install the adapter package (pinned)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install @chat-adapter/github@4.26.0
|
pnpm install @chat-adapter/github@4.27.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Build
|
### 5. Build
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler n
|
|||||||
### 5. Install the adapter package (pinned)
|
### 5. Install the adapter package (pinned)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install @chat-adapter/linear@4.26.0
|
pnpm install @chat-adapter/linear@4.27.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Build
|
### 6. Build
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import './slack.js';
|
|||||||
### 4. Install the adapter package (pinned)
|
### 4. Install the adapter package (pinned)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install @chat-adapter/slack@4.26.0
|
pnpm install @chat-adapter/slack@4.27.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Build
|
### 5. Build
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import './teams.js';
|
|||||||
### 4. Install the adapter package (pinned)
|
### 4. Install the adapter package (pinned)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install @chat-adapter/teams@4.26.0
|
pnpm install @chat-adapter/teams@4.27.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Build
|
### 5. Build
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
|
|||||||
### 5. Install the adapter package (pinned)
|
### 5. Install the adapter package (pinned)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install @chat-adapter/telegram@4.26.0
|
pnpm install @chat-adapter/telegram@4.27.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Build
|
### 6. Build
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import './whatsapp-cloud.js';
|
|||||||
### 4. Install the adapter package (pinned)
|
### 4. Install the adapter package (pinned)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install @chat-adapter/whatsapp@4.26.0
|
pnpm install @chat-adapter/whatsapp@4.27.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Build
|
### 5. Build
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ groups: () => import('./groups.js'),
|
|||||||
### 5. Install the adapter packages (pinned)
|
### 5. Install the adapter packages (pinned)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Build
|
### 6. Build
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat';
|
|||||||
let _inbound: Database | null = null;
|
let _inbound: Database | null = null;
|
||||||
let _outbound: Database | null = null;
|
let _outbound: Database | null = null;
|
||||||
let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH;
|
let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH;
|
||||||
|
let _testMode = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled.
|
* Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled.
|
||||||
@@ -42,6 +43,13 @@ let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH;
|
|||||||
* Cost is microseconds per query, so safe for universal use.
|
* Cost is microseconds per query, so safe for universal use.
|
||||||
*/
|
*/
|
||||||
export function openInboundDb(): Database {
|
export function openInboundDb(): Database {
|
||||||
|
// In test mode return a thin wrapper over the in-memory singleton.
|
||||||
|
// Callers do try/finally { db.close() } — the wrapper no-ops close()
|
||||||
|
// so the singleton survives for the rest of the test.
|
||||||
|
if (_testMode && _inbound) {
|
||||||
|
const db = _inbound;
|
||||||
|
return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database;
|
||||||
|
}
|
||||||
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
|
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
|
||||||
db.exec('PRAGMA busy_timeout = 5000');
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
db.exec('PRAGMA mmap_size = 0');
|
db.exec('PRAGMA mmap_size = 0');
|
||||||
@@ -170,6 +178,7 @@ export function clearStaleProcessingAcks(): void {
|
|||||||
|
|
||||||
/** For tests — creates in-memory DBs with the session schemas. */
|
/** For tests — creates in-memory DBs with the session schemas. */
|
||||||
export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
||||||
|
_testMode = true;
|
||||||
_inbound = new Database(':memory:');
|
_inbound = new Database(':memory:');
|
||||||
_inbound.exec('PRAGMA foreign_keys = ON');
|
_inbound.exec('PRAGMA foreign_keys = ON');
|
||||||
_inbound.exec(`
|
_inbound.exec(`
|
||||||
@@ -246,6 +255,7 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
|||||||
export function closeSessionDb(): void {
|
export function closeSessionDb(): void {
|
||||||
_inbound?.close();
|
_inbound?.close();
|
||||||
_inbound = null;
|
_inbound = null;
|
||||||
|
_testMode = false;
|
||||||
_outbound?.close();
|
_outbound?.close();
|
||||||
_outbound = null;
|
_outbound = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ const SDK_DISALLOWED_TOOLS = [
|
|||||||
'ExitWorktree',
|
'ExitWorktree',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Tool allowlist for NanoClaw agent containers
|
// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived
|
||||||
|
// at the call site from the registered `mcpServers` map so that any server
|
||||||
|
// added via `add_mcp_server` (or wired in container.json directly) is
|
||||||
|
// reachable to the agent — without this, the SDK's allowedTools filter
|
||||||
|
// silently drops every MCP namespace not listed here.
|
||||||
const TOOL_ALLOWLIST = [
|
const TOOL_ALLOWLIST = [
|
||||||
'Bash',
|
'Bash',
|
||||||
'Read',
|
'Read',
|
||||||
@@ -54,9 +58,15 @@ const TOOL_ALLOWLIST = [
|
|||||||
'ToolSearch',
|
'ToolSearch',
|
||||||
'Skill',
|
'Skill',
|
||||||
'NotebookEdit',
|
'NotebookEdit',
|
||||||
'mcp__nanoclaw__*',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// MCP server names are sanitized by the SDK when forming tool prefixes:
|
||||||
|
// any character outside [A-Za-z0-9_-] becomes '_'. Mirror that here so our
|
||||||
|
// allowlist patterns match what the SDK actually exposes.
|
||||||
|
function mcpAllowPattern(serverName: string): string {
|
||||||
|
return `mcp__${serverName.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
|
||||||
|
}
|
||||||
|
|
||||||
interface SDKUserMessage {
|
interface SDKUserMessage {
|
||||||
type: 'user';
|
type: 'user';
|
||||||
message: { role: 'user'; content: string };
|
message: { role: 'user'; content: string };
|
||||||
@@ -277,7 +287,10 @@ export class ClaudeProvider implements AgentProvider {
|
|||||||
resume: input.continuation,
|
resume: input.continuation,
|
||||||
pathToClaudeCodeExecutable: '/pnpm/claude',
|
pathToClaudeCodeExecutable: '/pnpm/claude',
|
||||||
systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined,
|
systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined,
|
||||||
allowedTools: TOOL_ALLOWLIST,
|
allowedTools: [
|
||||||
|
...TOOL_ALLOWLIST,
|
||||||
|
...Object.keys(this.mcpServers).map(mcpAllowPattern),
|
||||||
|
],
|
||||||
disallowedTools: SDK_DISALLOWED_TOOLS,
|
disallowedTools: SDK_DISALLOWED_TOOLS,
|
||||||
env: this.env,
|
env: this.env,
|
||||||
permissionMode: 'bypassPermissions',
|
permissionMode: 'bypassPermissions',
|
||||||
|
|||||||
@@ -408,20 +408,12 @@ else
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# 2d. WhatsApp LID resolution. After whatsapp is installed (so Baileys
|
# 2d. (Removed) WhatsApp LID resolution was previously needed because the
|
||||||
# is on disk) and auth files have been copied (so we can connect with
|
# v6 adapter couldn't reliably translate LID→phone JIDs, so the migration
|
||||||
# the migrated identity), boot Baileys briefly to learn LID↔phone
|
# pre-created dual messaging_groups rows. With Baileys v7, the adapter
|
||||||
# mappings during initial sync, then write paired LID-keyed
|
# resolves LIDs via extractAddressingContext + signalRepository.lidMapping
|
||||||
# messaging_groups. Best-effort: any failure degrades to runtime
|
# on every inbound message, so dual rows are unnecessary and were causing
|
||||||
# approval flow, which the WA adapter's isMention=true on DMs handles.
|
# split sessions.
|
||||||
for ch in "${SELECTED_CHANNELS[@]}"; do
|
|
||||||
if [ "$ch" = "whatsapp" ]; then
|
|
||||||
run_step "2d-whatsapp-lids" \
|
|
||||||
"Resolve WhatsApp LIDs for migrated DMs" \
|
|
||||||
"setup/migrate-v2/whatsapp-resolve-lids.ts"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
77
nanoclaw.sh
77
nanoclaw.sh
@@ -137,6 +137,83 @@ write_header
|
|||||||
# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark.
|
# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark.
|
||||||
cat "$PROJECT_ROOT/assets/setup-splash.txt"
|
cat "$PROJECT_ROOT/assets/setup-splash.txt"
|
||||||
|
|
||||||
|
# ─── pre-flight: minimum hardware specs ────────────────────────────────
|
||||||
|
# NanoClaw runs an agent container per session. Below this threshold the
|
||||||
|
# host + container + agent will struggle (OOM under load). Soft warn — the
|
||||||
|
# user can override.
|
||||||
|
|
||||||
|
# RAM floor is set below 4 GB because "4 GB" VMs typically report 3700–3900 MB
|
||||||
|
# after kernel reserves (e.g. Hetzner CX21 ≈ 3814, AWS t3.medium ≈ 3800).
|
||||||
|
MIN_MEM_MB=3700
|
||||||
|
|
||||||
|
detect_mem_mb() {
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Linux)
|
||||||
|
awk '/^MemTotal:/ {printf "%d", $2 / 1024}' /proc/meminfo 2>/dev/null
|
||||||
|
;;
|
||||||
|
Darwin)
|
||||||
|
local bytes
|
||||||
|
bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
|
||||||
|
echo $(( bytes / 1024 / 1024 ))
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
MEM_MB=$(detect_mem_mb)
|
||||||
|
: "${MEM_MB:=0}"
|
||||||
|
|
||||||
|
LOW_MEM=false
|
||||||
|
[ "$MEM_MB" -gt 0 ] && [ "$MEM_MB" -lt "$MIN_MEM_MB" ] && LOW_MEM=true
|
||||||
|
|
||||||
|
if [ "$LOW_MEM" = true ]; then
|
||||||
|
printf ' %s\n' "$(red 'Warning: this machine likely cannot run NanoClaw.')"
|
||||||
|
printf ' %s\n' "$(dim 'NanoClaw recommends a 4 GB+ RAM machine. Below this, the host + agent')"
|
||||||
|
printf ' %s\n' "$(dim 'container will run out of memory under most workloads. A stronger')"
|
||||||
|
printf ' %s\n' "$(dim 'machine is strongly recommended.')"
|
||||||
|
printf ' %s\n' "$(dim " · Detected RAM: ${MEM_MB} MB")"
|
||||||
|
printf '\n'
|
||||||
|
read -r -p " $(bold 'Try anyway?') [y/N] " SPECS_ANS </dev/tty
|
||||||
|
|
||||||
|
case "${SPECS_ANS:-N}" in
|
||||||
|
[Yy]*)
|
||||||
|
ph_event setup_low_specs_continued mem_mb="$MEM_MB" low_mem="$LOW_MEM"
|
||||||
|
printf '\n'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
ph_event setup_low_specs_aborted mem_mb="$MEM_MB" low_mem="$LOW_MEM"
|
||||||
|
printf '\n %s\n\n' "$(dim 'Aborted. Re-run after upgrading the host.')"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── pre-flight: Google Cloud VM warning (Linux) ──────────────────────
|
||||||
|
# NanoClaw is known to not run reliably on Google Compute Engine instances.
|
||||||
|
# Warn early — before the root check or bootstrap spinner — so users can
|
||||||
|
# switch providers before sinking time into setup. Detection uses DMI
|
||||||
|
# (no network round-trip), which on GCE reports "Google" / "Google
|
||||||
|
# Compute Engine".
|
||||||
|
if [ "$(uname -s)" = "Linux" ] \
|
||||||
|
&& { grep -qi 'Google' /sys/class/dmi/id/product_name 2>/dev/null \
|
||||||
|
|| grep -qi 'Google' /sys/class/dmi/id/sys_vendor 2>/dev/null; }; then
|
||||||
|
printf ' %s\n' "$(red 'Warning: Google Cloud VM detected.')"
|
||||||
|
printf ' %s\n' "$(dim 'Google blocks sudo commands, so NanoClaw is unlikely to run successfully on this VM.')"
|
||||||
|
printf ' %s\n\n' "$(dim 'If you want to run NanoClaw successfully, switch to a different provider (Hetzner, Hostinger, exe.dev and others..).')"
|
||||||
|
read -r -p " $(bold 'Try anyway?') [y/N] " GCE_ANS </dev/tty
|
||||||
|
|
||||||
|
case "${GCE_ANS:-N}" in
|
||||||
|
[Yy]*)
|
||||||
|
ph_event setup_gce_continued
|
||||||
|
printf '\n'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
ph_event setup_gce_aborted
|
||||||
|
printf '\n %s\n\n' "$(dim 'Aborted. Re-run on a non-GCE host to continue.')"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
# ─── pre-flight: root user warning (Linux) ────────────────────────────
|
# ─── pre-flight: root user warning (Linux) ────────────────────────────
|
||||||
if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then
|
if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then
|
||||||
printf ' %s\n' \
|
printf ' %s\n' \
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "2.0.28",
|
"version": "2.0.32",
|
||||||
"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="140k tokens, 70% 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="141k tokens, 71% of context window">
|
||||||
<title>140k tokens, 70% of context window</title>
|
<title>141k tokens, 71% 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">140k</text>
|
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">141k</text>
|
||||||
<text x="71" y="14">140k</text>
|
<text x="71" y="14">141k</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
2
setup/add-whatsapp.sh
Executable file → Normal file
2
setup/add-whatsapp.sh
Executable file → Normal file
@@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
|
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
|
||||||
BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
|
BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9"
|
||||||
QRCODE_VERSION="qrcode@1.5.4"
|
QRCODE_VERSION="qrcode@1.5.4"
|
||||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||||
PINO_VERSION="pino@9.6.0"
|
PINO_VERSION="pino@9.6.0"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import path from 'path';
|
|||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
|
import { BACK_TO_CHANNEL_SELECTION } from './lib/back-nav.js';
|
||||||
import { runDiscordChannel } from './channels/discord.js';
|
import { runDiscordChannel } from './channels/discord.js';
|
||||||
import { runIMessageChannel } from './channels/imessage.js';
|
import { runIMessageChannel } from './channels/imessage.js';
|
||||||
import { runSignalChannel } from './channels/signal.js';
|
import { runSignalChannel } from './channels/signal.js';
|
||||||
@@ -440,35 +441,45 @@ async function main(): Promise<void> {
|
|||||||
let channelChoice: ChannelChoice = 'skip';
|
let channelChoice: ChannelChoice = 'skip';
|
||||||
|
|
||||||
if (!skip.has('channel')) {
|
if (!skip.has('channel')) {
|
||||||
channelChoice = await askChannelChoice();
|
// Loop so a channel sub-flow can return BACK_TO_CHANNEL_SELECTION on
|
||||||
if (channelChoice !== 'skip' && channelChoice !== 'other') {
|
// its first prompt and bounce the user back to the chooser without
|
||||||
await resolveDisplayName();
|
// restarting setup. Channels not yet wired with the back option just
|
||||||
}
|
// return void and the loop exits after one pass.
|
||||||
if (channelChoice === 'telegram') {
|
let backed = true;
|
||||||
await runTelegramChannel(displayName!);
|
while (backed) {
|
||||||
} else if (channelChoice === 'discord') {
|
backed = false;
|
||||||
await runDiscordChannel(displayName!);
|
channelChoice = await askChannelChoice();
|
||||||
} else if (channelChoice === 'whatsapp') {
|
if (channelChoice !== 'skip' && channelChoice !== 'other') {
|
||||||
await runWhatsAppChannel(displayName!);
|
await resolveDisplayName();
|
||||||
} else if (channelChoice === 'signal') {
|
}
|
||||||
await runSignalChannel(displayName!);
|
let result: void | typeof BACK_TO_CHANNEL_SELECTION;
|
||||||
} else if (channelChoice === 'teams') {
|
if (channelChoice === 'telegram') {
|
||||||
await runTeamsChannel(displayName!);
|
result = await runTelegramChannel(displayName!);
|
||||||
} else if (channelChoice === 'slack') {
|
} else if (channelChoice === 'discord') {
|
||||||
await runSlackChannel(displayName!);
|
result = await runDiscordChannel(displayName!);
|
||||||
} else if (channelChoice === 'imessage') {
|
} else if (channelChoice === 'whatsapp') {
|
||||||
await runIMessageChannel(displayName!);
|
result = await runWhatsAppChannel(displayName!);
|
||||||
} else if (channelChoice === 'other') {
|
} else if (channelChoice === 'signal') {
|
||||||
await askOtherChannelName();
|
result = await runSignalChannel(displayName!);
|
||||||
} else {
|
} else if (channelChoice === 'teams') {
|
||||||
p.log.info(
|
result = await runTeamsChannel(displayName!);
|
||||||
brandBody(
|
} else if (channelChoice === 'slack') {
|
||||||
wrapForGutter(
|
result = await runSlackChannel(displayName!);
|
||||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
} else if (channelChoice === 'imessage') {
|
||||||
4,
|
result = await runIMessageChannel(displayName!);
|
||||||
|
} else if (channelChoice === 'other') {
|
||||||
|
await askOtherChannelName();
|
||||||
|
} else {
|
||||||
|
p.log.info(
|
||||||
|
brandBody(
|
||||||
|
wrapForGutter(
|
||||||
|
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||||||
|
4,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
|
if (result === BACK_TO_CHANNEL_SELECTION) backed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,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 { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||||
import { brightSelect } from '../lib/bright-select.js';
|
import { brightSelect } from '../lib/bright-select.js';
|
||||||
import { confirmThenOpen, formatNoteLink } 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';
|
||||||
@@ -48,8 +49,10 @@ interface AppInfo {
|
|||||||
owner: { id: string; username: string } | null;
|
owner: { id: string; username: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runDiscordChannel(displayName: string): Promise<void> {
|
export async function runDiscordChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||||
const hasBot = await askHasBotToken();
|
const choice = await askHasBotToken();
|
||||||
|
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||||
|
const hasBot = choice === 'yes';
|
||||||
if (!hasBot) {
|
if (!hasBot) {
|
||||||
await walkThroughBotCreation();
|
await walkThroughBotCreation();
|
||||||
}
|
}
|
||||||
@@ -142,17 +145,18 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askHasBotToken(): Promise<boolean> {
|
async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> {
|
||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await brightSelect({
|
await brightSelect({
|
||||||
message: 'Do you already have a Discord bot?',
|
message: 'Do you already have a Discord bot?',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'yes', label: 'Yes, I have a bot token ready' },
|
{ value: 'yes', label: 'Yes, I have a bot token ready' },
|
||||||
{ value: 'no', label: "No, walk me through creating one" },
|
{ value: 'no', label: "No, walk me through creating one" },
|
||||||
|
{ value: 'back', label: '← Back to channel selection' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return answer === 'yes';
|
return answer as 'yes' | 'no' | 'back';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function walkThroughBotCreation(): Promise<void> {
|
async function walkThroughBotCreation(): Promise<void> {
|
||||||
|
|||||||
@@ -33,6 +33,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 { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||||
import { brightSelect } from '../lib/bright-select.js';
|
import { brightSelect } from '../lib/bright-select.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';
|
||||||
@@ -48,10 +49,11 @@ interface RemoteCreds {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runIMessageChannel(displayName: string): Promise<void> {
|
export async function runIMessageChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||||
const isMac = os.platform() === 'darwin';
|
const isMac = os.platform() === 'darwin';
|
||||||
|
|
||||||
const mode = await askMode(isMac);
|
const mode = await askMode(isMac);
|
||||||
|
if (mode === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||||
let remoteCreds: RemoteCreds | null = null;
|
let remoteCreds: RemoteCreds | null = null;
|
||||||
|
|
||||||
if (mode === 'local') {
|
if (mode === 'local') {
|
||||||
@@ -139,34 +141,38 @@ export async function runIMessageChannel(displayName: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askMode(isMac: boolean): Promise<Mode> {
|
async function askMode(isMac: boolean): Promise<Mode | 'back'> {
|
||||||
|
const baseOptions = isMac
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: 'local' as const,
|
||||||
|
label: 'Local (this Mac)',
|
||||||
|
hint: "uses this machine's iMessage account",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'remote' as const,
|
||||||
|
label: 'Remote (Photon API)',
|
||||||
|
hint: 'the bot lives on another server',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
value: 'remote' as const,
|
||||||
|
label: 'Remote (Photon API)',
|
||||||
|
hint: 'only option off macOS',
|
||||||
|
},
|
||||||
|
];
|
||||||
const choice = ensureAnswer(
|
const choice = ensureAnswer(
|
||||||
await brightSelect<Mode>({
|
await brightSelect<Mode | 'back'>({
|
||||||
message: 'How should iMessage run?',
|
message: 'How should iMessage run?',
|
||||||
initialValue: isMac ? 'local' : 'remote',
|
initialValue: isMac ? 'local' : 'remote',
|
||||||
options: isMac
|
options: [
|
||||||
? [
|
...baseOptions,
|
||||||
{
|
{ value: 'back', label: '← Back to channel selection' },
|
||||||
value: 'local',
|
],
|
||||||
label: 'Local (this Mac)',
|
|
||||||
hint: "uses this machine's iMessage account",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'remote',
|
|
||||||
label: 'Remote (Photon API)',
|
|
||||||
hint: 'the bot lives on another server',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
value: 'remote',
|
|
||||||
label: 'Remote (Photon API)',
|
|
||||||
hint: 'only option off macOS',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setupLog.userInput('imessage_mode', String(choice));
|
if (choice !== 'back') setupLog.userInput('imessage_mode', String(choice));
|
||||||
return choice;
|
return choice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import k from 'kleur';
|
|||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||||
|
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||||
|
import { brightSelect } from '../lib/bright-select.js';
|
||||||
import {
|
import {
|
||||||
type Block,
|
type Block,
|
||||||
type StepResult,
|
type StepResult,
|
||||||
@@ -48,7 +50,33 @@ import { accentGreen, fmtDuration, note } from '../lib/theme.js';
|
|||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
|
|
||||||
export async function runSignalChannel(displayName: string): Promise<void> {
|
export async function runSignalChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||||
|
note(
|
||||||
|
[
|
||||||
|
"NanoClaw links to Signal as a *secondary* device on your existing",
|
||||||
|
"phone — no new number needed. Your assistant will send and receive",
|
||||||
|
"messages as the number on that phone.",
|
||||||
|
'',
|
||||||
|
"Here's what's about to happen — no input needed for any of it:",
|
||||||
|
'',
|
||||||
|
' 1. Set up signal-cli (auto-installs if missing)',
|
||||||
|
' 2. Install the Signal adapter',
|
||||||
|
' 3. Show a QR code — scan it from Signal → Settings → Linked Devices',
|
||||||
|
' 4. Wire your assistant and send a welcome message',
|
||||||
|
].join('\n'),
|
||||||
|
'Set up Signal',
|
||||||
|
);
|
||||||
|
|
||||||
|
const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({
|
||||||
|
message: 'Ready to set up Signal?',
|
||||||
|
options: [
|
||||||
|
{ value: 'continue', label: 'Continue' },
|
||||||
|
{ value: 'back', label: '← Back to channel selection' },
|
||||||
|
],
|
||||||
|
initialValue: 'continue',
|
||||||
|
}));
|
||||||
|
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||||
|
|
||||||
await ensureSignalCli();
|
await ensureSignalCli();
|
||||||
|
|
||||||
const install = await runQuietChild(
|
const install = await runQuietChild(
|
||||||
@@ -134,42 +162,74 @@ export async function runSignalChannel(displayName: string): Promise<void> {
|
|||||||
|
|
||||||
async function ensureSignalCli(): Promise<void> {
|
async function ensureSignalCli(): Promise<void> {
|
||||||
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
|
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
|
||||||
const probe = spawnSync(cli, ['--version'], {
|
const probeFor = (): boolean => {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
const r = spawnSync(cli, ['--version'], {
|
||||||
});
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
if (!probe.error && probe.status === 0) return;
|
});
|
||||||
|
return !r.error && r.status === 0;
|
||||||
|
};
|
||||||
|
if (probeFor()) return;
|
||||||
|
|
||||||
|
note(
|
||||||
|
[
|
||||||
|
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||||
|
"We'll install it for you now — about 30 seconds, one-time only.",
|
||||||
|
'',
|
||||||
|
process.platform === 'darwin'
|
||||||
|
? "On this Mac we'll use Homebrew (no admin password needed)."
|
||||||
|
: "On Linux we'll grab the native release binary (no Java needed) and install it to ~/.local/bin.",
|
||||||
|
].join('\n'),
|
||||||
|
'Setting up signal-cli',
|
||||||
|
);
|
||||||
|
|
||||||
|
const install = await runQuietChild(
|
||||||
|
'install-signal-cli',
|
||||||
|
'bash',
|
||||||
|
['setup/install-signal-cli.sh'],
|
||||||
|
{
|
||||||
|
running: 'Installing signal-cli…',
|
||||||
|
done: 'signal-cli installed.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (install.ok && probeFor()) return;
|
||||||
|
|
||||||
|
const reason = install.terminal?.fields.ERROR;
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
"We couldn't install signal-cli automatically.",
|
||||||
|
reason === 'homebrew_not_installed'
|
||||||
|
? ' Reason: Homebrew is not installed.'
|
||||||
|
: ` Reason: ${reason ?? 'unknown'}.`,
|
||||||
'',
|
'',
|
||||||
'The quickest way on macOS is Homebrew:',
|
'You can install it manually:',
|
||||||
'',
|
'',
|
||||||
k.cyan(' brew install signal-cli'),
|
k.cyan(' brew install signal-cli'),
|
||||||
'',
|
'',
|
||||||
"Install it in another terminal, then re-run setup.",
|
'Then re-run setup.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'signal-cli not found',
|
"Couldn't install signal-cli",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
"We couldn't install signal-cli automatically.",
|
||||||
|
` Reason: ${reason ?? 'unknown'}.`,
|
||||||
'',
|
'',
|
||||||
'Grab the latest release from GitHub:',
|
'You can install it manually from GitHub:',
|
||||||
'',
|
'',
|
||||||
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
|
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
|
||||||
'',
|
'',
|
||||||
"Install it, make sure `signal-cli --version` works, then re-run setup.",
|
'Then re-run setup.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'signal-cli not found',
|
"Couldn't install signal-cli",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await fail(
|
await fail(
|
||||||
'signal-install',
|
'install-signal-cli',
|
||||||
'signal-cli is required but not installed.',
|
'signal-cli is required but the auto-install failed.',
|
||||||
'Install it and re-run setup.',
|
'Install it manually and re-run setup.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ 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, formatNoteLink } from '../lib/browser.js';
|
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||||
|
import { brightSelect } from '../lib/bright-select.js';
|
||||||
|
import { formatNoteLink, openUrl } from '../lib/browser.js';
|
||||||
|
import { isHeadless } from '../platform.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 { readEnvKey } from '../environment.js';
|
import { readEnvKey } from '../environment.js';
|
||||||
@@ -42,8 +45,9 @@ interface WorkspaceInfo {
|
|||||||
botUserId: string;
|
botUserId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runSlackChannel(displayName: string): Promise<void> {
|
export async function runSlackChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||||
await walkThroughAppCreation();
|
const intro = await walkThroughAppCreation();
|
||||||
|
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||||
|
|
||||||
const token = await collectBotToken();
|
const token = await collectBotToken();
|
||||||
const signingSecret = await collectSigningSecret();
|
const signingSecret = await collectSigningSecret();
|
||||||
@@ -121,7 +125,7 @@ export async function runSlackChannel(displayName: string): Promise<void> {
|
|||||||
showPostInstallChecklist(info);
|
showPostInstallChecklist(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function walkThroughAppCreation(): Promise<void> {
|
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"You'll create a Slack app that the assistant talks through.",
|
"You'll create a Slack app that the assistant talks through.",
|
||||||
@@ -140,7 +144,20 @@ async function walkThroughAppCreation(): Promise<void> {
|
|||||||
].filter((line): line is string => line !== null).join('\n'),
|
].filter((line): line is string => line !== null).join('\n'),
|
||||||
'Create a Slack app',
|
'Create a Slack app',
|
||||||
);
|
);
|
||||||
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
|
|
||||||
|
// Back-aware gate replacing the old `confirmThenOpen` "Press Enter to open
|
||||||
|
// Slack app settings" so users can bail out of Slack before we open the
|
||||||
|
// browser or ask for tokens.
|
||||||
|
const choice = ensureAnswer(await brightSelect<'open' | 'back'>({
|
||||||
|
message: 'Open Slack app settings in your browser?',
|
||||||
|
options: [
|
||||||
|
{ value: 'open', label: 'Open Slack app settings' },
|
||||||
|
{ value: 'back', label: '← Back to channel selection' },
|
||||||
|
],
|
||||||
|
initialValue: 'open',
|
||||||
|
}));
|
||||||
|
if (choice === 'back') return 'back';
|
||||||
|
if (!isHeadless()) openUrl(SLACK_APPS_URL);
|
||||||
|
|
||||||
ensureAnswer(
|
ensureAnswer(
|
||||||
await p.confirm({
|
await p.confirm({
|
||||||
@@ -148,6 +165,7 @@ async function walkThroughAppCreation(): Promise<void> {
|
|||||||
initialValue: true,
|
initialValue: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
return 'continue';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectBotToken(): Promise<string> {
|
async function collectBotToken(): Promise<string> {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import path from 'path';
|
|||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
|
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||||
import { brightSelect } from '../lib/bright-select.js';
|
import { brightSelect } from '../lib/bright-select.js';
|
||||||
import { confirmThenOpen } from '../lib/browser.js';
|
import { confirmThenOpen } from '../lib/browser.js';
|
||||||
import {
|
import {
|
||||||
@@ -57,18 +58,24 @@ interface Collected {
|
|||||||
agentName?: string;
|
agentName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runTeamsChannel(_displayName: string): Promise<void> {
|
export async function runTeamsChannel(_displayName: string): Promise<ChannelFlowResult> {
|
||||||
const collected: Collected = {};
|
const collected: Collected = {};
|
||||||
const completed: string[] = [];
|
const completed: string[] = [];
|
||||||
|
|
||||||
const existingAppId = readEnvKey('TEAMS_APP_ID');
|
const existingAppId = readEnvKey('TEAMS_APP_ID');
|
||||||
const existingPassword = readEnvKey('TEAMS_APP_PASSWORD');
|
const existingPassword = readEnvKey('TEAMS_APP_PASSWORD');
|
||||||
if (existingAppId && existingPassword) {
|
if (existingAppId && existingPassword) {
|
||||||
const reuse = ensureAnswer(await p.confirm({
|
const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({
|
||||||
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
|
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
|
||||||
initialValue: true,
|
options: [
|
||||||
|
{ value: 'yes', label: 'Yes, use the existing credentials' },
|
||||||
|
{ value: 'no', label: "No, set up new ones" },
|
||||||
|
{ value: 'back', label: '← Back to channel selection' },
|
||||||
|
],
|
||||||
|
initialValue: 'yes',
|
||||||
}));
|
}));
|
||||||
if (reuse) {
|
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||||
|
if (choice === 'yes') {
|
||||||
collected.appId = existingAppId;
|
collected.appId = existingAppId;
|
||||||
collected.appPassword = existingPassword;
|
collected.appPassword = existingPassword;
|
||||||
collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
|
collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
|
||||||
@@ -85,7 +92,8 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
|
|||||||
|
|
||||||
printIntro();
|
printIntro();
|
||||||
|
|
||||||
await confirmPrereqs({ collected, completed });
|
const prereqsResult = await confirmPrereqs({ collected, completed });
|
||||||
|
if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||||
await stepPublicUrl({ collected, completed });
|
await stepPublicUrl({ collected, completed });
|
||||||
await stepAppRegistration({ collected, completed });
|
await stepAppRegistration({ collected, completed });
|
||||||
await stepClientSecret({ collected, completed });
|
await stepClientSecret({ collected, completed });
|
||||||
@@ -116,7 +124,7 @@ function printIntro(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<void> {
|
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<'continue' | 'back'> {
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
'Before we start, confirm you have:',
|
'Before we start, confirm you have:',
|
||||||
@@ -131,13 +139,36 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[]
|
|||||||
'Prereqs',
|
'Prereqs',
|
||||||
);
|
);
|
||||||
|
|
||||||
await stepGate({
|
// Back-aware variant of stepGate — Back is only offered on the very first
|
||||||
stepName: 'teams-prereqs',
|
// step of the Teams flow so users can bail out before any state is taken.
|
||||||
stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel',
|
while (true) {
|
||||||
reshow: () => confirmPrereqs(args),
|
const choice = ensureAnswer(
|
||||||
args,
|
await brightSelect<'done' | 'help' | 'reshow' | 'back'>({
|
||||||
});
|
message: 'How did that go?',
|
||||||
|
options: [
|
||||||
|
{ value: 'done', label: "Done — let's continue" },
|
||||||
|
{ value: 'help', label: 'Stuck — hand me off to Claude' },
|
||||||
|
{ value: 'reshow', label: 'Show me the steps again' },
|
||||||
|
{ value: 'back', label: '← Back to channel selection' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (choice === 'back') return 'back';
|
||||||
|
if (choice === 'done') break;
|
||||||
|
if (choice === 'help') {
|
||||||
|
await offerHandoff({
|
||||||
|
step: 'teams-prereqs',
|
||||||
|
stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel',
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (choice === 'reshow') {
|
||||||
|
return confirmPrereqs(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
args.completed.push('Prereqs confirmed.');
|
args.completed.push('Prereqs confirmed.');
|
||||||
|
return 'continue';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── step: public URL ──────────────────────────────────────────────────
|
// ─── step: public URL ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ 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 { openUrl } from '../lib/browser.js';
|
|
||||||
import { isHeadless } from '../platform.js';
|
import { isHeadless } from '../platform.js';
|
||||||
|
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||||
|
import { confirmThenOpen, formatNoteLink, openUrl } from '../lib/browser.js';
|
||||||
|
import { brightSelect } from '../lib/bright-select.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import {
|
import {
|
||||||
type Block,
|
type Block,
|
||||||
@@ -39,8 +41,10 @@ import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/th
|
|||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
|
|
||||||
export async function runTelegramChannel(displayName: string): Promise<void> {
|
export async function runTelegramChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||||
const token = await collectTelegramToken();
|
const tokenOrBack = await collectTelegramToken();
|
||||||
|
if (tokenOrBack === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||||
|
const token = tokenOrBack;
|
||||||
const botUsername = await validateTelegramToken(token);
|
const botUsername = await validateTelegramToken(token);
|
||||||
|
|
||||||
// Deep-link the user into the bot's chat so they're on the right screen
|
// Deep-link the user into the bot's chat so they're on the right screen
|
||||||
@@ -155,17 +159,24 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectTelegramToken(): Promise<string> {
|
async function collectTelegramToken(): Promise<string | 'back'> {
|
||||||
const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
|
const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
|
||||||
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
|
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
|
||||||
const reuse = ensureAnswer(await p.confirm({
|
const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({
|
||||||
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
|
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
|
||||||
initialValue: true,
|
options: [
|
||||||
|
{ value: 'yes', label: 'Yes, use the existing token' },
|
||||||
|
{ value: 'no', label: 'No, paste a new one' },
|
||||||
|
{ value: 'back', label: '← Back to channel selection' },
|
||||||
|
],
|
||||||
|
initialValue: 'yes',
|
||||||
}));
|
}));
|
||||||
if (reuse) {
|
if (choice === 'back') return 'back';
|
||||||
|
if (choice === 'yes') {
|
||||||
setupLog.userInput('telegram_token', 'reused-existing');
|
setupLog.userInput('telegram_token', 'reused-existing');
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
// 'no' falls through to the paste flow below
|
||||||
}
|
}
|
||||||
|
|
||||||
note(
|
note(
|
||||||
@@ -183,6 +194,19 @@ async function collectTelegramToken(): Promise<string> {
|
|||||||
'Set up your Telegram bot',
|
'Set up your Telegram bot',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Back-aware gate before the password prompt — `p.password` doesn't
|
||||||
|
// accept extra options, so we offer Back as a separate brightSelect
|
||||||
|
// immediately after the BotFather instructions and before the paste.
|
||||||
|
const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({
|
||||||
|
message: 'Ready to paste your bot token?',
|
||||||
|
options: [
|
||||||
|
{ value: 'continue', label: 'Yes, paste it on the next prompt' },
|
||||||
|
{ value: 'back', label: '← Back to channel selection' },
|
||||||
|
],
|
||||||
|
initialValue: 'continue',
|
||||||
|
}));
|
||||||
|
if (proceed === 'back') return 'back';
|
||||||
|
|
||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.password({
|
await p.password({
|
||||||
message: 'Paste your bot token',
|
message: 'Paste your bot token',
|
||||||
|
|||||||
@@ -33,6 +33,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 { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||||
import { brightSelect } from '../lib/bright-select.js';
|
import { brightSelect } from '../lib/bright-select.js';
|
||||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||||
import {
|
import {
|
||||||
@@ -53,8 +54,9 @@ const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
|
|||||||
|
|
||||||
type AuthMethod = 'qr' | 'pairing-code';
|
type AuthMethod = 'qr' | 'pairing-code';
|
||||||
|
|
||||||
export async function runWhatsAppChannel(displayName: string): Promise<void> {
|
export async function runWhatsAppChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||||
const method = await askAuthMethod();
|
const method = await askAuthMethod();
|
||||||
|
if (method === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||||
const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined;
|
const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined;
|
||||||
|
|
||||||
const install = await runQuietChild(
|
const install = await runQuietChild(
|
||||||
@@ -148,7 +150,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askAuthMethod(): Promise<AuthMethod> {
|
async function askAuthMethod(): Promise<AuthMethod | 'back'> {
|
||||||
const choice = ensureAnswer(
|
const choice = ensureAnswer(
|
||||||
await brightSelect({
|
await brightSelect({
|
||||||
message: 'How would you like to authenticate with WhatsApp?',
|
message: 'How would you like to authenticate with WhatsApp?',
|
||||||
@@ -163,10 +165,14 @@ async function askAuthMethod(): Promise<AuthMethod> {
|
|||||||
label: 'Enter a pairing code on your phone',
|
label: 'Enter a pairing code on your phone',
|
||||||
hint: 'no camera needed',
|
hint: 'no camera needed',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'back',
|
||||||
|
label: '← Back to channel selection',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
) as AuthMethod;
|
) as AuthMethod | 'back';
|
||||||
setupLog.userInput('whatsapp_auth_method', choice);
|
if (choice !== 'back') setupLog.userInput('whatsapp_auth_method', choice);
|
||||||
return choice;
|
return choice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +318,7 @@ async function renderQr(qr: string): Promise<string[]> {
|
|||||||
const QRCode = await import('qrcode');
|
const QRCode = await import('qrcode');
|
||||||
const qrText = await QRCode.toString(qr, { type: 'terminal', small: true });
|
const qrText = await QRCode.toString(qr, { type: 'terminal', small: true });
|
||||||
const caption = k.dim(
|
const caption = k.dim(
|
||||||
' Open WhatsApp → Settings → Linked Devices → Link a Device → scan.',
|
' Open WhatsApp → You / Settings → Linked Devices → Link a Device → scan.',
|
||||||
);
|
);
|
||||||
return [...qrText.trimEnd().split('\n'), '', caption];
|
return [...qrText.trimEnd().split('\n'), '', caption];
|
||||||
} catch {
|
} catch {
|
||||||
@@ -328,7 +334,7 @@ function formatPairingCard(code: string): string {
|
|||||||
'',
|
'',
|
||||||
` ${brandBold(spaced)}`,
|
` ${brandBold(spaced)}`,
|
||||||
'',
|
'',
|
||||||
k.dim(' Open WhatsApp → Settings → Linked Devices → Link a Device'),
|
k.dim(' Open WhatsApp → You / Settings → Linked Devices → Link a Device'),
|
||||||
k.dim(' → "Link with phone number instead" → enter this code.'),
|
k.dim(' → "Link with phone number instead" → enter this code.'),
|
||||||
k.dim(' It expires in ~60 seconds.'),
|
k.dim(' It expires in ~60 seconds.'),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
@@ -17,30 +17,40 @@ if command -v node >/dev/null 2>&1; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$(uname -s)" in
|
if command -v uvx >/dev/null 2>&1; then
|
||||||
Darwin)
|
echo "STEP: uvx-nodeenv"
|
||||||
echo "STEP: brew-install-node"
|
uvx nodeenv -n lts ~/node
|
||||||
if ! command -v brew >/dev/null 2>&1; then
|
mkdir -p ~/.local/bin
|
||||||
|
ln -sf ~/node/bin/node ~/.local/bin/node
|
||||||
|
ln -sf ~/node/bin/npm ~/.local/bin/npm
|
||||||
|
ln -sf ~/node/bin/npx ~/.local/bin/npx
|
||||||
|
ln -sf ~/node/bin/pnpm ~/.local/bin/pnpm
|
||||||
|
else
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin)
|
||||||
|
echo "STEP: brew-install-node"
|
||||||
|
if ! command -v brew >/dev/null 2>&1; then
|
||||||
|
echo "STATUS: failed"
|
||||||
|
echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run."
|
||||||
|
echo "=== END ==="
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
brew install node@22
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
echo "STEP: nodesource-setup"
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||||
|
echo "STEP: apt-install-nodejs"
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
;;
|
||||||
|
*)
|
||||||
echo "STATUS: failed"
|
echo "STATUS: failed"
|
||||||
echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run."
|
echo "ERROR: Unsupported platform: $(uname -s)"
|
||||||
echo "=== END ==="
|
echo "=== END ==="
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
;;
|
||||||
brew install node@22
|
esac
|
||||||
;;
|
fi
|
||||||
Linux)
|
|
||||||
echo "STEP: nodesource-setup"
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
|
||||||
echo "STEP: apt-install-nodejs"
|
|
||||||
sudo apt-get install -y nodejs
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "STATUS: failed"
|
|
||||||
echo "ERROR: Unsupported platform: $(uname -s)"
|
|
||||||
echo "=== END ==="
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if ! command -v node >/dev/null 2>&1; then
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
echo "STATUS: failed"
|
echo "STATUS: failed"
|
||||||
|
|||||||
78
setup/install-signal-cli.sh
Executable file
78
setup/install-signal-cli.sh
Executable file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install-signal-cli.sh — auto-install signal-cli on the host.
|
||||||
|
#
|
||||||
|
# NanoClaw needs `signal-cli` on PATH to talk to Signal. Picks the right
|
||||||
|
# install method per platform:
|
||||||
|
# macOS → `brew install signal-cli` (bottled, no Java needed)
|
||||||
|
# Linux → download latest native binary from GitHub releases to
|
||||||
|
# ~/.local/bin/signal-cli (no Java, no sudo)
|
||||||
|
#
|
||||||
|
# Emits the standard NanoClaw STATUS block on success or failure so the
|
||||||
|
# `runQuietChild` driver can parse the outcome.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="0.14.3"
|
||||||
|
INSTALL_DIR="${HOME}/.local/bin"
|
||||||
|
|
||||||
|
emit_status() {
|
||||||
|
local status=$1 error=${2:-}
|
||||||
|
echo "=== NANOCLAW SETUP: INSTALL_SIGNAL_CLI ==="
|
||||||
|
echo "STATUS: ${status}"
|
||||||
|
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||||
|
echo "=== END ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
log() { echo "[install-signal-cli] $*" >&2; }
|
||||||
|
|
||||||
|
uname_s=$(uname)
|
||||||
|
|
||||||
|
if [[ "${uname_s}" == "Darwin" ]]; then
|
||||||
|
if ! command -v brew >/dev/null 2>&1; then
|
||||||
|
emit_status failed "homebrew_not_installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "Installing signal-cli via Homebrew…"
|
||||||
|
brew install signal-cli >&2 || {
|
||||||
|
emit_status failed "brew_install_failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
emit_status success
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${uname_s}" != "Linux" ]]; then
|
||||||
|
emit_status failed "unsupported_platform_${uname_s}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Linux native build (no Java required) → ~/.local/bin/signal-cli.
|
||||||
|
URL="https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz"
|
||||||
|
TARBALL=$(mktemp -t signal-cli.XXXXXX.tar.gz)
|
||||||
|
|
||||||
|
log "Downloading signal-cli v${VERSION} (~96MB)…"
|
||||||
|
if ! curl -fLsS -o "${TARBALL}" "${URL}"; then
|
||||||
|
rm -f "${TARBALL}"
|
||||||
|
emit_status failed "download_failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Extracting…"
|
||||||
|
EXTRACT_DIR=$(mktemp -d)
|
||||||
|
if ! tar -xzf "${TARBALL}" -C "${EXTRACT_DIR}"; then
|
||||||
|
rm -rf "${TARBALL}" "${EXTRACT_DIR}"
|
||||||
|
emit_status failed "extract_failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${INSTALL_DIR}"
|
||||||
|
log "Installing to ${INSTALL_DIR}/signal-cli…"
|
||||||
|
if ! mv "${EXTRACT_DIR}/signal-cli" "${INSTALL_DIR}/signal-cli"; then
|
||||||
|
rm -rf "${TARBALL}" "${EXTRACT_DIR}"
|
||||||
|
emit_status failed "install_failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod +x "${INSTALL_DIR}/signal-cli"
|
||||||
|
rm -rf "${TARBALL}" "${EXTRACT_DIR}"
|
||||||
|
|
||||||
|
emit_status success
|
||||||
2
setup/install-whatsapp.sh
Executable file → Normal file
2
setup/install-whatsapp.sh
Executable file → Normal file
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "STEP: pnpm-install"
|
echo "STEP: pnpm-install"
|
||||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||||
|
|
||||||
echo "STEP: pnpm-build"
|
echo "STEP: pnpm-build"
|
||||||
pnpm run build
|
pnpm run build
|
||||||
|
|||||||
17
setup/lib/back-nav.ts
Normal file
17
setup/lib/back-nav.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Channel-flow back-navigation sentinel.
|
||||||
|
*
|
||||||
|
* Each `runXxxChannel(displayName)` in `setup/channels/` may return either
|
||||||
|
* `void` (sub-flow completed normally) or `BACK_TO_CHANNEL_SELECTION` to
|
||||||
|
* signal "the user picked '← Back to channel selection' on my first
|
||||||
|
* prompt; please re-run the channel chooser." `setup/auto.ts` catches
|
||||||
|
* that signal and loops back to `askChannelChoice()`.
|
||||||
|
*
|
||||||
|
* Back is only offered on the *first* interactive prompt of each channel
|
||||||
|
* sub-flow — once the user has answered something, they're committed
|
||||||
|
* (subsequent steps may have side effects like opening browsers, hitting
|
||||||
|
* APIs, or installing adapter packages, none of which are easily undone).
|
||||||
|
*/
|
||||||
|
export const BACK_TO_CHANNEL_SELECTION = Symbol('BACK_TO_CHANNEL_SELECTION');
|
||||||
|
|
||||||
|
export type ChannelFlowResult = void | typeof BACK_TO_CHANNEL_SELECTION;
|
||||||
@@ -23,7 +23,7 @@ import { emit as phEmit } from './diagnostics.js';
|
|||||||
import type { StepResult, SpinnerLabels } from './runner.js';
|
import type { StepResult, SpinnerLabels } from './runner.js';
|
||||||
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
|
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { brandBody, fitToWidth } from './theme.js';
|
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
|
||||||
|
|
||||||
const WINDOW_SIZE = 3;
|
const WINDOW_SIZE = 3;
|
||||||
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
|
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
|
||||||
@@ -85,9 +85,8 @@ async function runUnderWindow(
|
|||||||
const redraw = (): void => {
|
const redraw = (): void => {
|
||||||
if (stallPromptActive) return;
|
if (stallPromptActive) return;
|
||||||
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(labels.running, suffix);
|
const header = fitToWidth(labels.running, 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`);
|
||||||
|
|
||||||
@@ -164,8 +163,7 @@ async function runUnderWindow(
|
|||||||
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 (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;
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
/**
|
|
||||||
* migrate-v2 step: resolve WhatsApp LIDs for migrated DM messaging_groups.
|
|
||||||
*
|
|
||||||
* Why this exists
|
|
||||||
* ───────────────
|
|
||||||
* v1 stored every WhatsApp DM as `<phone>@s.whatsapp.net`. v2's WA adapter
|
|
||||||
* sometimes resolves the chat to `<lid>@lid` instead — when WhatsApp
|
|
||||||
* delivers a message via the LID protocol and Baileys hasn't yet learned
|
|
||||||
* a LID→phone mapping for that contact (cold cache after migration). The
|
|
||||||
* router then can't find the phone-keyed messaging_group and silently
|
|
||||||
* drops the message at router.ts:184 — until the LID is learned (which
|
|
||||||
* happens lazily, message-by-message, via `chats.phoneNumberShare`).
|
|
||||||
*
|
|
||||||
* Baileys persists LID↔phone mappings to disk as
|
|
||||||
* `store/auth/lid-mapping-<lid>_reverse.json` (LID → phone) and
|
|
||||||
* `lid-mapping-<phone>.json` (phone → LID). v1 will already have populated
|
|
||||||
* these for every contact it talked to. This step parses the reverse
|
|
||||||
* files and writes paired LID-keyed `messaging_groups` +
|
|
||||||
* `messaging_group_agents` rows so both `<phone>@s.whatsapp.net` and
|
|
||||||
* `<lid>@lid` route to the same agent_group with the same engage rules.
|
|
||||||
*
|
|
||||||
* No Baileys boot, no network — pure filesystem read. If store/auth is
|
|
||||||
* missing or has no reverse mappings, exits 0 with a SKIPPED. Runtime
|
|
||||||
* fallback (WA adapter sets isMention=true on DMs → router auto-creates
|
|
||||||
* with `unknown_sender_policy=request_approval`) handles anything we
|
|
||||||
* miss.
|
|
||||||
*
|
|
||||||
* Usage: pnpm exec tsx setup/migrate-v2/whatsapp-resolve-lids.ts
|
|
||||||
*/
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { DATA_DIR } from '../../src/config.js';
|
|
||||||
import { initDb } from '../../src/db/connection.js';
|
|
||||||
import {
|
|
||||||
createMessagingGroup,
|
|
||||||
createMessagingGroupAgent,
|
|
||||||
getMessagingGroupAgentByPair,
|
|
||||||
getMessagingGroupByPlatform,
|
|
||||||
} from '../../src/db/messaging-groups.js';
|
|
||||||
import { runMigrations } from '../../src/db/migrations/index.js';
|
|
||||||
import { generateId } from './shared.js';
|
|
||||||
|
|
||||||
interface RawMessagingGroup {
|
|
||||||
id: string;
|
|
||||||
channel_type: string;
|
|
||||||
platform_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawWiring {
|
|
||||||
id: string;
|
|
||||||
messaging_group_id: string;
|
|
||||||
agent_group_id: string;
|
|
||||||
engage_mode: string;
|
|
||||||
engage_pattern: string | null;
|
|
||||||
sender_scope: string;
|
|
||||||
ignored_message_policy: string;
|
|
||||||
session_mode: string;
|
|
||||||
priority: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const REVERSE_FILE_RE = /^lid-mapping-(\d+)_reverse\.json$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read store/auth/lid-mapping-*_reverse.json into a Map<lidUser, phoneUser>.
|
|
||||||
* Returns an empty Map if the directory doesn't exist.
|
|
||||||
*/
|
|
||||||
function readReverseMappings(authDir: string): Map<string, string> {
|
|
||||||
const out = new Map<string, string>();
|
|
||||||
if (!fs.existsSync(authDir)) return out;
|
|
||||||
for (const entry of fs.readdirSync(authDir)) {
|
|
||||||
const m = REVERSE_FILE_RE.exec(entry);
|
|
||||||
if (!m) continue;
|
|
||||||
const lidUser = m[1];
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(path.join(authDir, entry), 'utf-8').trim();
|
|
||||||
// The file content is a JSON-encoded string: `"<phone>"`
|
|
||||||
const phoneUser = JSON.parse(raw);
|
|
||||||
if (typeof phoneUser !== 'string' || phoneUser.length === 0) continue;
|
|
||||||
out.set(lidUser, phoneUser);
|
|
||||||
} catch {
|
|
||||||
// Skip malformed entries — best-effort.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function phoneUserOf(jid: string): string {
|
|
||||||
return jid.split('@')[0].split(':')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function main(): void {
|
|
||||||
const authDir = path.join(process.cwd(), 'store', 'auth');
|
|
||||||
const reverse = readReverseMappings(authDir);
|
|
||||||
|
|
||||||
if (reverse.size === 0) {
|
|
||||||
console.log('SKIPPED:no lid-mapping-*_reverse.json files in store/auth');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// phoneUser → lidJid (the form we'll write to messaging_groups)
|
|
||||||
const phoneUserToLidJid = new Map<string, string>();
|
|
||||||
for (const [lidUser, phoneUser] of reverse) {
|
|
||||||
phoneUserToLidJid.set(phoneUser, `${lidUser}@lid`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const v2DbPath = path.join(DATA_DIR, 'v2.db');
|
|
||||||
if (!fs.existsSync(v2DbPath)) {
|
|
||||||
console.error('FAIL:v2.db not found — run db step first');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const v2Db = initDb(v2DbPath);
|
|
||||||
runMigrations(v2Db);
|
|
||||||
|
|
||||||
const phoneRows = v2Db
|
|
||||||
.prepare(
|
|
||||||
`SELECT id, channel_type, platform_id FROM messaging_groups
|
|
||||||
WHERE channel_type='whatsapp' AND platform_id LIKE '%@s.whatsapp.net'`,
|
|
||||||
)
|
|
||||||
.all() as RawMessagingGroup[];
|
|
||||||
|
|
||||||
if (phoneRows.length === 0) {
|
|
||||||
console.log('SKIPPED:no whatsapp DM messaging_groups to resolve');
|
|
||||||
v2Db.close();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull existing wirings so each new alias gets the same agent_group +
|
|
||||||
// engage rules as the phone-keyed row.
|
|
||||||
const placeholders = phoneRows.map(() => '?').join(',');
|
|
||||||
const wiringRows = v2Db
|
|
||||||
.prepare(`SELECT * FROM messaging_group_agents WHERE messaging_group_id IN (${placeholders})`)
|
|
||||||
.all(...phoneRows.map((r) => r.id)) as RawWiring[];
|
|
||||||
|
|
||||||
const wiringsByMg = new Map<string, RawWiring[]>();
|
|
||||||
for (const w of wiringRows) {
|
|
||||||
const arr = wiringsByMg.get(w.messaging_group_id) ?? [];
|
|
||||||
arr.push(w);
|
|
||||||
wiringsByMg.set(w.messaging_group_id, arr);
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolved = 0;
|
|
||||||
let aliased = 0;
|
|
||||||
const createdAt = new Date().toISOString();
|
|
||||||
|
|
||||||
for (const row of phoneRows) {
|
|
||||||
const phoneUser = phoneUserOf(row.platform_id);
|
|
||||||
const lidJid = phoneUserToLidJid.get(phoneUser);
|
|
||||||
if (!lidJid) continue;
|
|
||||||
resolved++;
|
|
||||||
|
|
||||||
let lidMg = getMessagingGroupByPlatform('whatsapp', lidJid);
|
|
||||||
if (!lidMg) {
|
|
||||||
createMessagingGroup({
|
|
||||||
id: generateId('mg'),
|
|
||||||
channel_type: 'whatsapp',
|
|
||||||
platform_id: lidJid,
|
|
||||||
name: null,
|
|
||||||
is_group: 0,
|
|
||||||
unknown_sender_policy: 'public',
|
|
||||||
created_at: createdAt,
|
|
||||||
});
|
|
||||||
lidMg = getMessagingGroupByPlatform('whatsapp', lidJid)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wirings = wiringsByMg.get(row.id) ?? [];
|
|
||||||
for (const w of wirings) {
|
|
||||||
if (getMessagingGroupAgentByPair(lidMg.id, w.agent_group_id)) continue;
|
|
||||||
createMessagingGroupAgent({
|
|
||||||
id: generateId('mga'),
|
|
||||||
messaging_group_id: lidMg.id,
|
|
||||||
agent_group_id: w.agent_group_id,
|
|
||||||
engage_mode: w.engage_mode as 'pattern' | 'mention' | 'mention-sticky',
|
|
||||||
engage_pattern: w.engage_pattern,
|
|
||||||
sender_scope: w.sender_scope as 'all' | 'admins',
|
|
||||||
ignored_message_policy: w.ignored_message_policy as 'drop' | 'queue',
|
|
||||||
session_mode: w.session_mode as 'shared' | 'thread',
|
|
||||||
priority: w.priority,
|
|
||||||
created_at: createdAt,
|
|
||||||
});
|
|
||||||
aliased++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
v2Db.close();
|
|
||||||
console.log(
|
|
||||||
`OK:reverse_mappings=${reverse.size},phone_dms=${phoneRows.length},lids_resolved=${resolved},aliased=${aliased}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { Adapter } from 'chat';
|
import type { Adapter, AdapterPostableMessage, RawMessage } from 'chat';
|
||||||
|
|
||||||
import { createChatSdkBridge, splitForLimit } from './chat-sdk-bridge.js';
|
import { createChatSdkBridge, splitForLimit } from './chat-sdk-bridge.js';
|
||||||
|
|
||||||
@@ -8,6 +8,20 @@ function stubAdapter(partial: Partial<Adapter>): Adapter {
|
|||||||
return { name: 'stub', ...partial } as unknown as Adapter;
|
return { name: 'stub', ...partial } as unknown as Adapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PostCall {
|
||||||
|
threadId: string;
|
||||||
|
message: AdapterPostableMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePostCapture() {
|
||||||
|
const calls: PostCall[] = [];
|
||||||
|
const postMessage = async (threadId: string, message: AdapterPostableMessage): Promise<RawMessage<unknown>> => {
|
||||||
|
calls.push({ threadId, message });
|
||||||
|
return { id: 'msg-stub', threadId, raw: {} };
|
||||||
|
};
|
||||||
|
return { calls, postMessage };
|
||||||
|
}
|
||||||
|
|
||||||
describe('splitForLimit', () => {
|
describe('splitForLimit', () => {
|
||||||
it('returns a single chunk when text fits', () => {
|
it('returns a single chunk when text fits', () => {
|
||||||
expect(splitForLimit('short text', 100)).toEqual(['short text']);
|
expect(splitForLimit('short text', 100)).toEqual(['short text']);
|
||||||
@@ -78,3 +92,116 @@ describe('createChatSdkBridge', () => {
|
|||||||
expect(typeof bridge.subscribe).toBe('function');
|
expect(typeof bridge.subscribe).toBe('function');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createChatSdkBridge.deliver — display cards (send_card)', () => {
|
||||||
|
// The send_card MCP tool writes outbound rows with `{ type: 'card', card, fallbackText }`.
|
||||||
|
// Before this branch existed the bridge silently dropped them: cards have no
|
||||||
|
// `text` / `markdown`, so the trailing fallback `if (text)` was false and the
|
||||||
|
// function returned without calling the adapter. These tests pin the contract
|
||||||
|
// for the dedicated card branch.
|
||||||
|
|
||||||
|
it('renders title, description, and string children, then posts via the adapter', async () => {
|
||||||
|
const { calls, postMessage } = makePostCapture();
|
||||||
|
const bridge = createChatSdkBridge({
|
||||||
|
adapter: stubAdapter({ postMessage }),
|
||||||
|
supportsThreads: false,
|
||||||
|
});
|
||||||
|
const id = await bridge.deliver('telegram:42', null, {
|
||||||
|
kind: 'chat-sdk',
|
||||||
|
content: {
|
||||||
|
type: 'card',
|
||||||
|
card: {
|
||||||
|
title: 'Daily',
|
||||||
|
description: 'Your plate today',
|
||||||
|
children: ['• item one', '• item two'],
|
||||||
|
},
|
||||||
|
fallbackText: 'Daily: your plate',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(id).toBe('msg-stub');
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const msg = calls[0].message as { card?: unknown; fallbackText?: string };
|
||||||
|
expect(msg.fallbackText).toBe('Daily: your plate');
|
||||||
|
expect(msg.card).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops actions without url (send_card is fire-and-forget; non-URL buttons would have nowhere to land)', async () => {
|
||||||
|
const { calls, postMessage } = makePostCapture();
|
||||||
|
const bridge = createChatSdkBridge({
|
||||||
|
adapter: stubAdapter({ postMessage }),
|
||||||
|
supportsThreads: false,
|
||||||
|
});
|
||||||
|
await bridge.deliver('discord:guild:chan', null, {
|
||||||
|
kind: 'chat-sdk',
|
||||||
|
content: {
|
||||||
|
type: 'card',
|
||||||
|
card: {
|
||||||
|
title: 'Card',
|
||||||
|
description: 'has only label-only actions',
|
||||||
|
actions: [{ label: 'Add' }, { label: 'Skip' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
// Cast through the public Card shape to read the children we set
|
||||||
|
const msg = calls[0].message as { card?: { children?: Array<{ type?: string }> } };
|
||||||
|
const childTypes = (msg.card?.children ?? []).map((c) => c.type);
|
||||||
|
expect(childTypes).not.toContain('actions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders url actions as link buttons inside an Actions row', async () => {
|
||||||
|
const { calls, postMessage } = makePostCapture();
|
||||||
|
const bridge = createChatSdkBridge({
|
||||||
|
adapter: stubAdapter({ postMessage }),
|
||||||
|
supportsThreads: false,
|
||||||
|
});
|
||||||
|
await bridge.deliver('discord:guild:chan', null, {
|
||||||
|
kind: 'chat-sdk',
|
||||||
|
content: {
|
||||||
|
type: 'card',
|
||||||
|
card: {
|
||||||
|
title: 'Docs',
|
||||||
|
actions: [{ label: 'Open', url: 'https://example.com' }, { label: 'No-link' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const msg = calls[0].message as {
|
||||||
|
card?: { children?: Array<{ type?: string; children?: Array<{ type?: string; url?: string }> }> };
|
||||||
|
};
|
||||||
|
const actionsRow = msg.card?.children?.find((c) => c.type === 'actions');
|
||||||
|
expect(actionsRow).toBeDefined();
|
||||||
|
const buttons = actionsRow?.children ?? [];
|
||||||
|
expect(buttons).toHaveLength(1);
|
||||||
|
expect(buttons[0].type).toBe('link-button');
|
||||||
|
expect(buttons[0].url).toBe('https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips delivery when the card has neither title nor body content', async () => {
|
||||||
|
const { calls, postMessage } = makePostCapture();
|
||||||
|
const bridge = createChatSdkBridge({
|
||||||
|
adapter: stubAdapter({ postMessage }),
|
||||||
|
supportsThreads: false,
|
||||||
|
});
|
||||||
|
const id = await bridge.deliver('telegram:42', null, {
|
||||||
|
kind: 'chat-sdk',
|
||||||
|
content: { type: 'card', card: {} },
|
||||||
|
});
|
||||||
|
expect(id).toBeUndefined();
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to the text branch for non-card chat-sdk payloads (no regression)', async () => {
|
||||||
|
const { calls, postMessage } = makePostCapture();
|
||||||
|
const bridge = createChatSdkBridge({
|
||||||
|
adapter: stubAdapter({ postMessage }),
|
||||||
|
supportsThreads: false,
|
||||||
|
});
|
||||||
|
await bridge.deliver('telegram:42', null, {
|
||||||
|
kind: 'chat-sdk',
|
||||||
|
content: { text: 'plain hello' },
|
||||||
|
});
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const msg = calls[0].message as { markdown?: string };
|
||||||
|
expect(msg.markdown).toBe('plain hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
CardText,
|
CardText,
|
||||||
Actions,
|
Actions,
|
||||||
Button,
|
Button,
|
||||||
|
LinkButton,
|
||||||
|
type CardChild,
|
||||||
type Adapter,
|
type Adapter,
|
||||||
type ConcurrencyStrategy,
|
type ConcurrencyStrategy,
|
||||||
type Message as ChatMessage,
|
type Message as ChatMessage,
|
||||||
@@ -399,6 +401,59 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
return result?.id;
|
return result?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display card (send_card MCP tool) — returns immediately, no callback flow.
|
||||||
|
// Non-URL actions are dropped: send_card's contract is fire-and-forget, so a
|
||||||
|
// callback button would have nowhere to land. URL actions render as link buttons.
|
||||||
|
if (content.type === 'card' && content.card && typeof content.card === 'object') {
|
||||||
|
const cardSpec = content.card as Record<string, unknown>;
|
||||||
|
const title = (cardSpec.title as string) || '';
|
||||||
|
const fallbackText = (content.fallbackText as string) || (cardSpec.description as string) || title || '';
|
||||||
|
|
||||||
|
const cardChildren: CardChild[] = [];
|
||||||
|
if (typeof cardSpec.description === 'string' && cardSpec.description) {
|
||||||
|
cardChildren.push(CardText(cardSpec.description));
|
||||||
|
}
|
||||||
|
if (Array.isArray(cardSpec.children)) {
|
||||||
|
for (const child of cardSpec.children) {
|
||||||
|
if (typeof child === 'string' && child) {
|
||||||
|
cardChildren.push(CardText(child));
|
||||||
|
} else if (
|
||||||
|
child &&
|
||||||
|
typeof child === 'object' &&
|
||||||
|
typeof (child as Record<string, unknown>).text === 'string'
|
||||||
|
) {
|
||||||
|
cardChildren.push(CardText((child as Record<string, string>).text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(cardSpec.actions)) {
|
||||||
|
const linkButtons = (cardSpec.actions as Array<Record<string, unknown>>)
|
||||||
|
.filter((a) => typeof a.url === 'string' && a.url && typeof a.label === 'string' && a.label)
|
||||||
|
.map((a) => {
|
||||||
|
const style = a.style;
|
||||||
|
const safeStyle: 'primary' | 'danger' | 'default' | undefined =
|
||||||
|
style === 'primary' || style === 'danger' || style === 'default' ? style : undefined;
|
||||||
|
return LinkButton({
|
||||||
|
label: a.label as string,
|
||||||
|
url: a.url as string,
|
||||||
|
style: safeStyle,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (linkButtons.length > 0) {
|
||||||
|
cardChildren.push(Actions(linkButtons));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardChildren.length === 0 && !title) {
|
||||||
|
log.warn('send_card payload empty, skipping delivery');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = Card({ title, children: cardChildren });
|
||||||
|
const result = await adapter.postMessage(tid, { card, fallbackText });
|
||||||
|
return result?.id;
|
||||||
|
}
|
||||||
|
|
||||||
// Normal message
|
// Normal message
|
||||||
const rawText = (content.markdown as string) || (content.text as string);
|
const rawText = (content.markdown as string) || (content.text as string);
|
||||||
const text = rawText ? transformText(rawText) : rawText;
|
const text = rawText ? transformText(rawText) : rawText;
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export function _resetStuckProcessingRowsForTesting(
|
|||||||
session: Session,
|
session: Session,
|
||||||
reason: string,
|
reason: string,
|
||||||
): void {
|
): void {
|
||||||
resetStuckProcessingRows(inDb, outDb, session, reason);
|
resetStuckProcessingRows(inDb, outDb, session, reason, outDb);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetStuckProcessingRows(
|
function resetStuckProcessingRows(
|
||||||
@@ -264,6 +264,7 @@ function resetStuckProcessingRows(
|
|||||||
outDb: Database.Database,
|
outDb: Database.Database,
|
||||||
session: Session,
|
session: Session,
|
||||||
reason: string,
|
reason: string,
|
||||||
|
writableOutDb?: Database.Database,
|
||||||
): void {
|
): void {
|
||||||
const claims = getProcessingClaims(outDb);
|
const claims = getProcessingClaims(outDb);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -300,19 +301,17 @@ function resetStuckProcessingRows(
|
|||||||
// would re-read them, see the old status_changed timestamp, conclude the
|
// would re-read them, see the old status_changed timestamp, conclude the
|
||||||
// freshly respawned container is stuck, and SIGKILL it before its
|
// freshly respawned container is stuck, and SIGKILL it before its
|
||||||
// agent-runner has a chance to run clearStaleProcessingAcks() on startup.
|
// agent-runner has a chance to run clearStaleProcessingAcks() on startup.
|
||||||
// We're safe to write outbound.db here because we just killed the container
|
const ownsDb = !writableOutDb;
|
||||||
// that owned it (or it crashed and left no writer behind).
|
let useDb: Database.Database | null = writableOutDb ?? null;
|
||||||
// outDb was opened readonly for reads above; reopen with write access for this delete.
|
|
||||||
let outDbRw: Database.Database | null = null;
|
|
||||||
try {
|
try {
|
||||||
outDbRw = openOutboundDbRw(session.agent_group_id, session.id);
|
if (!useDb) useDb = openOutboundDbRw(session.agent_group_id, session.id);
|
||||||
const cleared = deleteOrphanProcessingClaims(outDbRw);
|
const cleared = deleteOrphanProcessingClaims(useDb);
|
||||||
if (cleared > 0) {
|
if (cleared > 0) {
|
||||||
log.info('Cleared orphan processing claims', { sessionId: session.id, cleared, reason });
|
log.info('Cleared orphan processing claims', { sessionId: session.id, cleared, reason });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn('Failed to clear orphan processing claims', { sessionId: session.id, err });
|
log.warn('Failed to clear orphan processing claims', { sessionId: session.id, err });
|
||||||
} finally {
|
} finally {
|
||||||
outDbRw?.close();
|
if (ownsDb) useDb?.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,17 @@
|
|||||||
* will later emit as event.platformId, or router lookups miss and messages
|
* will later emit as event.platformId, or router lookups miss and messages
|
||||||
* get silently dropped.
|
* get silently dropped.
|
||||||
*
|
*
|
||||||
* Native adapters (Signal, WhatsApp, iMessage) use their own ID formats and
|
* Native adapters (Signal, WhatsApp, iMessage, DeltaChat) use their own ID
|
||||||
* send them as-is — no channel prefix. WhatsApp/iMessage emit JIDs/emails
|
* formats and send them as-is — no channel prefix. WhatsApp/iMessage emit
|
||||||
* containing '@'. Signal emits raw phone numbers ('+15551234567') for DMs
|
* JIDs/emails containing '@'. Signal emits raw phone numbers ('+15551234567')
|
||||||
* and 'group:<id>' for group chats. Prefixing any of these would cause a
|
* for DMs and 'group:<id>' for group chats. DeltaChat emits numeric chat IDs
|
||||||
* mismatch with what the adapter later emits.
|
* ('12'). Prefixing any of these would cause a mismatch with what the adapter
|
||||||
|
* later emits.
|
||||||
*/
|
*/
|
||||||
export function namespacedPlatformId(channel: string, raw: string): string {
|
export function namespacedPlatformId(channel: string, raw: string): string {
|
||||||
if (raw.startsWith(`${channel}:`)) return raw;
|
if (raw.startsWith(`${channel}:`)) return raw;
|
||||||
if (raw.includes('@')) return raw;
|
if (raw.includes('@')) return raw;
|
||||||
if (raw.startsWith('+') || raw.startsWith('group:')) return raw;
|
if (raw.startsWith('+') || raw.startsWith('group:')) return raw;
|
||||||
|
if (channel === 'deltachat') return raw;
|
||||||
return `${channel}:${raw}`;
|
return `${channel}:${raw}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user