feat: Slack platform + Claude-powered chat support widget

- Add Slack as customer-facing messaging platform (client, 4 MCP tools, dashboard card)
- Add /api/chat endpoint powered by Claude Haiku with SquareMCP system prompt
- Add embeddable chat-widget.js injected into all 3 sites (docs, app, www)
- Add ANTHROPIC_API_KEY, serve product/ as static files
- Update Platform type to include slack

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-05-15 10:44:24 -04:00
parent 05b4a30759
commit 4bf93d6763
15 changed files with 547 additions and 4 deletions

113
src/clients/slack.ts Normal file
View File

@@ -0,0 +1,113 @@
import type { Customer } from '../billing/middleware.js';
import type { OAuthCredentials } from '../multitenancy/credential-store.js';
import { createToolAudit } from '../multitenancy/audit-log.js';
const SLACK_API_BASE = 'https://slack.com/api';
interface SlackCredentials extends OAuthCredentials {
channelId?: string;
}
function getEnvToken(account: string): string {
const envKey = `SLACK_${account.toUpperCase()}_BOT_TOKEN`;
return process.env[envKey] ?? '';
}
async function resolveCredentials(
args: { account?: string },
customer?: Customer
): Promise<{ token: string; defaultChannelId?: string }> {
if (customer) {
const creds = await customer.getCredential<SlackCredentials>('slack');
if (!creds) throw new Error('Slack not connected for this account');
return { token: creds.accessToken, defaultChannelId: creds.channelId };
}
const token = getEnvToken(args.account ?? 'default');
if (!token) throw new Error('Missing Slack credentials. Set SLACK_{ACCOUNT}_BOT_TOKEN');
return { token };
}
async function slackRequest(token: string, method: string, body?: Record<string, unknown>) {
const res = await fetch(`${SLACK_API_BASE}/${method}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
throw new Error(`Slack HTTP error (${res.status})`);
}
const data = await res.json() as { ok: boolean; error?: string; [key: string]: unknown };
if (!data.ok) {
throw new Error(`Slack API error: ${data.error}`);
}
return data;
}
export async function getMe(
args: { account?: string },
customer?: Customer
): Promise<{ user_id: string; user: string; team: string; team_id: string }> {
const { token } = await resolveCredentials(args, customer);
const data = await slackRequest(token, 'auth.test');
return {
user_id: data.user_id as string,
user: data.user as string,
team: data.team as string,
team_id: data.team_id as string,
};
}
export async function getChannels(
args: { limit?: number; account?: string },
customer?: Customer
): Promise<Array<{ id: string; name: string; is_member: boolean; num_members: number }>> {
const { token } = await resolveCredentials(args, customer);
const data = await slackRequest(token, 'conversations.list', {
limit: args.limit ?? 100,
types: 'public_channel,private_channel',
exclude_archived: true,
});
const channels = data.channels as Array<{ id: string; name: string; is_member: boolean; num_members: number }>;
return channels.map(c => ({ id: c.id, name: c.name, is_member: c.is_member, num_members: c.num_members }));
}
export async function sendMessage(
args: { channel_id?: string; text: string; account?: string },
customer?: Customer
): Promise<{ ts: string; channel: string }> {
const { token, defaultChannelId } = await resolveCredentials(args, customer);
const channel = args.channel_id ?? defaultChannelId;
if (!channel) throw new Error('channel_id is required (or set a default channel when connecting)');
const audit = customer ? createToolAudit(customer.id, 'slack:sendMessage') : null;
const auditArgs = { channel };
try {
const data = await slackRequest(token, 'chat.postMessage', { channel, text: args.text });
if (audit) await audit.success(auditArgs);
return { ts: data.ts as string, channel: data.channel as string };
} catch (err) {
if (audit) await audit.error(auditArgs, String(err));
throw err;
}
}
export async function getMessages(
args: { channel_id: string; limit?: number; account?: string },
customer?: Customer
): Promise<Array<{ ts: string; text: string; user: string }>> {
const { token } = await resolveCredentials(args, customer);
const data = await slackRequest(token, 'conversations.history', {
channel: args.channel_id,
limit: args.limit ?? 10,
});
const messages = data.messages as Array<{ ts: string; text: string; user: string }>;
return messages.map(m => ({ ts: m.ts, text: m.text, user: m.user }));
}