Merge branch 'main' into skill/signal
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="128k tokens, 64% of context window">
|
||||
<title>128k tokens, 64% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="129k tokens, 64% of context window">
|
||||
<title>129k tokens, 64% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">128k</text>
|
||||
<text x="71" y="14">128k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">129k</text>
|
||||
<text x="71" y="14">129k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
@@ -17,58 +19,63 @@ describe('environment detection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('registered groups DB query', () => {
|
||||
let db: Database.Database;
|
||||
describe('detectRegisteredGroups', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
container_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1
|
||||
)`);
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-'));
|
||||
fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
|
||||
});
|
||||
|
||||
it('returns 0 for empty table', () => {
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(0);
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns correct count after inserts', () => {
|
||||
db.prepare(
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Group 1',
|
||||
'group-1',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
it('returns false when no registration state exists', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'456@g.us',
|
||||
'Group 2',
|
||||
'group-2',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
it('detects pre-migration registered_groups.json', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
fs.writeFileSync(path.join(tempDir, 'data', 'registered_groups.json'), '[]');
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(true);
|
||||
});
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(2);
|
||||
it('returns false for an empty v2 central DB', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL,
|
||||
agent_group_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.close();
|
||||
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects wired agent groups in the v2 central DB', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL,
|
||||
agent_group_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-1');
|
||||
db.prepare(
|
||||
'INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id) VALUES (?, ?, ?)',
|
||||
).run('mga-1', 'mg-1', 'ag-1');
|
||||
db.close();
|
||||
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,11 +7,35 @@ import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
export function detectRegisteredGroups(projectRoot: string): boolean {
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dbPath = path.join(projectRoot, 'data', 'v2.db');
|
||||
if (!fs.existsSync(dbPath)) return false;
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COUNT(DISTINCT ag.id) as count FROM agent_groups ag
|
||||
JOIN messaging_group_agents mga ON mga.agent_group_id = ag.id`,
|
||||
)
|
||||
.get() as { count: number };
|
||||
return row.count > 0;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
@@ -39,26 +63,7 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const authDir = path.join(projectRoot, 'store', 'auth');
|
||||
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
||||
|
||||
let hasRegisteredGroups = false;
|
||||
// Check JSON file first (pre-migration)
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||
hasRegisteredGroups = true;
|
||||
} else {
|
||||
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed)
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
if (row.count > 0) hasRegisteredGroups = true;
|
||||
db.close();
|
||||
} catch {
|
||||
// Table might not exist yet
|
||||
}
|
||||
}
|
||||
}
|
||||
const hasRegisteredGroups = detectRegisteredGroups(projectRoot);
|
||||
|
||||
// Check for existing OpenClaw installation
|
||||
const homedir = (await import('os')).homedir();
|
||||
|
||||
@@ -81,6 +81,26 @@ export interface ChatSdkBridgeConfig {
|
||||
* chunk boundary will render as two independent blocks on the receiving
|
||||
* platform, which is the same behavior as manually re-opening a fence.
|
||||
*/
|
||||
/**
|
||||
* Decode the actual option value from a button callback. Buttons are encoded
|
||||
* with an integer index (to keep under Telegram's 64-byte callback_data cap),
|
||||
* and the real value is looked up via `getAskQuestionRender(questionId)`.
|
||||
* Falls back to treating the tail as a literal value so old in-flight cards
|
||||
* (encoded before this shortening landed) still resolve.
|
||||
*/
|
||||
function resolveSelectedOption(
|
||||
render: { options: NormalizedOption[] } | undefined,
|
||||
eventValue: string | undefined,
|
||||
tail: string | undefined,
|
||||
): string {
|
||||
const candidate = eventValue ?? tail ?? '';
|
||||
if (render && /^\d+$/.test(candidate)) {
|
||||
const idx = Number(candidate);
|
||||
if (render.options[idx]) return render.options[idx].value;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function splitForLimit(text: string, limit: number): string[] {
|
||||
if (text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
@@ -240,11 +260,15 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
const parts = event.actionId.split(':');
|
||||
if (parts.length < 3) return;
|
||||
const questionId = parts[1];
|
||||
const selectedOption = event.value || '';
|
||||
const tail = parts.slice(2).join(':');
|
||||
const userId = event.user?.userId || '';
|
||||
|
||||
// Resolve render metadata BEFORE dispatching onAction (which deletes the row).
|
||||
const render = getAskQuestionRender(questionId);
|
||||
// New format: button id/value is an integer index into options (kept
|
||||
// short to fit Telegram's 64-byte callback_data cap). Old format:
|
||||
// the full value is embedded in actionId/value directly.
|
||||
const selectedOption = resolveSelectedOption(render, event.value, tail);
|
||||
const title = render?.title ?? '❓ Question';
|
||||
const matched = render?.options.find((o) => o.value === selectedOption);
|
||||
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
|
||||
@@ -348,8 +372,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
children: [
|
||||
CardText(question),
|
||||
Actions(
|
||||
options.map((opt) =>
|
||||
Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }),
|
||||
// Encode button id/value with the option index rather than the
|
||||
// full value. Telegram caps callback_data at 64 bytes, and
|
||||
// long values (e.g. ISO datetimes, URLs) push the JSON payload
|
||||
// well past that. The onAction handlers resolve the index back
|
||||
// to the real value via getAskQuestionRender(questionId).
|
||||
options.map((opt, idx) =>
|
||||
Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -507,12 +536,12 @@ async function handleForwardedEvent(
|
||||
|
||||
// Parse the selected option from custom_id
|
||||
let questionId: string | undefined;
|
||||
let selectedOption: string | undefined;
|
||||
let tail: string | undefined;
|
||||
if (customId?.startsWith('ncq:')) {
|
||||
const colonIdx = customId.indexOf(':', 4); // after "ncq:"
|
||||
if (colonIdx !== -1) {
|
||||
questionId = customId.slice(4, colonIdx);
|
||||
selectedOption = customId.slice(colonIdx + 1);
|
||||
tail = customId.slice(colonIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,6 +550,9 @@ async function handleForwardedEvent(
|
||||
((interaction.message as Record<string, unknown>)?.embeds as Array<Record<string, unknown>>) || [];
|
||||
const originalDescription = (originalEmbeds[0]?.description as string) || '';
|
||||
const render = questionId ? getAskQuestionRender(questionId) : undefined;
|
||||
// Discord custom_id mirrors the new index-based encoding (see Button
|
||||
// construction). Decode back to the real option value for downstream.
|
||||
const selectedOption = resolveSelectedOption(render, tail, tail);
|
||||
const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question');
|
||||
const matchedOpt = render?.options.find((o) => o.value === selectedOption);
|
||||
const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId;
|
||||
|
||||
Reference in New Issue
Block a user