v2 phase 4+5: Discord via Chat SDK, expanded MCP tools, message seq IDs

- Chat SDK bridge + Discord adapter (gateway listener, message routing)
- MCP tools refactored into modular structure: core (send_message, send_file,
  edit_message, add_reaction), scheduling (schedule/list/cancel/pause/resume
  tasks), interactive (ask_user_question, send_card), agents (send_to_agent)
- Message seq IDs: shared integer sequence across messages_in/out so agents
  see small numeric IDs instead of platform snowflakes
- busy_timeout=5000 for session DB (poll loop + MCP server concurrent access)
- Always copy agent-runner source to fix stale cache when non-index files change
- Seed script for Discord testing, e2e test script

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-09 02:53:39 +03:00
parent b36f127acc
commit afbc20a6c4
21 changed files with 2702 additions and 37 deletions

View File

@@ -8,6 +8,7 @@ export function getSessionDb(): Database.Database {
if (!_db) { if (!_db) {
_db = new Database(process.env.SESSION_DB_PATH || SESSION_DB_PATH); _db = new Database(process.env.SESSION_DB_PATH || SESSION_DB_PATH);
_db.pragma('journal_mode = DELETE'); _db.pragma('journal_mode = DELETE');
_db.pragma('busy_timeout = 5000');
_db.pragma('foreign_keys = ON'); _db.pragma('foreign_keys = ON');
} }
return _db; return _db;
@@ -20,6 +21,7 @@ export function initTestSessionDb(): Database.Database {
_db.exec(` _db.exec(`
CREATE TABLE messages_in ( CREATE TABLE messages_in (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
seq INTEGER UNIQUE,
kind TEXT NOT NULL, kind TEXT NOT NULL,
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', status TEXT DEFAULT 'pending',
@@ -34,6 +36,7 @@ export function initTestSessionDb(): Database.Database {
); );
CREATE TABLE messages_out ( CREATE TABLE messages_out (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
seq INTEGER UNIQUE,
in_reply_to TEXT, in_reply_to TEXT,
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
delivered INTEGER DEFAULT 0, delivered INTEGER DEFAULT 0,

View File

@@ -2,6 +2,7 @@ import { getSessionDb } from './connection.js';
export interface MessageInRow { export interface MessageInRow {
id: string; id: string;
seq: number | null;
kind: string; kind: string;
timestamp: string; timestamp: string;
status: string; status: string;

View File

@@ -2,6 +2,7 @@ import { getSessionDb } from './connection.js';
export interface MessageOutRow { export interface MessageOutRow {
id: string; id: string;
seq: number | null;
in_reply_to: string | null; in_reply_to: string | null;
timestamp: string; timestamp: string;
delivered: number; delivered: number;
@@ -26,22 +27,44 @@ export interface WriteMessageOut {
content: string; content: string;
} }
/** Write a new outbound message. */ /** Write a new outbound message, auto-assigning a seq number. */
export function writeMessageOut(msg: WriteMessageOut): void { export function writeMessageOut(msg: WriteMessageOut): number {
getSessionDb() const db = getSessionDb();
.prepare( const nextSeq = (
`INSERT INTO messages_out (id, in_reply_to, timestamp, delivered, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content) db
VALUES (@id, @in_reply_to, datetime('now'), 0, @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`, .prepare(
) `SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM (
.run({ SELECT seq FROM messages_in WHERE seq IS NOT NULL
in_reply_to: null, UNION ALL
deliver_after: null, SELECT seq FROM messages_out WHERE seq IS NOT NULL
recurrence: null, )`,
platform_id: null, )
channel_type: null, .get() as { next: number }
thread_id: null, ).next;
...msg,
}); db.prepare(
`INSERT INTO messages_out (id, seq, in_reply_to, timestamp, delivered, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
VALUES (@id, @seq, @in_reply_to, datetime('now'), 0, @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`,
).run({
in_reply_to: null,
deliver_after: null,
recurrence: null,
platform_id: null,
channel_type: null,
thread_id: null,
...msg,
seq: nextSeq,
});
return nextSeq;
}
/** Look up a message's platform ID by seq number. */
export function getMessageIdBySeq(seq: number): string | null {
const inRow = getSessionDb().prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as { id: string } | undefined;
if (inRow) return inRow.id;
const outRow = getSessionDb().prepare('SELECT id FROM messages_out WHERE seq = ?').get(seq) as { id: string } | undefined;
return outRow?.id ?? null;
} }
/** Get undelivered messages (for host polling). */ /** Get undelivered messages (for host polling). */

View File

@@ -67,7 +67,8 @@ function formatChatMessages(messages: MessageInRow[]): string {
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown'; const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
const time = formatTime(msg.timestamp); const time = formatTime(msg.timestamp);
const text = content.text || ''; const text = content.text || '';
lines.push(`<message sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}</message>`); const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
lines.push(`<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}</message>`);
} }
lines.push('</messages>'); lines.push('</messages>');
return lines.join('\n'); return lines.join('\n');
@@ -78,7 +79,8 @@ function formatSingleChat(msg: MessageInRow): string {
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown'; const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
const time = formatTime(msg.timestamp); const time = formatTime(msg.timestamp);
const text = content.text || ''; const text = content.text || '';
return `<message sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}</message>`; const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
return `<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}</message>`;
} }
function formatTaskMessage(msg: MessageInRow): string { function formatTaskMessage(msg: MessageInRow): string {

View File

@@ -64,7 +64,7 @@ async function main(): Promise<void> {
// MCP server path // MCP server path
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'mcp-tools.js'); const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.js');
// SDK env // SDK env
const env: Record<string, string | undefined> = { const env: Record<string, string | undefined> = {

View File

@@ -0,0 +1,58 @@
/**
* Agent-to-agent MCP tools: send_to_agent.
*/
import { writeMessageOut } from '../db/messages-out.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function ok(text: string) {
return { content: [{ type: 'text' as const, text }] };
}
function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
export const sendToAgent: McpToolDefinition = {
tool: {
name: 'send_to_agent',
description: 'Send a message to another agent group.',
inputSchema: {
type: 'object' as const,
properties: {
agentGroupId: { type: 'string', description: 'Target agent group ID' },
text: { type: 'string', description: 'Message content' },
sessionId: { type: 'string', description: 'Target specific session (optional)' },
},
required: ['agentGroupId', 'text'],
},
},
async handler(args) {
const agentGroupId = args.agentGroupId as string;
const text = args.text as string;
if (!agentGroupId || !text) return err('agentGroupId and text are required');
const id = generateId();
writeMessageOut({
id,
kind: 'chat',
channel_type: 'agent',
platform_id: agentGroupId,
thread_id: (args.sessionId as string) || null,
content: JSON.stringify({ text }),
});
log(`send_to_agent: ${id}${agentGroupId}`);
return ok(`Message sent to agent ${agentGroupId} (id: ${id})`);
},
};
export const agentTools: McpToolDefinition[] = [sendToAgent];

View File

@@ -0,0 +1,190 @@
/**
* Core MCP tools: send_message, send_file, edit_message, add_reaction.
*/
import fs from 'fs';
import path from 'path';
import { writeMessageOut, getMessageIdBySeq } from '../db/messages-out.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function routing() {
return {
platform_id: process.env.NANOCLAW_PLATFORM_ID || null,
channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null,
thread_id: process.env.NANOCLAW_THREAD_ID || null,
};
}
function ok(text: string) {
return { content: [{ type: 'text' as const, text }] };
}
function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
export const sendMessage: McpToolDefinition = {
tool: {
name: 'send_message',
description: 'Send a chat message to the current conversation or a specified destination.',
inputSchema: {
type: 'object' as const,
properties: {
text: { type: 'string', description: 'Message content' },
channel: { type: 'string', description: 'Target channel type (default: reply to origin)' },
platformId: { type: 'string', description: 'Target platform ID' },
threadId: { type: 'string', description: 'Target thread ID' },
},
required: ['text'],
},
},
async handler(args) {
const text = args.text as string;
if (!text) return err('text is required');
const id = generateId();
const r = routing();
const seq = writeMessageOut({
id,
kind: 'chat',
platform_id: (args.platformId as string) || r.platform_id,
channel_type: (args.channel as string) || r.channel_type,
thread_id: (args.threadId as string) || r.thread_id,
content: JSON.stringify({ text }),
});
log(`send_message: #${seq} ${id}${r.channel_type || 'default'}/${r.platform_id || 'default'}`);
return ok(`Message sent (id: ${seq})`);
},
};
export const sendFile: McpToolDefinition = {
tool: {
name: 'send_file',
description: 'Send a file to the current conversation.',
inputSchema: {
type: 'object' as const,
properties: {
path: { type: 'string', description: 'File path (relative to /workspace/agent/ or absolute)' },
text: { type: 'string', description: 'Optional accompanying message' },
filename: { type: 'string', description: 'Display name (default: basename of path)' },
},
required: ['path'],
},
},
async handler(args) {
const filePath = args.path as string;
if (!filePath) return err('path is required');
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath);
if (!fs.existsSync(resolvedPath)) return err(`File not found: ${filePath}`);
const id = generateId();
const filename = (args.filename as string) || path.basename(resolvedPath);
const r = routing();
// Copy file to outbox
const outboxDir = path.join('/workspace/outbox', id);
fs.mkdirSync(outboxDir, { recursive: true });
fs.copyFileSync(resolvedPath, path.join(outboxDir, filename));
writeMessageOut({
id,
kind: 'chat',
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }),
});
log(`send_file: ${id}${filename}`);
return ok(`File sent (id: ${id}, filename: ${filename})`);
},
};
export const editMessage: McpToolDefinition = {
tool: {
name: 'edit_message',
description: 'Edit a previously sent message.',
inputSchema: {
type: 'object' as const,
properties: {
messageId: { type: 'integer', description: 'Message ID (the numeric id shown in messages)' },
text: { type: 'string', description: 'New message content' },
},
required: ['messageId', 'text'],
},
},
async handler(args) {
const seq = Number(args.messageId);
const text = args.text as string;
if (!seq || !text) return err('messageId and text are required');
const platformId = getMessageIdBySeq(seq);
if (!platformId) return err(`Message #${seq} not found`);
const id = generateId();
const r = routing();
writeMessageOut({
id,
kind: 'chat',
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
content: JSON.stringify({ operation: 'edit', messageId: platformId, text }),
});
log(`edit_message: #${seq}${platformId}`);
return ok(`Message edit queued for #${seq}`);
},
};
export const addReaction: McpToolDefinition = {
tool: {
name: 'add_reaction',
description: 'Add an emoji reaction to a message.',
inputSchema: {
type: 'object' as const,
properties: {
messageId: { type: 'integer', description: 'Message ID (the numeric id shown in messages)' },
emoji: { type: 'string', description: 'Emoji name (e.g., thumbs_up, heart, check)' },
},
required: ['messageId', 'emoji'],
},
},
async handler(args) {
const seq = Number(args.messageId);
const emoji = args.emoji as string;
if (!seq || !emoji) return err('messageId and emoji are required');
const platformId = getMessageIdBySeq(seq);
if (!platformId) return err(`Message #${seq} not found`);
const id = generateId();
const r = routing();
writeMessageOut({
id,
kind: 'chat',
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
content: JSON.stringify({ operation: 'reaction', messageId: platformId, emoji }),
});
log(`add_reaction: #${seq}${emoji} on ${platformId}`);
return ok(`Reaction queued for #${seq}`);
},
};
export const coreTools: McpToolDefinition[] = [sendMessage, sendFile, editMessage, addReaction];

View File

@@ -0,0 +1,53 @@
/**
* MCP tools barrel — collects all tool modules and starts the server.
*
* Each module exports a McpToolDefinition[] array. This file registers
* them all with the MCP server. Adding a new tool module requires only
* importing it here and spreading its tools array.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import type { McpToolDefinition } from './types.js';
import { coreTools } from './core.js';
import { schedulingTools } from './scheduling.js';
import { interactiveTools } from './interactive.js';
import { agentTools } from './agents.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
const allTools: McpToolDefinition[] = [...coreTools, ...schedulingTools, ...interactiveTools, ...agentTools];
const toolMap = new Map<string, McpToolDefinition>();
for (const t of allTools) {
toolMap.set(t.tool.name, t);
}
async function startMcpServer(): Promise<void> {
const server = new Server({ name: 'nanoclaw', version: '2.0.0' }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: allTools.map((t) => t.tool),
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const tool = toolMap.get(name);
if (!tool) {
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
}
return tool.handler(args ?? {});
});
const transport = new StdioServerTransport();
await server.connect(transport);
log(`MCP server started with ${allTools.length} tools: ${allTools.map((t) => t.tool.name).join(', ')}`);
}
startMcpServer().catch((err) => {
log(`MCP server error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});

View File

@@ -0,0 +1,147 @@
/**
* Interactive MCP tools: ask_user_question, send_card.
*
* ask_user_question is a blocking tool call — it writes a messages_out row
* with a question card, then polls messages_in for the response.
*/
import { getSessionDb } from '../db/connection.js';
import { writeMessageOut } from '../db/messages-out.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function routing() {
return {
platform_id: process.env.NANOCLAW_PLATFORM_ID || null,
channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null,
thread_id: process.env.NANOCLAW_THREAD_ID || null,
};
}
function ok(text: string) {
return { content: [{ type: 'text' as const, text }] };
}
function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const askUserQuestion: McpToolDefinition = {
tool: {
name: 'ask_user_question',
description:
'Ask the user a multiple-choice question and wait for their response. This is a blocking call — execution pauses until the user responds or the timeout expires.',
inputSchema: {
type: 'object' as const,
properties: {
question: { type: 'string', description: 'The question to ask' },
options: {
type: 'array',
items: { type: 'string' },
description: 'Button labels for the user to choose from',
},
timeout: { type: 'number', description: 'Timeout in seconds (default: 300)' },
},
required: ['question', 'options'],
},
},
async handler(args) {
const question = args.question as string;
const options = args.options as string[];
const timeout = ((args.timeout as number) || 300) * 1000;
if (!question || !options?.length) return err('question and options are required');
const questionId = generateId();
const r = routing();
// Write question card to messages_out
writeMessageOut({
id: questionId,
kind: 'chat-sdk',
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
content: JSON.stringify({
type: 'ask_question',
questionId,
question,
options,
}),
});
log(`ask_user_question: ${questionId} → "${question}" [${options.join(', ')}]`);
// Poll for response in messages_in
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const response = getSessionDb()
.prepare("SELECT content FROM messages_in WHERE kind = 'system' AND content LIKE ? AND status = 'pending' LIMIT 1")
.get(`%"questionId":"${questionId}"%`) as { content: string } | undefined;
if (response) {
const parsed = JSON.parse(response.content);
// Mark the response as completed so the poll loop doesn't pick it up
getSessionDb()
.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE kind = 'system' AND content LIKE ?")
.run(`%"questionId":"${questionId}"%`);
log(`ask_user_question response: ${questionId}${parsed.selectedOption}`);
return ok(parsed.selectedOption);
}
await sleep(1000);
}
log(`ask_user_question timeout: ${questionId}`);
return err(`Question timed out after ${timeout / 1000}s`);
},
};
export const sendCard: McpToolDefinition = {
tool: {
name: 'send_card',
description: 'Send a structured card (interactive or display-only) to the current conversation.',
inputSchema: {
type: 'object' as const,
properties: {
card: {
type: 'object',
description: 'Card structure with title, description, and optional children/actions',
},
fallbackText: { type: 'string', description: 'Text fallback for platforms without card support' },
},
required: ['card'],
},
},
async handler(args) {
const card = args.card as Record<string, unknown>;
if (!card) return err('card is required');
const id = generateId();
const r = routing();
writeMessageOut({
id,
kind: 'chat-sdk',
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
content: JSON.stringify({ type: 'card', card, fallbackText: (args.fallbackText as string) || '' }),
});
log(`send_card: ${id}`);
return ok(`Card sent (id: ${id})`);
},
};
export const interactiveTools: McpToolDefinition[] = [askUserQuestion, sendCard];

View File

@@ -0,0 +1,199 @@
/**
* Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task.
*
* Tasks are messages_in rows with process_after timestamps and optional recurrence.
* The host sweep detects due tasks and wakes the container.
*/
import { getSessionDb } from '../db/connection.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
function generateId(): string {
return `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function routing() {
return {
platform_id: process.env.NANOCLAW_PLATFORM_ID || null,
channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null,
thread_id: process.env.NANOCLAW_THREAD_ID || null,
};
}
function ok(text: string) {
return { content: [{ type: 'text' as const, text }] };
}
function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
export const scheduleTask: McpToolDefinition = {
tool: {
name: 'schedule_task',
description:
'Schedule a one-shot or recurring task. The task will be processed at the specified time. Use cron expressions for recurring tasks.',
inputSchema: {
type: 'object' as const,
properties: {
prompt: { type: 'string', description: 'Task instructions/prompt' },
processAfter: { type: 'string', description: 'ISO timestamp for first run (e.g., 2024-01-15T09:00:00Z)' },
recurrence: { type: 'string', description: 'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" for weekdays at 9am)' },
script: { type: 'string', description: 'Optional pre-agent script to run before processing' },
},
required: ['prompt', 'processAfter'],
},
},
async handler(args) {
const prompt = args.prompt as string;
const processAfter = args.processAfter as string;
if (!prompt || !processAfter) return err('prompt and processAfter are required');
const id = generateId();
const r = routing();
const recurrence = (args.recurrence as string) || null;
const script = (args.script as string) || null;
const content = JSON.stringify({ prompt, script });
getSessionDb()
.prepare(
`INSERT INTO messages_in (id, timestamp, status, status_changed, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content)
VALUES (@id, datetime('now'), 'pending', datetime('now'), 0, @process_after, @recurrence, 'task', @platform_id, @channel_type, @thread_id, @content)`,
)
.run({
id,
process_after: processAfter,
recurrence,
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
content,
});
log(`schedule_task: ${id} at ${processAfter}${recurrence ? ` (recurring: ${recurrence})` : ''}`);
return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`);
},
};
export const listTasks: McpToolDefinition = {
tool: {
name: 'list_tasks',
description: 'List scheduled and pending tasks.',
inputSchema: {
type: 'object' as const,
properties: {
status: { type: 'string', description: 'Filter by status: pending, processing, completed, paused (default: all non-completed)' },
},
},
},
async handler(args) {
const status = args.status as string | undefined;
let rows;
if (status) {
rows = getSessionDb()
.prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status = ? ORDER BY process_after ASC")
.all(status);
} else {
rows = getSessionDb()
.prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status NOT IN ('completed') ORDER BY process_after ASC")
.all();
}
if ((rows as unknown[]).length === 0) return ok('No tasks found.');
const lines = (rows as Array<{ id: string; status: string; process_after: string | null; recurrence: string | null; content: string }>).map((r) => {
const content = JSON.parse(r.content);
const prompt = (content.prompt as string || '').slice(0, 80);
return `- ${r.id} [${r.status}] at=${r.process_after || 'now'} ${r.recurrence ? `recur=${r.recurrence} ` : ''}${prompt}`;
});
return ok(lines.join('\n'));
},
};
export const cancelTask: McpToolDefinition = {
tool: {
name: 'cancel_task',
description: 'Cancel a scheduled task.',
inputSchema: {
type: 'object' as const,
properties: {
taskId: { type: 'string', description: 'Task ID to cancel' },
},
required: ['taskId'],
},
},
async handler(args) {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
const result = getSessionDb()
.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status IN ('pending', 'paused')")
.run(taskId);
if (result.changes === 0) return err(`Task not found or not cancellable: ${taskId}`);
log(`cancel_task: ${taskId}`);
return ok(`Task cancelled: ${taskId}`);
},
};
export const pauseTask: McpToolDefinition = {
tool: {
name: 'pause_task',
description: 'Pause a scheduled task. It will not run until resumed.',
inputSchema: {
type: 'object' as const,
properties: {
taskId: { type: 'string', description: 'Task ID to pause' },
},
required: ['taskId'],
},
},
async handler(args) {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
const result = getSessionDb()
.prepare("UPDATE messages_in SET status = 'paused', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status = 'pending'")
.run(taskId);
if (result.changes === 0) return err(`Task not found or not pausable: ${taskId}`);
log(`pause_task: ${taskId}`);
return ok(`Task paused: ${taskId}`);
},
};
export const resumeTask: McpToolDefinition = {
tool: {
name: 'resume_task',
description: 'Resume a paused task.',
inputSchema: {
type: 'object' as const,
properties: {
taskId: { type: 'string', description: 'Task ID to resume' },
},
required: ['taskId'],
},
},
async handler(args) {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
const result = getSessionDb()
.prepare("UPDATE messages_in SET status = 'pending', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status = 'paused'")
.run(taskId);
if (result.changes === 0) return err(`Task not found or not paused: ${taskId}`);
log(`resume_task: ${taskId}`);
return ok(`Task resumed: ${taskId}`);
},
};
export const schedulingTools: McpToolDefinition[] = [scheduleTask, listTasks, cancelTask, pauseTask, resumeTask];

