This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
seahaven-slack-bot/lambda/notion-sync/index.ts
Adam Moussa 67266ba57b Fix notion-sync header crash on non-ASCII titles
S3 user-metadata values are sent as HTTP headers, which must be
US-ASCII. Notion page titles routinely contain em dashes and other
non-ASCII characters, causing ERR_INVALID_CHAR and a daily sync
failure. Sanitize the notion-title metadata to printable ASCII.

Also treat a Bedrock ConflictException from StartIngestionJob as
benign (an ingestion job is already running and will pick up the
freshly uploaded files) instead of failing the whole invocation.
2026-06-08 14:58:27 -04:00

181 lines
5.5 KiB
TypeScript

import { Client } from '@notionhq/client';
import { NotionToMarkdown } from 'notion-to-md';
import {
S3Client,
PutObjectCommand,
ListObjectsV2Command,
DeleteObjectsCommand,
} from '@aws-sdk/client-s3';
import {
BedrockAgentClient,
StartIngestionJobCommand,
} from '@aws-sdk/client-bedrock-agent';
import {
SecretsManagerClient,
GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';
const s3 = new S3Client({});
const bedrockAgent = new BedrockAgentClient({ region: process.env.REGION! });
const secrets = new SecretsManagerClient({});
const NOTION_PREFIX = 'notion/';
let cachedApiKey: string | undefined;
async function getApiKey(): Promise<string> {
if (cachedApiKey) return cachedApiKey;
const res = await secrets.send(
new GetSecretValueCommand({ SecretId: process.env.NOTION_SECRET_ARN! }),
);
const parsed = JSON.parse(res.SecretString!);
cachedApiKey = parsed.apiKey;
return cachedApiKey!;
}
function getPageTitle(page: Record<string, any>): string {
// Regular pages store the title in properties.title or properties.Name
const props = page.properties ?? {};
for (const prop of Object.values(props) as any[]) {
if (prop?.type === 'title' && Array.isArray(prop.title) && prop.title[0]?.plain_text) {
return prop.title[0].plain_text;
}
}
return page.id as string;
}
// S3 user-metadata values travel as HTTP headers, which must be US-ASCII with no
// control characters. Notion titles routinely contain em dashes and other
// non-ASCII characters (ERR_INVALID_CHAR otherwise). Strip to safe printable
// ASCII and cap at the 256-char metadata limit.
function sanitizeMetadataValue(value: string): string {
return value
.replace(/[^\x20-\x7E]/g, '') // drop non-printable / non-ASCII
.trim()
.slice(0, 256);
}
async function clearOldFiles(bucket: string): Promise<void> {
let continuationToken: string | undefined;
const toDelete: { Key: string }[] = [];
do {
const res = await s3.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: NOTION_PREFIX,
ContinuationToken: continuationToken,
}),
);
for (const obj of res.Contents ?? []) {
if (obj.Key) toDelete.push({ Key: obj.Key });
}
continuationToken = res.NextContinuationToken;
} while (continuationToken);
if (toDelete.length === 0) return;
// S3 DeleteObjects accepts up to 1000 keys per request
for (let i = 0; i < toDelete.length; i += 1000) {
await s3.send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: toDelete.slice(i, i + 1000) },
}),
);
}
console.log(`Deleted ${toDelete.length} stale Notion file(s) from S3`);
}
async function getAllPages(notion: Client): Promise<Record<string, any>[]> {
const results: Record<string, any>[] = [];
let cursor: string | undefined;
do {
const res = await notion.search({
filter: { value: 'page', property: 'object' },
page_size: 100,
start_cursor: cursor,
});
results.push(...(res.results as Record<string, any>[]));
cursor = res.has_more && res.next_cursor ? res.next_cursor : undefined;
} while (cursor);
return results;
}
export const handler = async (): Promise<void> => {
const apiKey = await getApiKey();
const notion = new Client({ auth: apiKey });
const n2m = new NotionToMarkdown({ notionClient: notion });
const bucket = process.env.KB_BUCKET_NAME!;
const kbId = process.env.KNOWLEDGE_BASE_ID!;
const dsId = process.env.DATA_SOURCE_ID!;
console.log('Clearing stale Notion files from S3...');
await clearOldFiles(bucket);
console.log('Fetching pages from Notion Office Operations teamspace...');
const pages = await getAllPages(notion);
console.log(`Found ${pages.length} page(s)`);
let synced = 0;
let failed = 0;
for (const page of pages) {
const pageId = page.id as string;
const title = getPageTitle(page);
try {
const mdBlocks = await n2m.pageToMarkdown(pageId);
const { parent: mdContent } = n2m.toMarkdownString(mdBlocks);
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: `${NOTION_PREFIX}${pageId}.md`,
Body: `# ${title}\n\n${mdContent}`,
ContentType: 'text/markdown',
Metadata: {
'notion-page-id': pageId,
'notion-title': sanitizeMetadataValue(title),
},
}),
);
console.log(`Synced: "${title}" (${pageId})`);
synced++;
} catch (err) {
console.error(`Failed to sync page "${title}" (${pageId}):`, err);
failed++;
}
}
console.log(`Upload complete. ${synced} synced, ${failed} failed.`);
console.log('Triggering Bedrock KB ingestion job...');
try {
const ingestionRes = await bedrockAgent.send(
new StartIngestionJobCommand({
knowledgeBaseId: kbId,
dataSourceId: dsId,
}),
);
console.log(
`Ingestion job started: ${ingestionRes.ingestionJob?.ingestionJobId}`,
);
} catch (err) {
// A ConflictException means an ingestion job is already running for this data
// source (e.g. an overlapping run). The freshly uploaded files will be picked
// up by that in-flight job, so this is benign — log and exit cleanly rather
// than failing the whole invocation.
if (err instanceof Error && err.name === 'ConflictException') {
console.log(
'Ingestion job already in progress; uploaded files will be picked up by the running job. Skipping.',
);
return;
}
throw err;
}
};