2026-04-11 23:15:15 -04:00
|
|
|
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
|
|
|
|
|
import {
|
|
|
|
|
BedrockAgentRuntimeClient,
|
|
|
|
|
InvokeAgentCommand,
|
|
|
|
|
} from '@aws-sdk/client-bedrock-agent-runtime';
|
|
|
|
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
|
|
|
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
|
|
|
|
|
|
|
|
|
|
const secretsClient = new SecretsManagerClient({});
|
|
|
|
|
const bedrockClient = new BedrockAgentRuntimeClient({ region: process.env.REGION! });
|
|
|
|
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
|
|
|
|
|
|
|
|
|
|
interface SlackCredentials {
|
|
|
|
|
botToken: string;
|
|
|
|
|
signingSecret: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ProcessorEvent {
|
|
|
|
|
userId: string;
|
|
|
|
|
channelId: string;
|
|
|
|
|
text: string;
|
|
|
|
|
ts: string;
|
|
|
|
|
threadTs?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cache the secret across warm invocations
|
|
|
|
|
let cachedCredentials: SlackCredentials | undefined;
|
|
|
|
|
|
|
|
|
|
async function getSlackCredentials(): Promise<SlackCredentials> {
|
|
|
|
|
if (!cachedCredentials) {
|
|
|
|
|
const res = await secretsClient.send(
|
|
|
|
|
new GetSecretValueCommand({ SecretId: process.env.SLACK_SECRET_ARN! }),
|
|
|
|
|
);
|
|
|
|
|
cachedCredentials = JSON.parse(res.SecretString!) as SlackCredentials;
|
|
|
|
|
}
|
|
|
|
|
return cachedCredentials;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function postToSlack(
|
|
|
|
|
token: string,
|
|
|
|
|
channel: string,
|
|
|
|
|
text: string,
|
|
|
|
|
threadTs?: string,
|
2026-04-12 00:01:33 -04:00
|
|
|
): Promise<string> {
|
2026-04-11 23:15:15 -04:00
|
|
|
const body: Record<string, string> = { channel, text };
|
|
|
|
|
if (threadTs) body.thread_ts = threadTs;
|
|
|
|
|
|
|
|
|
|
const res = await fetch('https://slack.com/api/chat.postMessage', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Authorization: `Bearer ${token}`,
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) throw new Error(`Slack HTTP error ${res.status}`);
|
2026-04-12 00:01:33 -04:00
|
|
|
const data = (await res.json()) as { ok: boolean; ts?: string; error?: string };
|
2026-04-11 23:15:15 -04:00
|
|
|
if (!data.ok) throw new Error(`Slack API error: ${data.error}`);
|
2026-04-12 00:01:33 -04:00
|
|
|
return data.ts!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function updateSlack(
|
|
|
|
|
token: string,
|
|
|
|
|
channel: string,
|
|
|
|
|
ts: string,
|
|
|
|
|
text: string,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const res = await fetch('https://slack.com/api/chat.update', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Authorization: `Bearer ${token}`,
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ channel, ts, text }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) throw new Error(`Slack HTTP error ${res.status}`);
|
|
|
|
|
const data = (await res.json()) as { ok: boolean; error?: string };
|
|
|
|
|
if (!data.ok) throw new Error(`Slack update error: ${data.error}`);
|
2026-04-11 23:15:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reuse the most recent Bedrock session if there's been activity in the last 30 minutes.
|
|
|
|
|
// This enables multi-turn conversation without re-introducing context each message.
|
|
|
|
|
async function resolveSessionId(userId: string): Promise<string> {
|
|
|
|
|
const cutoff = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
|
|
|
|
|
|
|
|
|
const result = await ddb.send(
|
|
|
|
|
new QueryCommand({
|
|
|
|
|
TableName: process.env.CONVERSATION_TABLE!,
|
|
|
|
|
KeyConditionExpression: 'userId = :uid AND #ts > :cutoff',
|
|
|
|
|
ExpressionAttributeNames: { '#ts': 'timestamp' },
|
|
|
|
|
ExpressionAttributeValues: { ':uid': userId, ':cutoff': cutoff },
|
|
|
|
|
Limit: 1,
|
|
|
|
|
ScanIndexForward: false,
|
|
|
|
|
ProjectionExpression: 'sessionId',
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (result.Items?.length && result.Items[0].sessionId) {
|
|
|
|
|
return result.Items[0].sessionId as string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `${userId}-${Date.now()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function invokeAgent(sessionId: string, inputText: string): Promise<string> {
|
|
|
|
|
const res = await bedrockClient.send(
|
|
|
|
|
new InvokeAgentCommand({
|
|
|
|
|
agentId: process.env.AGENT_ID!,
|
|
|
|
|
agentAliasId: process.env.AGENT_ALIAS_ID!,
|
|
|
|
|
sessionId,
|
|
|
|
|
inputText,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let answer = '';
|
|
|
|
|
if (res.completion) {
|
|
|
|
|
for await (const event of res.completion) {
|
|
|
|
|
if (event.chunk?.bytes) {
|
|
|
|
|
answer += Buffer.from(event.chunk.bytes).toString('utf-8');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return answer.trim() || 'I was unable to generate a response. Please try again.';
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:34:47 -04:00
|
|
|
/** Convert Markdown from Bedrock Agent output to Slack mrkdwn. */
|
|
|
|
|
function toSlackMrkdwn(md: string): string {
|
|
|
|
|
return md
|
|
|
|
|
// Headers → bold text
|
|
|
|
|
.replace(/^#{1,3}\s+(.+)$/gm, '*$1*')
|
|
|
|
|
// Bold: **text** or __text__ → *text*
|
|
|
|
|
.replace(/\*\*(.+?)\*\*/g, '*$1*')
|
|
|
|
|
.replace(/__(.+?)__/g, '*$1*')
|
|
|
|
|
// Italic: _text_ is the same in Slack, leave as-is
|
|
|
|
|
// Inline code and code blocks are the same in Slack, leave as-is
|
|
|
|
|
// Links: [text](url) → <url|text>
|
|
|
|
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>')
|
|
|
|
|
// Collapse 3+ consecutive newlines to 2
|
|
|
|
|
.replace(/\n{3,}/g, '\n\n');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 23:15:15 -04:00
|
|
|
export const handler = async (event: ProcessorEvent): Promise<void> => {
|
|
|
|
|
const { userId, channelId, text, ts, threadTs } = event;
|
|
|
|
|
|
|
|
|
|
const [credentials, sessionId] = await Promise.all([
|
|
|
|
|
getSlackCredentials(),
|
|
|
|
|
resolveSessionId(userId),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-12 00:01:33 -04:00
|
|
|
// Post placeholder immediately so the user sees a response while the agent thinks
|
|
|
|
|
const placeholderTs = await postToSlack(
|
|
|
|
|
credentials.botToken,
|
|
|
|
|
channelId,
|
|
|
|
|
'_Sea Haven Assistant is thinking..._',
|
|
|
|
|
threadTs ?? ts,
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-11 23:15:15 -04:00
|
|
|
let answer: string;
|
|
|
|
|
try {
|
|
|
|
|
answer = await invokeAgent(sessionId, text);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Bedrock agent invocation failed:', err);
|
|
|
|
|
answer = 'Sorry, I encountered an error processing your request. Please try again in a moment.';
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:34:47 -04:00
|
|
|
await updateSlack(credentials.botToken, channelId, placeholderTs, toSlackMrkdwn(answer));
|
2026-04-11 23:15:15 -04:00
|
|
|
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
const ttl = Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60; // 90-day TTL
|
|
|
|
|
|
|
|
|
|
await ddb.send(
|
|
|
|
|
new PutCommand({
|
|
|
|
|
TableName: process.env.CONVERSATION_TABLE!,
|
|
|
|
|
Item: {
|
|
|
|
|
userId,
|
|
|
|
|
timestamp: now,
|
|
|
|
|
sessionId,
|
|
|
|
|
question: text,
|
|
|
|
|
answer,
|
|
|
|
|
channelId,
|
|
|
|
|
messageTs: ts,
|
|
|
|
|
ttl,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
};
|