feat: WhatsApp + LinkedIn integrations, SquareMCP rebrand, opencode docs
WhatsApp Business API (Meta Cloud API)
- New client: src/clients/whatsapp.ts
- Tools: whatsapp_send_message, whatsapp_send_template, whatsapp_list_templates
- REST endpoints: POST /api/whatsapp/send, POST /api/whatsapp/template, GET /api/whatsapp/templates
- Multi-account env var pattern: WHATSAPP_{ACCOUNT}_*
LinkedIn API (OpenID Connect)
- New client: src/clients/linkedin.ts
- Tools: linkedin_get_profile, linkedin_create_post, linkedin_search_connections, linkedin_send_message
- REST endpoints: GET /api/linkedin/profile, POST /api/linkedin/post, POST /api/linkedin/search-connections, POST /api/linkedin/message
- Multi-account env var pattern: LINKEDIN_{ACCOUNT}_*
- Uses /v2/userinfo (OpenID Connect) for profile reads
Domain migration
- hermes.fetcherpay.com -> hermes.squaremcp.com
- Updated K8s ingress, TLS cert, SERVER_URL env var
- Updated OPENCODE.md and opencode.json references
SquareMCP site
- Added logo assets (SVG, LinkedIn variants)
- Added terms.html
- Updated Dockerfile, nginx config, styles, index, privacy pages
Docs
- Added OPENCODE.md for opencode AI integration setup
- Updated .env.example with WhatsApp and LinkedIn credentials
- Added opencode.json to .gitignore (contains live API key)
Total tools: 19 (email 6, obsidian 5, whatsapp 4, linkedin 4)
This commit is contained in:
142
src/clients/linkedin.ts
Normal file
142
src/clients/linkedin.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
const LINKEDIN_API_BASE = 'https://api.linkedin.com/v2';
|
||||
|
||||
function getEnvVar(account: string, key: string): string {
|
||||
const envKey = `LINKEDIN_${account.toUpperCase()}_${key}`;
|
||||
return process.env[envKey] ?? '';
|
||||
}
|
||||
|
||||
function getAccessToken(account: string): string {
|
||||
return getEnvVar(account, 'ACCESS_TOKEN');
|
||||
}
|
||||
|
||||
async function linkedinRequest(
|
||||
endpoint: string,
|
||||
accessToken: string,
|
||||
method: 'GET' | 'POST' = 'GET',
|
||||
body?: unknown
|
||||
) {
|
||||
const url = `${LINKEDIN_API_BASE}${endpoint}`;
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Restli-Protocol-Version': '2.0.0',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(`LinkedIn API error (${res.status}): ${error}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getProfile(args: { account?: string }): Promise<{
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
picture?: string;
|
||||
}> {
|
||||
const accessToken = getAccessToken(args.account ?? 'default');
|
||||
if (!accessToken) {
|
||||
throw new Error('Missing LinkedIn credentials. Set LINKEDIN_{ACCOUNT}_ACCESS_TOKEN');
|
||||
}
|
||||
|
||||
// OpenID Connect userinfo endpoint (works with profile scope)
|
||||
const res = await fetch(`${LINKEDIN_API_BASE}/userinfo`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(`LinkedIn API error (${res.status}): ${error}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return {
|
||||
id: data.sub,
|
||||
firstName: data.given_name ?? '',
|
||||
lastName: data.family_name ?? '',
|
||||
email: data.email ?? '',
|
||||
picture: data.picture ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPost(args: {
|
||||
text: string;
|
||||
visibility?: 'PUBLIC' | 'CONNECTIONS';
|
||||
account?: string;
|
||||
}): Promise<{ success: boolean; post_id: string; url: string }> {
|
||||
const accessToken = getAccessToken(args.account ?? 'default');
|
||||
if (!accessToken) {
|
||||
throw new Error('Missing LinkedIn credentials. Set LINKEDIN_{ACCOUNT}_ACCESS_TOKEN');
|
||||
}
|
||||
|
||||
const profile = await getProfile({ account: args.account });
|
||||
const authorUrn = `urn:li:person:${profile.id}`;
|
||||
|
||||
const body = {
|
||||
author: authorUrn,
|
||||
lifecycleState: 'PUBLISHED',
|
||||
specificContent: {
|
||||
'com.linkedin.ugc.ShareContent': {
|
||||
shareCommentary: {
|
||||
text: args.text,
|
||||
},
|
||||
shareMediaCategory: 'NONE',
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
'com.linkedin.ugc.MemberNetworkVisibility': args.visibility ?? 'PUBLIC',
|
||||
},
|
||||
};
|
||||
|
||||
const data = await linkedinRequest('/ugcPosts', accessToken, 'POST', body);
|
||||
const postId = data.id ?? '';
|
||||
return {
|
||||
success: true,
|
||||
post_id: postId,
|
||||
url: postId ? `https://www.linkedin.com/feed/update/${postId}` : '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchConnections(args: {
|
||||
keywords?: string;
|
||||
account?: string;
|
||||
}): Promise<{ message: string }> {
|
||||
const accessToken = getAccessToken(args.account ?? 'default');
|
||||
if (!accessToken) {
|
||||
throw new Error('Missing LinkedIn credentials. Set LINKEDIN_{ACCOUNT}_ACCESS_TOKEN');
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'LinkedIn connections search requires the LinkedIn Partnership Program. ' +
|
||||
'Public API access to connections was removed. ' +
|
||||
'Apply at https://developer.linkedin.com/partner-programs'
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendMessage(args: {
|
||||
recipient_id: string;
|
||||
message: string;
|
||||
account?: string;
|
||||
}): Promise<{ message: string }> {
|
||||
const accessToken = getAccessToken(args.account ?? 'default');
|
||||
if (!accessToken) {
|
||||
throw new Error('Missing LinkedIn credentials. Set LINKEDIN_{ACCOUNT}_ACCESS_TOKEN');
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'LinkedIn messaging requires the LinkedIn Partnership Program. ' +
|
||||
'Direct messaging is not available through the public API. ' +
|
||||
'Apply at https://developer.linkedin.com/partner-programs'
|
||||
);
|
||||
}
|
||||
145
src/clients/whatsapp.ts
Normal file
145
src/clients/whatsapp.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
|
||||
|
||||
const WHATSAPP_API_VERSION = 'v18.0';
|
||||
const WHATSAPP_BASE_URL = process.env['WHATSAPP_API_BASE_URL'] ?? 'https://graph.facebook.com';
|
||||
|
||||
function getEnvVar(prefix: string, account: string, key: string): string {
|
||||
const envKey = `WHATSAPP_${account.toUpperCase()}_${key}`;
|
||||
return process.env[envKey] ?? '';
|
||||
}
|
||||
|
||||
function getPhoneNumberId(account: string): string {
|
||||
return getEnvVar('WHATSAPP', account, 'PHONE_NUMBER_ID');
|
||||
}
|
||||
|
||||
function getAccessToken(account: string): string {
|
||||
return getEnvVar('WHATSAPP', account, 'ACCESS_TOKEN');
|
||||
}
|
||||
|
||||
function getBusinessAccountId(account: string): string {
|
||||
return getEnvVar('WHATSAPP', account, 'BUSINESS_ACCOUNT_ID');
|
||||
}
|
||||
|
||||
interface WhatsAppMessageResponse {
|
||||
messaging_product: string;
|
||||
contacts?: Array<{ wa_id: string; input: string }>;
|
||||
messages?: Array<{ id: string }>;
|
||||
}
|
||||
|
||||
async function whatsappApiRequest(
|
||||
phoneId: string,
|
||||
accessToken: string,
|
||||
endpoint: string,
|
||||
method: 'GET' | 'POST' = 'POST',
|
||||
body?: unknown
|
||||
) {
|
||||
const url = `${WHATSAPP_BASE_URL}/${WHATSAPP_API_VERSION}/${phoneId}/${endpoint}`;
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(`WhatsApp API error (${res.status}): ${error}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function sendMessage(args: { to: string; message: string; account?: string }): Promise<{ success: boolean; message_id: string }> {
|
||||
const phoneId = getPhoneNumberId(args.account ?? 'default');
|
||||
const accessToken = getAccessToken(args.account ?? 'default');
|
||||
|
||||
if (!phoneId || !accessToken) {
|
||||
throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_PHONE_NUMBER_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN');
|
||||
}
|
||||
|
||||
const body = {
|
||||
messaging_product: 'whatsapp',
|
||||
to: args.to,
|
||||
type: 'text',
|
||||
text: { body: args.message },
|
||||
};
|
||||
|
||||
const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body);
|
||||
const response = data as WhatsAppMessageResponse;
|
||||
return { success: true, message_id: response.messages?.[0]?.id ?? '' };
|
||||
}
|
||||
|
||||
export async function sendTemplate(args: { to: string; template_name: string; language?: string; components?: unknown[]; account?: string }): Promise<{ success: boolean; message_id: string }> {
|
||||
const phoneId = getPhoneNumberId(args.account ?? 'default');
|
||||
const accessToken = getAccessToken(args.account ?? 'default');
|
||||
|
||||
if (!phoneId || !accessToken) {
|
||||
throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_PHONE_NUMBER_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN');
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
messaging_product: 'whatsapp',
|
||||
to: args.to,
|
||||
type: 'template',
|
||||
template: {
|
||||
name: args.template_name,
|
||||
language: { code: args.language ?? 'en' },
|
||||
},
|
||||
};
|
||||
|
||||
if (args.components) {
|
||||
(body.template as Record<string, unknown>).components = args.components;
|
||||
}
|
||||
|
||||
const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body);
|
||||
const response = data as WhatsAppMessageResponse;
|
||||
return { success: true, message_id: response.messages?.[0]?.id ?? '' };
|
||||
}
|
||||
|
||||
export async function getMessageStatus(args: { message_id: string; account?: string }): Promise<{ message_id: string; status: string; timestamp?: string }> {
|
||||
const phoneId = getPhoneNumberId(args.account ?? 'default');
|
||||
const accessToken = getAccessToken(args.account ?? 'default');
|
||||
|
||||
if (!phoneId || !accessToken) {
|
||||
throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_PHONE_NUMBER_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN');
|
||||
}
|
||||
|
||||
// Note: Meta Cloud API doesn't support polling message status via GET
|
||||
// Status updates are only available via webhooks (push-based)
|
||||
throw new Error('whatsapp_get_message_status is not supported. Meta Cloud API only provides delivery status via webhooks. Use POST /api/whatsapp/webhook to receive status updates.');
|
||||
}
|
||||
|
||||
export async function listTemplates(args: { account?: string }): Promise<{ templates: Array<{ name: string; language: string; status: string }> }> {
|
||||
const account = args.account ?? 'default';
|
||||
const businessAccountId = getBusinessAccountId(account);
|
||||
const accessToken = getAccessToken(account);
|
||||
|
||||
if (!businessAccountId || !accessToken) {
|
||||
throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_BUSINESS_ACCOUNT_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN');
|
||||
}
|
||||
|
||||
const url = `${WHATSAPP_BASE_URL}/${WHATSAPP_API_VERSION}/${businessAccountId}/message_templates?fields=name,language,status`;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(`WhatsApp API error (${res.status}): ${error}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return {
|
||||
templates: (data.data ?? []).map((t: { name: string; language: string | { code: string }; status: string }) => ({
|
||||
name: t.name,
|
||||
language: typeof t.language === 'string' ? t.language : t.language?.code ?? 'en',
|
||||
status: t.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user