View File

@@ -0,0 +1,6 @@
import type { Tool, CallToolResult } from '@modelcontextprotocol/sdk/types.js';
export interface McpToolDefinition {
tool: Tool;
handler: (args: Record<string, unknown>) => Promise<CallToolResult>;
}

1439
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,8 +21,11 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@chat-adapter/discord": "^4.24.0",
"@chat-adapter/state-memory": "^4.24.0",
"@onecli-sh/sdk": "^0.2.0", "@onecli-sh/sdk": "^0.2.0",
"better-sqlite3": "11.10.0", "better-sqlite3": "11.10.0",
"chat": "^4.24.0",
"cron-parser": "5.5.0" "cron-parser": "5.5.0"
}, },
"devDependencies": { "devDependencies": {

78
scripts/seed-discord.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* Seed the v2 central DB with a Discord agent group + messaging group.
*
* Usage: npx tsx scripts/seed-discord.ts
*/
import path from 'path';
import { DATA_DIR } from '../src/config.js';
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
import { createAgentGroup, getAgentGroup } from '../src/db/agent-groups.js';
import {
createMessagingGroup,
createMessagingGroupAgent,
getMessagingGroup,
} from '../src/db/messaging-groups.js';
const db = initDb(path.join(DATA_DIR, 'v2.db'));
runMigrations(db);
const AGENT_GROUP_ID = 'ag-main';
const MESSAGING_GROUP_ID = 'mg-discord';
const CHANNEL_ID = 'discord:1470188214710046894:1491569326447132673';
// Agent group
if (!getAgentGroup(AGENT_GROUP_ID)) {
createAgentGroup({
id: AGENT_GROUP_ID,
name: 'Main',
folder: 'main',
is_admin: 1,
agent_provider: 'claude',
container_config: null,
created_at: new Date().toISOString(),
});
console.log('Created agent group:', AGENT_GROUP_ID);
} else {
console.log('Agent group already exists:', AGENT_GROUP_ID);
}
// Messaging group
if (!getMessagingGroup(MESSAGING_GROUP_ID)) {
createMessagingGroup({
id: MESSAGING_GROUP_ID,
channel_type: 'discord',
platform_id: CHANNEL_ID,
name: 'Discord Test',
is_group: 1,
admin_user_id: null,
created_at: new Date().toISOString(),
});
console.log('Created messaging group:', MESSAGING_GROUP_ID);
} else {
console.log('Messaging group already exists:', MESSAGING_GROUP_ID);
}
// Link
try {
createMessagingGroupAgent({
id: 'mga-discord',
messaging_group_id: MESSAGING_GROUP_ID,
agent_group_id: AGENT_GROUP_ID,
trigger_rules: null,
response_scope: 'all',
session_mode: 'shared',
priority: 0,
created_at: new Date().toISOString(),
});
console.log('Created messaging_group_agent link');
} catch (err: any) {
if (err.message?.includes('UNIQUE')) {
console.log('Messaging group agent link already exists');
} else {
throw err;
}
}
console.log('Done! Run: npm run build && node dist/index-v2.js');

View File

@@ -0,0 +1,257 @@
/**
* End-to-end test of v2 channel adapter pipeline:
*
* Mock adapter → onInbound → router → session DB → Docker container →
* agent-runner → Claude → messages_out → delivery → mock adapter.deliver()
*
* Usage: npx tsx scripts/test-v2-channel-e2e.ts
*/
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
const TEST_DIR = '/tmp/nanoclaw-v2-channel-e2e';
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
// --- Step 1: Init central DB ---
console.log('\n=== Step 1: Init central DB ===');
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
import { createAgentGroup } from '../src/db/agent-groups.js';
import { createMessagingGroup, createMessagingGroupAgent } from '../src/db/messaging-groups.js';
const centralDb = initDb(path.join(TEST_DIR, 'v2.db'));
runMigrations(centralDb);
// Create groups dir for agent folder mount
const groupsDir = path.resolve(process.cwd(), 'groups');
const testGroupDir = path.join(groupsDir, 'test-channel-e2e');
fs.mkdirSync(testGroupDir, { recursive: true });
fs.writeFileSync(path.join(testGroupDir, 'CLAUDE.md'), '# Test Agent\nYou are a test agent. Be brief.\n');
createAgentGroup({
id: 'ag-chan',
name: 'Channel E2E Agent',
folder: 'test-channel-e2e',
is_admin: 1, // admin so OneCLI uses default agent for auth
agent_provider: 'claude',
container_config: null,
created_at: new Date().toISOString(),
});
createMessagingGroup({
id: 'mg-chan',
channel_type: 'mock',
platform_id: 'mock-channel-1',
name: 'Mock Channel',
is_group: 0,
admin_user_id: null,
created_at: new Date().toISOString(),
});
createMessagingGroupAgent({
id: 'mga-chan',
messaging_group_id: 'mg-chan',
agent_group_id: 'ag-chan',
trigger_rules: null,
response_scope: 'all',
session_mode: 'shared',
priority: 0,
created_at: new Date().toISOString(),
});
console.log('✓ Central DB initialized');
// --- Step 2: Set up mock channel adapter + delivery ---
console.log('\n=== Step 2: Set up mock channel adapter & delivery ===');
import { routeInbound } from '../src/router-v2.js';
import { setDeliveryAdapter, startActiveDeliveryPoll, stopDeliveryPolls } from '../src/delivery.js';
import { getChannelAdapter, registerChannelAdapter, initChannelAdapters } from '../src/channels/channel-registry.js';
import { findSession } from '../src/db/sessions.js';
import { sessionDbPath } from '../src/session-manager.js';
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from '../src/channels/adapter.js';
// Track delivered messages
const deliveredMessages: Array<{ platformId: string; threadId: string | null; message: OutboundMessage }> = [];
let lastDeliveryTime = 0;
const startTime = Date.now();
// Create mock adapter
const mockAdapter: ChannelAdapter = {
name: 'mock',
channelType: 'mock',
async setup(config: ChannelSetup) {
console.log(` ✓ Mock adapter setup with ${config.conversations.length} conversations`);
},
async deliver(platformId, threadId, message) {
deliveredMessages.push({ platformId, threadId, message });
lastDeliveryTime = Date.now();
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const content = message.content as Record<string, unknown>;
const text = ((content.text as string) || '').slice(0, 120);
console.log(` ✓ [${elapsed}s] Delivered #${deliveredMessages.length}: ${text}...`);
},
async setTyping() {},
async teardown() {},
isConnected() { return true; },
};
// Register mock adapter
registerChannelAdapter('mock', { factory: () => mockAdapter });
// Init channel adapters — this calls setup() with conversation configs from central DB
await initChannelAdapters((adapter) => ({
conversations: [{ platformId: 'mock-channel-1', agentGroupId: 'ag-chan', requiresTrigger: false, sessionMode: 'shared' }],
onInbound(platformId, threadId, message) {
routeInbound({
channelType: adapter.channelType,
platformId,
threadId,
message: {
id: message.id,
kind: message.kind,
content: JSON.stringify(message.content),
timestamp: message.timestamp,
},
}).catch((err) => console.error('Route error:', err));
},
onMetadata() {},
}));
// Set up delivery adapter bridge
setDeliveryAdapter({
async deliver(channelType, platformId, threadId, kind, content) {
const adapter = getChannelAdapter(channelType);
if (!adapter) return;
await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content) });
},
});
// Start delivery polling
startActiveDeliveryPoll();
console.log('✓ Mock adapter & delivery configured');
// --- Step 3: Simulate inbound message through adapter ---
console.log('\n=== Step 3: Simulate inbound message ===');
// This is what a real adapter would do when receiving a platform message
const adapterSetup = (mockAdapter as { _setup?: ChannelSetup })._setup;
// Call routeInbound directly (simulating onInbound callback)
await routeInbound({
channelType: 'mock',
platformId: 'mock-channel-1',
threadId: null,
message: {
id: 'msg-chan-1',
kind: 'chat',
content: JSON.stringify({
sender: 'Gavriel',
text: 'Call the send_message tool 3 times: text="Update 1", text="Update 2", text="Update 3". Make each call separately. After all 3, say "Done".',
}),
timestamp: new Date().toISOString(),
},
});
const session = findSession('mg-chan', null);
if (!session) {
console.log('✗ No session created!');
cleanup();
process.exit(1);
}
console.log(`✓ Session: ${session.id}`);
console.log(`✓ Container status: ${session.container_status}`);
import { execSync } from 'child_process';
const checkContainerLogs = () => {
try {
const containers = execSync('docker ps -a --filter name=nanoclaw-v2-test-channel --format "{{.Names}}"').toString().trim();
for (const name of containers.split('\n').filter(Boolean)) {
console.log(`\nContainer logs (${name}):`);
console.log(execSync(`docker logs ${name} 2>&1`).toString());
}
} catch { /* ignore */ }
};
const sessDbPath = sessionDbPath('ag-chan', session.id);
console.log(`✓ Session DB: ${sessDbPath}`);
// --- Step 4: Wait for delivery through mock adapter ---
console.log('\n=== Step 4: Waiting for delivery through mock adapter... ===');
const TIMEOUT_MS = 300_000;
// Wait for deliveries — resolve when no new ones for 30s after first delivery
await new Promise<void>((resolve) => {
const poll = () => {
if (lastDeliveryTime > 0 && Date.now() - lastDeliveryTime > 30_000) {
resolve();
return;
}
if (Date.now() - startTime > TIMEOUT_MS) {
console.log(`\n✗ Timed out after ${TIMEOUT_MS / 1000}s`);
// Check session DB directly
try {
const db = new Database(sessDbPath, { readonly: true });
const out = db.prepare('SELECT * FROM messages_out').all();
console.log(` messages_out rows: ${out.length}`);
if (out.length > 0) console.log(' (messages exist but delivery failed)');
db.close();
} catch { /* ignore */ }
checkContainerLogs();
cleanup();
process.exit(1);
}
const elapsed = Math.floor((Date.now() - startTime) / 1000);
if (elapsed > 0 && elapsed % 10 === 0) {
process.stdout.write(` ${elapsed}s...`);
}
setTimeout(poll, 1000);
};
poll();
});
// --- Step 5: Print results ---
console.log('\n\n=== Results ===');
console.log('\nSession DB:');
try {
const db = new Database(sessDbPath, { readonly: true });
const inRows = db.prepare('SELECT * FROM messages_in').all() as Array<Record<string, unknown>>;
const outRows = db.prepare('SELECT * FROM messages_out').all() as Array<Record<string, unknown>>;
db.close();
console.log(` messages_in: ${inRows.length} row(s)`);
for (const r of inRows) {
console.log(` [${r.id}] status=${r.status} kind=${r.kind}`);
}
console.log(` messages_out: ${outRows.length} row(s)`);
for (const r of outRows) {
const content = JSON.parse(r.content as string);
console.log(` [${r.id}] kind=${r.kind} delivered=${r.delivered}`);
console.log(`${content.text}`);
}
} catch (err) {
console.log(` (could not read session DB: ${err})`);
}
console.log('\nDelivered through mock adapter:');
for (const d of deliveredMessages) {
const content = d.message.content as Record<string, unknown>;
console.log(` → [${d.platformId}] ${content.text}`);
}
console.log('\n✓ Full channel adapter pipeline verified!');
cleanup();
process.exit(0);
function cleanup() {
stopDeliveryPolls();
fs.rmSync(testGroupDir, { recursive: true, force: true });
}

