feat(skills): add pdf-reader skill (#772)

Thanks @glifocat! Clean skill package — good docs, solid tests, nice intent files. Pushed a small fix for path traversal on the PDF filename before merging.
This commit is contained in:
glifocat
2026-03-06 17:47:12 +01:00
committed by GitHub
parent 1e89d61928
commit 0b260ece57
12 changed files with 2238 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
# NanoClaw Agent Container
# Runs Claude Agent SDK in isolated Linux VM with browser automation
FROM node:22-slim
# Install system dependencies for Chromium and PDF tools
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
libgbm1 \
libnss3 \
libatk-bridge2.0-0 \
libgtk-3-0 \
libx11-xcb1 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libasound2 \
libpangocairo-1.0-0 \
libcups2 \
libdrm2 \
libxshmfence1 \
curl \
git \
poppler-utils \
&& rm -rf /var/lib/apt/lists/*
# Set Chromium path for agent-browser
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
# Install agent-browser and claude-code globally
RUN npm install -g agent-browser @anthropic-ai/claude-code
# Create app directory
WORKDIR /app
# Copy package files first for better caching
COPY agent-runner/package*.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY agent-runner/ ./
# Build TypeScript
RUN npm run build
# Install pdf-reader CLI
COPY skills/pdf-reader/pdf-reader /usr/local/bin/pdf-reader
RUN chmod +x /usr/local/bin/pdf-reader
# Create workspace directories
RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
# Create entrypoint script
# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it
# Follow-up messages arrive via IPC files in /workspace/ipc/input/
RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# Set ownership to node user (non-root) for writable directories
RUN chown -R node:node /workspace && chmod 777 /home/node
# Switch to non-root user (required for --dangerously-skip-permissions)
USER node
# Set working directory to group workspace
WORKDIR /workspace/group
# Entry point reads JSON from stdin, outputs JSON to stdout
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -0,0 +1,23 @@
# Intent: container/Dockerfile modifications
## What changed
Added PDF reading capability via poppler-utils and a custom pdf-reader CLI script.
## Key sections
### apt-get install (system dependencies block)
- Added: `poppler-utils` to the package list (provides pdftotext, pdfinfo, pdftohtml)
- Changed: Comment updated to mention PDF tools
### After npm global installs
- Added: `COPY skills/pdf-reader/pdf-reader /usr/local/bin/pdf-reader` to copy CLI script
- Added: `RUN chmod +x /usr/local/bin/pdf-reader` to make it executable
## Invariants (must-keep)
- All Chromium dependencies unchanged
- agent-browser and claude-code npm global installs unchanged
- WORKDIR, COPY agent-runner, npm install, npm run build sequence unchanged
- Workspace directory creation unchanged
- Entrypoint script unchanged
- User switching (node user) unchanged
- ENTRYPOINT unchanged

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
# Intent: src/channels/whatsapp.test.ts modifications
## What changed
Added mocks for downloadMediaMessage and normalizeMessageContent, and test cases for PDF attachment handling.
## Key sections
### Mocks (top of file)
- Modified: config mock to export `GROUPS_DIR: '/tmp/test-groups'`
- Modified: `fs` mock to include `writeFileSync` as vi.fn()
- Modified: Baileys mock to export `downloadMediaMessage`, `normalizeMessageContent`
- Modified: fake socket factory to include `updateMediaMessage`
### Test cases (inside "message handling" describe block)
- "downloads and injects PDF attachment path" — verifies PDF download, save, and content replacement
- "handles PDF download failure gracefully" — verifies error handling (message skipped since content remains empty)
## Invariants (must-keep)
- All existing test cases unchanged
- All existing mocks unchanged (only additive changes)
- All existing test helpers unchanged
- All describe blocks preserved

View File

@@ -0,0 +1,429 @@
import { exec } from 'child_process';
import fs from 'fs';
import path from 'path';
import makeWASocket, {
Browsers,
DisconnectReason,
downloadMediaMessage,
WASocket,
fetchLatestWaWebVersion,
makeCacheableSignalKeyStore,
normalizeMessageContent,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
import {
ASSISTANT_HAS_OWN_NUMBER,
ASSISTANT_NAME,
GROUPS_DIR,
STORE_DIR,
} from '../config.js';
import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js';
import { logger } from '../logger.js';
import {
Channel,
OnInboundMessage,
OnChatMetadata,
RegisteredGroup,
} from '../types.js';
import { registerChannel, ChannelOpts } from './registry.js';
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface WhatsAppChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class WhatsAppChannel implements Channel {
name = 'whatsapp';
private sock!: WASocket;
private connected = false;
private lidToPhoneMap: Record<string, string> = {};
private outgoingQueue: Array<{ jid: string; text: string }> = [];
private flushing = false;
private groupSyncTimerStarted = false;
private opts: WhatsAppChannelOpts;
constructor(opts: WhatsAppChannelOpts) {
this.opts = opts;
}
async connect(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.connectInternal(resolve).catch(reject);
});
}
private async connectInternal(onFirstOpen?: () => void): Promise<void> {
const authDir = path.join(STORE_DIR, 'auth');
fs.mkdirSync(authDir, { recursive: true });
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
logger.warn(
{ err },
'Failed to fetch latest WA Web version, using default',
);
return { version: undefined };
});
this.sock = makeWASocket({
version,
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
printQRInTerminal: false,
logger,
browser: Browsers.macOS('Chrome'),
});
this.sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
const msg =
'WhatsApp authentication required. Run /setup in Claude Code.';
logger.error(msg);
exec(
`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`,
);
setTimeout(() => process.exit(1), 1000);
}
if (connection === 'close') {
this.connected = false;
const reason = (
lastDisconnect?.error as { output?: { statusCode?: number } }
)?.output?.statusCode;
const shouldReconnect = reason !== DisconnectReason.loggedOut;
logger.info(
{
reason,
shouldReconnect,
queuedMessages: this.outgoingQueue.length,
},
'Connection closed',
);
if (shouldReconnect) {
logger.info('Reconnecting...');
this.connectInternal().catch((err) => {
logger.error({ err }, 'Failed to reconnect, retrying in 5s');
setTimeout(() => {
this.connectInternal().catch((err2) => {
logger.error({ err: err2 }, 'Reconnection retry failed');
});
}, 5000);
});
} else {
logger.info('Logged out. Run /setup to re-authenticate.');
process.exit(0);
}
} else if (connection === 'open') {
this.connected = true;
logger.info('Connected to WhatsApp');
// Announce availability so WhatsApp relays subsequent presence updates (typing indicators)
this.sock.sendPresenceUpdate('available').catch((err) => {
logger.warn({ err }, 'Failed to send presence update');
});
// Build LID to phone mapping from auth state for self-chat translation
if (this.sock.user) {
const phoneUser = this.sock.user.id.split(':')[0];
const lidUser = this.sock.user.lid?.split(':')[0];
if (lidUser && phoneUser) {
this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`;
logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set');
}
}
// Flush any messages queued while disconnected
this.flushOutgoingQueue().catch((err) =>
logger.error({ err }, 'Failed to flush outgoing queue'),
);
// Sync group metadata on startup (respects 24h cache)
this.syncGroupMetadata().catch((err) =>
logger.error({ err }, 'Initial group sync failed'),
);
// Set up daily sync timer (only once)
if (!this.groupSyncTimerStarted) {
this.groupSyncTimerStarted = true;
setInterval(() => {
this.syncGroupMetadata().catch((err) =>
logger.error({ err }, 'Periodic group sync failed'),
);
}, GROUP_SYNC_INTERVAL_MS);
}
// Signal first connection to caller
if (onFirstOpen) {
onFirstOpen();
onFirstOpen = undefined;
}
}
});
this.sock.ev.on('creds.update', saveCreds);
this.sock.ev.on('messages.upsert', async ({ messages }) => {
for (const msg of messages) {
try {
if (!msg.message) continue;
// Unwrap container types (viewOnceMessageV2, ephemeralMessage,
// editedMessage, etc.) so that conversation, extendedTextMessage,
// imageMessage, etc. are accessible at the top level.
const normalized = normalizeMessageContent(msg.message);
if (!normalized) continue;
const rawJid = msg.key.remoteJid;
if (!rawJid || rawJid === 'status@broadcast') continue;
// Translate LID JID to phone JID if applicable
const chatJid = await this.translateJid(rawJid);
const timestamp = new Date(
Number(msg.messageTimestamp) * 1000,
).toISOString();
// Always notify about chat metadata for group discovery
const isGroup = chatJid.endsWith('@g.us');
this.opts.onChatMetadata(
chatJid,
timestamp,
undefined,
'whatsapp',
isGroup,
);
// Only deliver full message for registered groups
const groups = this.opts.registeredGroups();
if (groups[chatJid]) {
let content =
normalized.conversation ||
normalized.extendedTextMessage?.text ||
normalized.imageMessage?.caption ||
normalized.videoMessage?.caption ||
'';
// PDF attachment handling
if (normalized?.documentMessage?.mimetype === 'application/pdf') {
try {
const buffer = await downloadMediaMessage(msg, 'buffer', {});
const groupDir = path.join(GROUPS_DIR, groups[chatJid].folder);
const attachDir = path.join(groupDir, 'attachments');
fs.mkdirSync(attachDir, { recursive: true });
const filename = path.basename(
normalized.documentMessage.fileName ||
`doc-${Date.now()}.pdf`,
);
const filePath = path.join(attachDir, filename);
fs.writeFileSync(filePath, buffer as Buffer);
const sizeKB = Math.round((buffer as Buffer).length / 1024);
const pdfRef = `[PDF: attachments/${filename} (${sizeKB}KB)]\nUse: pdf-reader extract attachments/${filename}`;
const caption = normalized.documentMessage.caption || '';
content = caption ? `${caption}\n\n${pdfRef}` : pdfRef;
logger.info(
{ jid: chatJid, filename },
'Downloaded PDF attachment',
);
} catch (err) {
logger.warn(
{ err, jid: chatJid },
'Failed to download PDF attachment',
);
}
}
// Skip protocol messages with no text content (encryption keys, read receipts, etc.)
if (!content) continue;
const sender = msg.key.participant || msg.key.remoteJid || '';
const senderName = msg.pushName || sender.split('@')[0];
const fromMe = msg.key.fromMe || false;
// Detect bot messages: with own number, fromMe is reliable
// since only the bot sends from that number.
// With shared number, bot messages carry the assistant name prefix
// (even in DMs/self-chat) so we check for that.
const isBotMessage = ASSISTANT_HAS_OWN_NUMBER
? fromMe
: content.startsWith(`${ASSISTANT_NAME}:`);
this.opts.onMessage(chatJid, {
id: msg.key.id || '',
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: fromMe,
is_bot_message: isBotMessage,
});
}
} catch (err) {
logger.error(
{ err, remoteJid: msg.key?.remoteJid },
'Error processing incoming message',
);
}
}
});
}
async sendMessage(jid: string, text: string): Promise<void> {
// Prefix bot messages with assistant name so users know who's speaking.
// On a shared number, prefix is also needed in DMs (including self-chat)
// to distinguish bot output from user messages.
// Skip only when the assistant has its own dedicated phone number.
const prefixed = ASSISTANT_HAS_OWN_NUMBER
? text
: `${ASSISTANT_NAME}: ${text}`;
if (!this.connected) {
this.outgoingQueue.push({ jid, text: prefixed });
logger.info(
{ jid, length: prefixed.length, queueSize: this.outgoingQueue.length },
'WA disconnected, message queued',
);
return;
}
try {
await this.sock.sendMessage(jid, { text: prefixed });
logger.info({ jid, length: prefixed.length }, 'Message sent');
} catch (err) {
// If send fails, queue it for retry on reconnect
this.outgoingQueue.push({ jid, text: prefixed });
logger.warn(
{ jid, err, queueSize: this.outgoingQueue.length },
'Failed to send, message queued',
);
}
}
isConnected(): boolean {
return this.connected;
}
ownsJid(jid: string): boolean {
return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net');
}
async disconnect(): Promise<void> {
this.connected = false;
this.sock?.end(undefined);
}
async setTyping(jid: string, isTyping: boolean): Promise<void> {
try {
const status = isTyping ? 'composing' : 'paused';
logger.debug({ jid, status }, 'Sending presence update');
await this.sock.sendPresenceUpdate(status, jid);
} catch (err) {
logger.debug({ jid, err }, 'Failed to update typing status');
}
}
async syncGroups(force: boolean): Promise<void> {
return this.syncGroupMetadata(force);
}
/**
* Sync group metadata from WhatsApp.
* Fetches all participating groups and stores their names in the database.
* Called on startup, daily, and on-demand via IPC.
*/
async syncGroupMetadata(force = false): Promise<void> {
if (!force) {
const lastSync = getLastGroupSync();
if (lastSync) {
const lastSyncTime = new Date(lastSync).getTime();
if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) {
logger.debug({ lastSync }, 'Skipping group sync - synced recently');
return;
}
}
}
try {
logger.info('Syncing group metadata from WhatsApp...');
const groups = await this.sock.groupFetchAllParticipating();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
updateChatName(jid, metadata.subject);
count++;
}
}
setLastGroupSync();
logger.info({ count }, 'Group metadata synced');
} catch (err) {
logger.error({ err }, 'Failed to sync group metadata');
}
}
private async translateJid(jid: string): Promise<string> {
if (!jid.endsWith('@lid')) return jid;
const lidUser = jid.split('@')[0].split(':')[0];
// Check local cache first
const cached = this.lidToPhoneMap[lidUser];
if (cached) {
logger.debug(
{ lidJid: jid, phoneJid: cached },
'Translated LID to phone JID (cached)',
);
return cached;
}
// Query Baileys' signal repository for the mapping
try {
const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid);
if (pn) {
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
this.lidToPhoneMap[lidUser] = phoneJid;
logger.info(
{ lidJid: jid, phoneJid },
'Translated LID to phone JID (signalRepository)',
);
return phoneJid;
}
} catch (err) {
logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository');
}
return jid;
}
private async flushOutgoingQueue(): Promise<void> {
if (this.flushing || this.outgoingQueue.length === 0) return;
this.flushing = true;
try {
logger.info(
{ count: this.outgoingQueue.length },
'Flushing outgoing message queue',
);
while (this.outgoingQueue.length > 0) {
const item = this.outgoingQueue.shift()!;
// Send directly — queued items are already prefixed by sendMessage
await this.sock.sendMessage(item.jid, { text: item.text });
logger.info(
{ jid: item.jid, length: item.text.length },
'Queued message sent',
);
}
} finally {
this.flushing = false;
}
}
}
registerChannel('whatsapp', (opts: ChannelOpts) => new WhatsAppChannel(opts));

