Merge branch 'main' into fix/send-card-bridge
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "2.0.30",
|
"version": "2.0.31",
|
||||||
"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, 70% of context window">
|
||||||
<title>140k tokens, 70% of context window</title>
|
<title>141k tokens, 70% 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 |
@@ -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();
|
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user