View File

@@ -0,0 +1,189 @@
/**
* Chat SDK bridge — wraps a Chat SDK adapter + Chat instance
* to conform to the NanoClaw ChannelAdapter interface.
*
* Used by Discord, Slack, and other Chat SDK-supported platforms.
*/
import { Chat, type Adapter, type ConcurrencyStrategy, type Message as ChatMessage } from 'chat';
import { createMemoryState } from '@chat-adapter/state-memory';
import { log } from '../log.js';
import type { ChannelAdapter, ChannelSetup, ConversationConfig, InboundMessage } from './adapter.js';
/** Adapter with optional gateway support (e.g., Discord). */
interface GatewayAdapter extends Adapter {
startGatewayListener?(
options: { waitUntil?: (task: Promise<unknown>) => void },
durationMs?: number,
abortSignal?: AbortSignal,
): Promise<Response>;
}
export interface ChatSdkBridgeConfig {
adapter: GatewayAdapter;
concurrency?: ConcurrencyStrategy;
}
export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter {
const { adapter } = config;
let chat: Chat;
let state: ReturnType<typeof createMemoryState>;
let setupConfig: ChannelSetup;
let conversations: Map<string, ConversationConfig>;
let gatewayAbort: AbortController | null = null;
function buildConversationMap(configs: ConversationConfig[]): Map<string, ConversationConfig> {
const map = new Map<string, ConversationConfig>();
for (const conv of configs) {
map.set(conv.platformId, conv);
}
return map;
}
function messageToInbound(message: ChatMessage): InboundMessage {
return {
id: message.id,
kind: 'chat-sdk',
content: message.toJSON(),
timestamp: message.metadata.dateSent.toISOString(),
};
}
return {
name: adapter.name,
channelType: adapter.name,
async setup(hostConfig: ChannelSetup) {
setupConfig = hostConfig;
conversations = buildConversationMap(hostConfig.conversations);
state = createMemoryState();
chat = new Chat({
adapters: { [adapter.name]: adapter },
userName: adapter.userName || 'NanoClaw',
concurrency: config.concurrency ?? 'concurrent',
state,
logger: 'silent',
});
// Subscribed threads — forward all messages
chat.onSubscribedMessage(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
setupConfig.onInbound(channelId, thread.id, messageToInbound(message));
});
// @mention in unsubscribed thread — forward + subscribe
chat.onNewMention(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
setupConfig.onInbound(channelId, thread.id, messageToInbound(message));
await thread.subscribe();
});
// DMs — always forward + subscribe
chat.onDirectMessage(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
setupConfig.onInbound(channelId, null, messageToInbound(message));
await thread.subscribe();
});
await chat.initialize();
// Subscribe registered conversations (after initialize connects state)
for (const conv of hostConfig.conversations) {
if (conv.agentGroupId) {
const threadId = adapter.encodeThreadId({ guildId: '', channelId: conv.platformId } as never);
await state.subscribe(threadId);
}
}
// Start Gateway listener for adapters that support it (e.g., Discord)
if (adapter.startGatewayListener) {
gatewayAbort = new AbortController();
const startGateway = () => {
if (gatewayAbort?.signal.aborted) return;
// Capture the long-running listener promise via waitUntil
let listenerPromise: Promise<unknown> | undefined;
adapter
.startGatewayListener!(
{ waitUntil: (p: Promise<unknown>) => { listenerPromise = p; } },
24 * 60 * 60 * 1000,
gatewayAbort!.signal,
)
.then(() => {
// startGatewayListener resolves immediately with a Response;
// the actual work is in the listenerPromise passed to waitUntil
if (listenerPromise) {
listenerPromise
.then(() => {
if (!gatewayAbort?.signal.aborted) {
log.info('Gateway listener expired, restarting', { adapter: adapter.name });
startGateway();
}
})
.catch((err) => {
if (!gatewayAbort?.signal.aborted) {
log.error('Gateway listener error, restarting in 5s', { adapter: adapter.name, err });
setTimeout(startGateway, 5000);
}
});
}
});
};
startGateway();
log.info('Gateway listener started', { adapter: adapter.name });
}
log.info('Chat SDK bridge initialized', { adapter: adapter.name });
},
async deliver(platformId: string, threadId: string | null, message) {
const tid = threadId ?? adapter.encodeThreadId({ guildId: '', channelId: platformId } as never);
const content = message.content as Record<string, unknown>;
if (content.operation === 'edit' && content.messageId) {
await adapter.editMessage(tid, content.messageId as string, {
markdown: (content.text as string) || (content.markdown as string) || '',
});
return;
}
if (content.operation === 'reaction' && content.messageId && content.emoji) {
await adapter.addReaction(tid, content.messageId as string, content.emoji as string);
return;
}
// Normal message
const text = (content.markdown as string) || (content.text as string);
if (text) {
await adapter.postMessage(tid, { markdown: text });
}
},
async setTyping(platformId: string, threadId: string | null) {
const tid = threadId ?? adapter.encodeThreadId({ guildId: '', channelId: platformId } as never);
await adapter.startTyping(tid);
},
async teardown() {
gatewayAbort?.abort();
await chat.shutdown();
log.info('Chat SDK bridge shut down', { adapter: adapter.name });
},
isConnected() {
return true;
},
updateConversations(configs: ConversationConfig[]) {
conversations = buildConversationMap(configs);
// Subscribe new conversations
for (const conv of configs) {
if (conv.agentGroupId) {
const threadId = adapter.encodeThreadId({ guildId: '', channelId: conv.platformId } as never);
state.subscribe(threadId).catch(() => {});
}
}
},
};
}

