Merge branch 'qwibitai:main' into main
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -11,7 +11,16 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user
|
|||||||
|
|
||||||
## Assess Current State
|
## Assess Current State
|
||||||
|
|
||||||
Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
|
Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
|
||||||
|
SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups;
|
||||||
|
SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents;
|
||||||
|
SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
|
||||||
|
|
||||||
Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**.
|
Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**.
|
||||||
|
|
||||||
|
|||||||
@@ -215,3 +215,5 @@ See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release hist
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
|
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ ONECLI_OK=false
|
|||||||
ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//')
|
ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//')
|
||||||
ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}"
|
ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}"
|
||||||
|
|
||||||
if curl -sf "${ONECLI_URL_CHECK}/health" >/dev/null 2>&1; then
|
if curl -sf "${ONECLI_URL_CHECK}/api/health" >/dev/null 2>&1; then
|
||||||
step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")"
|
step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")"
|
||||||
ONECLI_OK=true
|
ONECLI_OK=true
|
||||||
log "OneCLI: running at $ONECLI_URL_CHECK"
|
log "OneCLI: running at $ONECLI_URL_CHECK"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "2.0.32",
|
"version": "2.0.33",
|
||||||
"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",
|
||||||
|
|||||||
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,7 +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 { confirmThenOpen, formatNoteLink } from '../lib/browser.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,
|
||||||
@@ -38,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
|
||||||
@@ -48,14 +53,37 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
// installed, or the bot's web profile if not. tg://resolve?domain= is
|
// installed, or the bot's web profile if not. tg://resolve?domain= is
|
||||||
// more direct but silently fails when the scheme isn't registered.
|
// more direct but silently fails when the scheme isn't registered.
|
||||||
const botUrl = `https://t.me/${botUsername}`;
|
const botUrl = `https://t.me/${botUsername}`;
|
||||||
note(
|
// Two card variants — auto-open fires only on GUI, so headless users
|
||||||
[
|
// need full self-serve instructions inside the card itself, while GUI
|
||||||
|
// users get a leaner status line plus the auto-open + a single
|
||||||
|
// combined dim fallback line (URL + mobile alternative) on the
|
||||||
|
// confirm prompt below.
|
||||||
|
if (isHeadless()) {
|
||||||
|
note(
|
||||||
|
[
|
||||||
|
`Open @${botUsername} in Telegram now — the pairing code is coming next, and that's where you'll send it.`,
|
||||||
|
'',
|
||||||
|
`Get started: ${botUrl}`,
|
||||||
|
'',
|
||||||
|
`Don't have Telegram installed here? Open it on any device and search for @${botUsername}`,
|
||||||
|
].join('\n'),
|
||||||
|
'Open Telegram',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
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),
|
'Open Telegram',
|
||||||
].filter((line): line is string => line !== null).join('\n'),
|
);
|
||||||
'Open Telegram',
|
ensureAnswer(
|
||||||
);
|
await p.confirm({
|
||||||
await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
|
message: `Press Enter to open Telegram (must be installed here)\n${k.dim(
|
||||||
|
`If browser does not appear, please visit: ${botUrl} — or search for @${botUsername} in Telegram`,
|
||||||
|
)}`,
|
||||||
|
initialValue: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
openUrl(botUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const install = await runQuietChild(
|
const install = await runQuietChild(
|
||||||
'telegram-install',
|
'telegram-install',
|
||||||
@@ -131,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(
|
||||||
@@ -159,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');
|
||||||
|
|||||||
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;
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
CLAIM_STUCK_MS,
|
CLAIM_STUCK_MS,
|
||||||
_resetStuckProcessingRowsForTesting,
|
_resetStuckProcessingRowsForTesting,
|
||||||
decideStuckAction,
|
decideStuckAction,
|
||||||
|
parseSqliteUtc,
|
||||||
} from './host-sweep.js';
|
} from './host-sweep.js';
|
||||||
import type { Session } from './types.js';
|
import type { Session } from './types.js';
|
||||||
|
|
||||||
@@ -292,3 +293,44 @@ describe('resetStuckProcessingRows — orphan claim cleanup', () => {
|
|||||||
expect(row.tries).toBe(1); // not bumped, the skip path held
|
expect(row.tries).toBe(1); // not bumped, the skip path held
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseSqliteUtc', () => {
|
||||||
|
// Regression: SQLite TIMESTAMP strings have no zone marker, but Date.parse
|
||||||
|
// treats those as local time. On non-UTC hosts this made every claim look
|
||||||
|
// (TZ offset) hours stale and tripped kill-claim on freshly-claimed messages.
|
||||||
|
// The helper appends "Z" only when no marker is present, so parsing is
|
||||||
|
// always anchored to UTC regardless of host timezone.
|
||||||
|
|
||||||
|
const utcMs = Date.parse('2026-04-20T12:00:00.000Z');
|
||||||
|
|
||||||
|
it('treats a SQLite-style timestamp (no zone) as UTC', () => {
|
||||||
|
expect(parseSqliteUtc('2026-04-20 12:00:00')).toBe(utcMs);
|
||||||
|
expect(parseSqliteUtc('2026-04-20T12:00:00')).toBe(utcMs);
|
||||||
|
expect(parseSqliteUtc('2026-04-20T12:00:00.000')).toBe(utcMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an explicit Z marker', () => {
|
||||||
|
expect(parseSqliteUtc('2026-04-20T12:00:00.000Z')).toBe(utcMs);
|
||||||
|
expect(parseSqliteUtc('2026-04-20T12:00:00z')).toBe(utcMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an explicit numeric offset', () => {
|
||||||
|
// 14:00+02:00 == 12:00 UTC
|
||||||
|
expect(parseSqliteUtc('2026-04-20T14:00:00+02:00')).toBe(utcMs);
|
||||||
|
expect(parseSqliteUtc('2026-04-20T14:00:00+0200')).toBe(utcMs);
|
||||||
|
// 07:00-05:00 == 12:00 UTC
|
||||||
|
expect(parseSqliteUtc('2026-04-20T07:00:00-05:00')).toBe(utcMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns NaN for unparseable input', () => {
|
||||||
|
expect(Number.isNaN(parseSqliteUtc('not a date'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not drift across host timezones for SQLite-style input', () => {
|
||||||
|
// The helper itself is timezone-independent because it forces UTC parsing.
|
||||||
|
// (Verifying the regex branch — without the helper, `Date.parse` of the
|
||||||
|
// bare string returns different values depending on the host TZ.)
|
||||||
|
const bare = '2026-04-20T12:00:00';
|
||||||
|
expect(parseSqliteUtc(bare)).toBe(Date.parse(bare + 'Z'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbe
|
|||||||
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
|
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
|
||||||
import type { Session } from './types.js';
|
import type { Session } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite TIMESTAMP columns store UTC without a timezone marker. Date.parse
|
||||||
|
* treats timezoneless ISO strings as local time, so on non-UTC hosts every
|
||||||
|
* timestamp looks (TZ offset) hours stale — leading to spurious kill-claim
|
||||||
|
* decisions on freshly-claimed messages. Append "Z" when no zone marker is
|
||||||
|
* present so Date.parse interprets the string as UTC.
|
||||||
|
*/
|
||||||
|
export function parseSqliteUtc(s: string): number {
|
||||||
|
return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? s : s + 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
const SWEEP_INTERVAL_MS = 60_000;
|
const SWEEP_INTERVAL_MS = 60_000;
|
||||||
// Absolute idle ceiling for a running container. If the heartbeat file hasn't
|
// Absolute idle ceiling for a running container. If the heartbeat file hasn't
|
||||||
// been touched in this long, the container is either stuck or doing genuinely
|
// been touched in this long, the container is either stuck or doing genuinely
|
||||||
@@ -95,7 +106,7 @@ export function decideStuckAction(args: {
|
|||||||
|
|
||||||
const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0);
|
const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0);
|
||||||
for (const claim of claims) {
|
for (const claim of claims) {
|
||||||
const claimedAt = Date.parse(claim.status_changed);
|
const claimedAt = parseSqliteUtc(claim.status_changed);
|
||||||
if (Number.isNaN(claimedAt)) continue;
|
if (Number.isNaN(claimedAt)) continue;
|
||||||
const claimAge = now - claimedAt;
|
const claimAge = now - claimedAt;
|
||||||
if (claimAge <= tolerance) continue;
|
if (claimAge <= tolerance) continue;
|
||||||
@@ -275,7 +286,7 @@ function resetStuckProcessingRows(
|
|||||||
// Already rescheduled for a future retry — don't bump tries again. The
|
// Already rescheduled for a future retry — don't bump tries again. The
|
||||||
// wake path (sweep step 2) will fire when process_after elapses and a
|
// wake path (sweep step 2) will fire when process_after elapses and a
|
||||||
// fresh container will clean the orphan claim on startup.
|
// fresh container will clean the orphan claim on startup.
|
||||||
if (msg.processAfter && Date.parse(msg.processAfter) > now) continue;
|
if (msg.processAfter && parseSqliteUtc(msg.processAfter) > now) continue;
|
||||||
|
|
||||||
if (msg.tries >= MAX_TRIES) {
|
if (msg.tries >= MAX_TRIES) {
|
||||||
markMessageFailed(inDb, msg.id);
|
markMessageFailed(inDb, msg.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user