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:
113
src/clients/slack.ts
Normal file
113
src/clients/slack.ts
Normal 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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user