View File

@@ -0,0 +1,22 @@
/**
* Discord channel adapter (v2) — uses Chat SDK bridge.
* Self-registers on import.
*/
import { createDiscordAdapter } from '@chat-adapter/discord';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('discord', {
factory: () => {
const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']);
if (!env.DISCORD_BOT_TOKEN) return null;
const discordAdapter = createDiscordAdapter({
botToken: env.DISCORD_BOT_TOKEN,
publicKey: env.DISCORD_PUBLIC_KEY,
applicationId: env.DISCORD_APPLICATION_ID,
});
return createChatSdkBridge({ adapter: discordAdapter, concurrency: 'concurrent' });
},
});

View File

@@ -185,15 +185,8 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] {
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src'); const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src');
if (fs.existsSync(agentRunnerSrc)) { if (fs.existsSync(agentRunnerSrc)) {
const srcIndex = path.join(agentRunnerSrc, 'index-v2.ts'); // Always copy — source files may have changed beyond just the index
const cachedIndex = path.join(groupRunnerDir, 'index-v2.ts'); fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true });
const needsCopy =
!fs.existsSync(groupRunnerDir) ||
!fs.existsSync(cachedIndex) ||
fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs;
if (needsCopy) {
fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true });
}
} }
mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false }); mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false });

View File

