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:
49
src/chat.ts
Normal file
49
src/chat.ts
Normal 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
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 }));
|
||||
}
|
||||
18
src/index.ts
18
src/index.ts
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
86
src/tools.ts
86
src/tools.ts
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user