View File

@@ -0,0 +1,29 @@
# Intent: src/channels/whatsapp.ts modifications
## What changed
Added PDF attachment download and path injection. When a WhatsApp message contains a PDF document, it is downloaded to the group's attachments/ directory and the message content is replaced with the file path and a usage hint. Also uses `normalizeMessageContent()` from Baileys to unwrap container types before reading fields.
## Key sections
### Imports (top of file)
- Added: `downloadMediaMessage` from `@whiskeysockets/baileys`
- Added: `normalizeMessageContent` from `@whiskeysockets/baileys`
- Added: `GROUPS_DIR` from `../config.js`
### messages.upsert handler (inside connectInternal)
- Added: `normalizeMessageContent(msg.message)` call to unwrap container types
- Changed: `let content` to allow reassignment for PDF messages
- Added: Check for `normalized.documentMessage?.mimetype === 'application/pdf'`
- Added: Download PDF via `downloadMediaMessage`, save to `groups/{folder}/attachments/`
- Added: Replace content with `[PDF: attachments/{filename} ({size}KB)]` and usage hint
- Note: PDF check is placed BEFORE the `if (!content) continue;` guard so PDF-only messages are not skipped
## Invariants (must-keep)
- All existing message handling (conversation, extendedTextMessage, imageMessage, videoMessage)
- Connection lifecycle (connect, reconnect with exponential backoff, disconnect)
- LID translation logic unchanged
- Outgoing message queue unchanged
- Group metadata sync unchanged
- sendMessage prefix logic unchanged
- setTyping, ownsJid, isConnected — all unchanged
- Local timestamp format (no Z suffix)