@@ -74,6 +74,7 @@ CREATE TABLE pending_questions (
export const SESSION_SCHEMA = ` export const SESSION_SCHEMA = `
CREATE TABLE messages_in ( CREATE TABLE messages_in (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
seq INTEGER UNIQUE,
kind TEXT NOT NULL, kind TEXT NOT NULL,
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', status TEXT DEFAULT 'pending',
@@ -89,6 +90,7 @@ CREATE TABLE messages_in (
CREATE TABLE messages_out ( CREATE TABLE messages_out (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
seq INTEGER UNIQUE,
in_reply_to TEXT, in_reply_to TEXT,
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
delivered INTEGER DEFAULT 0, delivered INTEGER DEFAULT 0,

View File

@@ -17,7 +17,7 @@ import { routeInbound } from './router-v2.js';
import { log } from './log.js'; import { log } from './log.js';
// Channel imports — each triggers self-registration // Channel imports — each triggers self-registration
// import './channels/discord-v2.js'; import './channels/discord-v2.js';
import type { ChannelAdapter, ChannelSetup, ConversationConfig } from './channels/adapter.js'; import type { ChannelAdapter, ChannelSetup, ConversationConfig } from './channels/adapter.js';
import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js'; import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js';

View File

@@ -108,11 +108,23 @@ export function writeSessionMessage(
db.pragma('journal_mode = DELETE'); db.pragma('journal_mode = DELETE');
try { try {
const nextSeq = (
db
.prepare(
`SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM (
SELECT seq FROM messages_in WHERE seq IS NOT NULL
UNION ALL
SELECT seq FROM messages_out WHERE seq IS NOT NULL
)`,
)
.get() as { next: number }
).next;
db.prepare( db.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence) `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence)
VALUES (@id, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence)`, VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence)`,
).run({ ).run({
id: message.id, id: message.id,
seq: nextSeq,
kind: message.kind, kind: message.kind,
timestamp: message.timestamp, timestamp: message.timestamp,
platformId: message.platformId ?? null, platformId: message.platformId ?? null,