Merge branch 'qwibitai:main' into main

This commit is contained in:
glifocat
2026-05-05 17:29:57 +02:00
committed by GitHub
18 changed files with 267 additions and 279 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ import './discord.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/discord@4.26.0
pnpm install @chat-adapter/discord@4.27.0
```
### 5. Build
+1 -1
View File
@@ -44,7 +44,7 @@ import './gchat.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/gchat@4.26.0
pnpm install @chat-adapter/gchat@4.27.0
```
### 5. Build
+1 -1
View File
@@ -48,7 +48,7 @@ import './github.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/github@4.26.0
pnpm install @chat-adapter/github@4.27.0
```
### 5. Build
+1 -1
View File
@@ -87,7 +87,7 @@ Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler n
### 5. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/linear@4.26.0
pnpm install @chat-adapter/linear@4.27.0
```
### 6. Build
+1 -1
View File
@@ -44,7 +44,7 @@ import './slack.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/slack@4.26.0
pnpm install @chat-adapter/slack@4.27.0
```
### 5. Build
+1 -1
View File
@@ -44,7 +44,7 @@ import './teams.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/teams@4.26.0
pnpm install @chat-adapter/teams@4.27.0
```
### 5. Build
+1 -1
View File
@@ -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)
```bash
pnpm install @chat-adapter/telegram@4.26.0
pnpm install @chat-adapter/telegram@4.27.0
```
### 6. Build
+1 -1
View File
@@ -44,7 +44,7 @@ import './whatsapp-cloud.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/whatsapp@4.26.0
pnpm install @chat-adapter/whatsapp@4.27.0
```
### 5. Build
+14 -4
View File
@@ -27,21 +27,29 @@ const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat';
let _inbound: Database | null = null;
let _outbound: Database | null = null;
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.
*
* Use this (not getInboundDb) for readers that need to see host-written rows
* promptly — e.g. messages_in polling. Caller must .close() the returned
* connection (try/finally).
*
* Needed for mounts where host writes don't reliably invalidate
* SQLite's caches: virtiofs (Colima, Lima, Podman Machine, Apple
* Container), NFS.
*
* Container), NFS.
*
* Cost is microseconds per query, so safe for universal use.
*/
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 });
db.exec('PRAGMA busy_timeout = 5000');
db.exec('PRAGMA mmap_size = 0');
@@ -170,6 +178,7 @@ export function clearStaleProcessingAcks(): void {
/** For tests — creates in-memory DBs with the session schemas. */
export function initTestSessionDb(): { inbound: Database; outbound: Database } {
_testMode = true;
_inbound = new Database(':memory:');
_inbound.exec('PRAGMA foreign_keys = ON');
_inbound.exec(`
@@ -246,6 +255,7 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
export function closeSessionDb(): void {
_inbound?.close();
_inbound = null;
_testMode = false;
_outbound?.close();
_outbound = null;
}
+6 -14
View File
@@ -408,20 +408,12 @@ else
fi
done
# 2d. WhatsApp LID resolution. After whatsapp is installed (so Baileys
# is on disk) and auth files have been copied (so we can connect with
# the migrated identity), boot Baileys briefly to learn LID↔phone
# mappings during initial sync, then write paired LID-keyed
# messaging_groups. Best-effort: any failure degrades to runtime
# approval flow, which the WA adapter's isMention=true on DMs handles.
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
# 2d. (Removed) WhatsApp LID resolution was previously needed because the
# v6 adapter couldn't reliably translate LID→phone JIDs, so the migration
# pre-created dual messaging_groups rows. With Baileys v7, the adapter
# resolves LIDs via extractAddressingContext + signalRepository.lidMapping
# on every inbound message, so dual rows are unnecessary and were causing
# split sessions.
fi
echo
+13 -26
View File
@@ -138,16 +138,13 @@ write_header
cat "$PROJECT_ROOT/assets/setup-splash.txt"
# ─── pre-flight: minimum hardware specs ────────────────────────────────
# NanoClaw runs an agent container per session. Below these thresholds the
# host + container + agent will struggle (OOM under load, image + session
# DBs filling the disk). Soft warn — `df` only sees the partition that
# $PROJECT_ROOT lives on, which can underreport on hosts with separate
# /home or /var mounts, so the user can override.
# 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 37003900 MB
# after kernel reserves (e.g. Hetzner CX21 ≈ 3814, AWS t3.medium ≈ 3800).
MIN_MEM_MB=3700
MIN_DISK_GB=20
detect_mem_mb() {
case "$(uname -s)" in
@@ -162,39 +159,29 @@ detect_mem_mb() {
esac
}
detect_disk_gb() {
# -P: POSIX format (no line-wrapping); -k: 1024-byte blocks. Avail is col 4.
df -Pk "$PROJECT_ROOT" 2>/dev/null \
| awk 'NR==2 { printf "%d", $4 / 1024 / 1024 }'
}
MEM_MB=$(detect_mem_mb)
DISK_GB=$(detect_disk_gb)
: "${MEM_MB:=0}"
: "${DISK_GB:=0}"
LOW_MEM=false; LOW_DISK=false
[ "$MEM_MB" -gt 0 ] && [ "$MEM_MB" -lt "$MIN_MEM_MB" ] && LOW_MEM=true
[ "$DISK_GB" -gt 0 ] && [ "$DISK_GB" -lt "$MIN_DISK_GB" ] && LOW_DISK=true
LOW_MEM=false
[ "$MEM_MB" -gt 0 ] && [ "$MEM_MB" -lt "$MIN_MEM_MB" ] && LOW_MEM=true
if [ "$LOW_MEM" = true ] || [ "$LOW_DISK" = true ]; then
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+ machine with 20 GB+ free disk. Below this,')"
printf ' %s\n' "$(dim 'the host + agent container will run out of memory or disk under most')"
printf ' %s\n' "$(dim 'workloads. A stronger machine is strongly recommended.')"
[ "$LOW_MEM" = true ] && printf ' %s\n' "$(dim " · Detected RAM: ${MEM_MB} MB")"
[ "$LOW_DISK" = true ] && printf ' %s\n' "$(dim " · Free disk on $PROJECT_ROOT: ${DISK_GB} GB")"
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" disk_gb="$DISK_GB" low_mem="$LOW_MEM" low_disk="$LOW_DISK"
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" disk_gb="$DISK_GB" low_mem="$LOW_MEM" low_disk="$LOW_DISK"
printf '\n %s\n\n' "$(dim 'Aborted. Re-run after upgrading the host or freeing disk space.')"
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.30",
"version": "2.0.32",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -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">
<title>140k tokens, 70% 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="141k tokens, 71% of context window">
<title>141k tokens, 71% 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">140k</text>
<text x="71" y="14">140k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">141k</text>
<text x="71" y="14">141k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+31 -21
View File
@@ -17,30 +17,40 @@ if command -v node >/dev/null 2>&1; then
exit 0
fi
case "$(uname -s)" in
Darwin)
echo "STEP: brew-install-node"
if ! command -v brew >/dev/null 2>&1; then
if command -v uvx >/dev/null 2>&1; then
echo "STEP: uvx-nodeenv"
uvx nodeenv -n lts ~/node
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 "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run."
echo "ERROR: Unsupported platform: $(uname -s)"
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 "ERROR: Unsupported platform: $(uname -s)"
echo "=== END ==="
exit 1
;;
esac
;;
esac
fi
if ! command -v node >/dev/null 2>&1; then
echo "STATUS: failed"
-192
View File
@@ -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();
+128 -1
View File
@@ -1,6 +1,6 @@
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';
@@ -8,6 +8,20 @@ function stubAdapter(partial: Partial<Adapter>): 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', () => {
it('returns a single chunk when text fits', () => {
expect(splitForLimit('short text', 100)).toEqual(['short text']);
@@ -78,3 +92,116 @@ describe('createChatSdkBridge', () => {
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');
});
});
+55
View File
@@ -12,6 +12,8 @@ import {
CardText,
Actions,
Button,
LinkButton,
type CardChild,
type Adapter,
type ConcurrencyStrategy,
type Message as ChatMessage,
@@ -399,6 +401,59 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
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
const rawText = (content.markdown as string) || (content.text as string);
const text = rawText ? transformText(rawText) : rawText;
+7 -8
View File
@@ -256,7 +256,7 @@ export function _resetStuckProcessingRowsForTesting(
session: Session,
reason: string,
): void {
resetStuckProcessingRows(inDb, outDb, session, reason);
resetStuckProcessingRows(inDb, outDb, session, reason, outDb);
}
function resetStuckProcessingRows(
@@ -264,6 +264,7 @@ function resetStuckProcessingRows(
outDb: Database.Database,
session: Session,
reason: string,
writableOutDb?: Database.Database,
): void {
const claims = getProcessingClaims(outDb);
const now = Date.now();
@@ -300,19 +301,17 @@ function resetStuckProcessingRows(
// would re-read them, see the old status_changed timestamp, conclude the
// freshly respawned container is stuck, and SIGKILL it before its
// agent-runner has a chance to run clearStaleProcessingAcks() on startup.
// We're safe to write outbound.db here because we just killed the container
// that owned it (or it crashed and left no writer behind).
// outDb was opened readonly for reads above; reopen with write access for this delete.
let outDbRw: Database.Database | null = null;
const ownsDb = !writableOutDb;
let useDb: Database.Database | null = writableOutDb ?? null;
try {
outDbRw = openOutboundDbRw(session.agent_group_id, session.id);
const cleared = deleteOrphanProcessingClaims(outDbRw);
if (!useDb) useDb = openOutboundDbRw(session.agent_group_id, session.id);
const cleared = deleteOrphanProcessingClaims(useDb);
if (cleared > 0) {
log.info('Cleared orphan processing claims', { sessionId: session.id, cleared, reason });
}
} catch (err) {
log.warn('Failed to clear orphan processing claims', { sessionId: session.id, err });
} finally {
outDbRw?.close();
if (ownsDb) useDb?.close();
}
}