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

49
src/chat.ts Normal file
View File

@@ -0,0 +1,49 @@
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const SYSTEM_PROMPT = `You are a friendly and knowledgeable support assistant for SquareMCP — an AI Social Media Gateway that lets AI agents (Claude, ChatGPT, Codex CLI, etc.) post to social platforms via the Model Context Protocol (MCP).
What SquareMCP does:
- Connects AI coding assistants to social platforms: LinkedIn, TikTok, WhatsApp, Instagram, Twitter/X, Facebook, Telegram, Discord, Slack, and Email
- Works with any MCP-compatible client: Claude Desktop, Claude Code, Cursor, Windsurf, opencode, Codex CLI
- Provides a multi-tenant SaaS platform where each customer securely stores their own platform credentials
- Offers a simple dashboard to connect platforms, view usage, and manage webhooks
- Plans: Free (100 calls/month), Pro, Business
How it works:
1. Customer signs up at the SquareMCP dashboard
2. Connects their social accounts (bot tokens, API keys)
3. Adds the SquareMCP MCP server to their AI client config
4. Their AI agent can now send messages, post content, read analytics — on any connected platform
Common questions:
- Pricing: Free tier available, paid plans for higher usage
- Supported platforms: LinkedIn, TikTok, WhatsApp Business, Instagram, Twitter/X, Facebook, Telegram, Discord, Slack, Email
- No coding required to use the dashboard; coding experience helps to get the most from MCP tool calls
- Webhooks: customers can receive real-time events when messages arrive
- Security: credentials are encrypted at rest, each customer's data is isolated
Keep answers concise (2-4 sentences max). If you don't know something, say so and suggest emailing support@squaremcp.com. Never make up features or pricing. Speak in a warm, direct tone.`;
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
export async function handleChat(messages: ChatMessage[]): Promise<string> {
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error('ANTHROPIC_API_KEY not configured');
}
const response = await client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 512,
system: SYSTEM_PROMPT,
messages: messages.map(m => ({ role: m.role, content: m.content })),
});
const block = response.content[0];
if (block.type !== 'text') throw new Error('Unexpected response type');
return block.text;
}

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 }));
}

View File

@@ -36,6 +36,7 @@ import { getAllPlatformHealth } from './multitenancy/platform-health.js';
import { deliverWebhook, isValidWebhookUrl } from './webhooks/delivery.js';
import { notifyNewPilotRequest } from './notifications/index.js';
import redis from './redis.js';
import { handleChat, type ChatMessage } from './chat.js';
const app = express();
app.use(cookieParser());
@@ -57,6 +58,7 @@ app.use(express.urlencoded({ extended: true }));
// ── Static files (videos, assets) ──────────────────────────────────────────
app.use('/public', express.static('/vaults/public'));
app.use(express.static(new URL('../../product', import.meta.url).pathname));
// ── Config ─────────────────────────────────────────────────────────────────
const PORT = process.env.PORT ?? 3456;
@@ -1978,6 +1980,22 @@ app.post('/api/facebook/video', requireAuth, async (req, res) => {
}
});
// ── Chat widget endpoint ────────────────────────────────────────
app.post('/api/chat', async (req, res) => {
const { messages } = req.body as { messages?: ChatMessage[] };
if (!Array.isArray(messages) || messages.length === 0) {
res.status(400).json({ error: 'messages array required' });
return;
}
try {
const reply = await handleChat(messages);
res.json({ reply });
} catch (err) {
console.error('[chat] error:', (err as Error).message);
res.status(500).json({ error: 'Chat unavailable' });
}
});
// ── TikTok REST endpoints ───────────────────────────────────────
app.get('/api/tiktok/profile', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined;

View File

@@ -23,7 +23,7 @@ function decrypt(ciphertext: string): string {
return decipher.update(encrypted) + decipher.final('utf8');
}
export type Platform = 'email' | 'whatsapp' | 'linkedin' | 'telegram' | 'discord' | 'instagram' | 'twitter' | 'tiktok' | 'snapchat' | 'facebook' | 'obsidian';
export type Platform = 'email' | 'whatsapp' | 'linkedin' | 'telegram' | 'discord' | 'instagram' | 'twitter' | 'tiktok' | 'snapchat' | 'facebook' | 'obsidian' | 'slack';
export interface EmailCredentials {
host: string;

View File

@@ -14,6 +14,7 @@ import { searchTweets, getUserProfile, getUserTweets, createTweet, uploadVideoAn
import { getUserProfile as getTikTokProfile, getCreatorInfo, createVideo, getVideoStatus } from './clients/tiktok.js';
import { getMe as getSnapchatMe, createSnap, getAdAccounts } from './clients/snapchat.js';
import { getPage, getPosts, createPost as createFacebookPost, createPhotoPost, createVideoPost as createFacebookVideoPost } from './clients/facebook.js';
import { getMe as getSlackMe, getChannels as getSlackChannels, sendMessage as sendSlackMessage, getMessages as getSlackMessages } from './clients/slack.js';
const ACCOUNT_PARAM = {
account: {
@@ -443,6 +444,59 @@ export const tools: Tool[] = [
},
},
// ── Slack tools ──────────────────────────────────────────────
{
name: 'slack_get_me',
description:
'Verify the Slack bot is connected and return workspace info. Use to confirm credentials are working.',
inputSchema: {
type: 'object',
properties: {
account: { type: 'string', description: 'Which Slack account to use (default: "default")' },
},
},
},
{
name: 'slack_get_channels',
description:
'List Slack channels the bot has access to. Use to find channel IDs before sending messages.',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Max channels to return (default: 100)' },
account: { type: 'string', description: 'Which Slack account to use (default: "default")' },
},
},
},
{
name: 'slack_send_message',
description:
'Send a message to a Slack channel. Uses the default channel if channel_id is omitted.',
inputSchema: {
type: 'object',
required: ['text'],
properties: {
channel_id: { type: 'string', description: 'Slack channel ID (e.g. C0123456). Uses default channel if omitted.' },
text: { type: 'string', description: 'Message text. Supports Slack mrkdwn formatting.' },
account: { type: 'string', description: 'Which Slack account to use (default: "default")' },
},
},
},
{
name: 'slack_get_messages',
description:
'Get recent messages from a Slack channel.',
inputSchema: {
type: 'object',
required: ['channel_id'],
properties: {
channel_id: { type: 'string', description: 'Slack channel ID' },
limit: { type: 'number', description: 'Max messages to return (default: 10)' },
account: { type: 'string', description: 'Which Slack account to use (default: "default")' },
},
},
},
// ── Instagram tools ──────────────────────────────────────────
{
name: 'instagram_get_profile',
@@ -738,7 +792,7 @@ async function resolveEmailCtx(args: Record<string, unknown>, customer?: Custome
const PLATFORM_PREFIXES = [
'linkedin', 'obsidian', 'whatsapp', 'telegram', 'discord',
'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook',
'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook', 'slack',
];
function toolPlatform(name: string): string {
@@ -984,6 +1038,36 @@ export async function handleToolCall(
}, customer);
break;
// ── Slack ───────────────────────────────────────────────────
case 'slack_get_me':
result = await getSlackMe({
account: args.account as string | undefined,
}, customer);
break;
case 'slack_get_channels':
result = await getSlackChannels({
limit: args.limit as number | undefined,
account: args.account as string | undefined,
}, customer);
break;
case 'slack_send_message':
result = await sendSlackMessage({
channel_id: args.channel_id as string | undefined,
text: args.text as string,
account: args.account as string | undefined,
}, customer);
break;
case 'slack_get_messages':
result = await getSlackMessages({
channel_id: args.channel_id as string,
limit: args.limit as number | undefined,
account: args.account as string | undefined,
}, customer);
break;
// ── Instagram ───────────────────────────────────────────────
case 'instagram_get_profile':
result = await getInstagramProfile({