feat: multi-tenant credential isolation + architecture docs

- Add src/multitenancy/ with AES-256-GCM credential store, WhatsApp
  webhook router (phone_number_id -> customerId), and per-customer
  audit log (90-day Redis TTL)
- Add src/billing/ with plan definitions and meterMiddleware that
  resolves API key -> Customer object with getCredential() closure
- Refactor all src/clients/* to accept optional customer param,
  falling back to env vars for backward compat with single-user mode
- Thread customer through handleToolCall(name, args, customer?)
- Add customers table to MySQL schema initDatabase()
- Add /webhook/whatsapp (immediate 200 + async routing) and
  /api/connect/* onboarding endpoints to index.ts
- Add Redis 7 to docker-compose.yml; add REDIS_URL and
  CREDENTIAL_ENCRYPTION_KEY to hermes-k8s.yaml
- Add product/incubation/ with architecture write-up and PlantUML
  diagrams (system architecture + 5 user flows)
- Extend OpenAPI spec in manifest.ts with all platform endpoints

Verification: 3 isolation tests (credential, webhook routing, audit
log) passed against live Redis. Deployed to hermes.squaremcp.com.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-05-08 11:27:29 -04:00
parent 59501f11f1
commit 8d62e4d9d5
21 changed files with 1863 additions and 346 deletions

View File

@@ -1,10 +1,25 @@
import type { Customer } from '../billing/middleware.js';
import type { OAuthCredentials } from '../multitenancy/credential-store.js';
import { createToolAudit } from '../multitenancy/audit-log.js';
const DISCORD_API_BASE = 'https://discord.com/api/v10';
function getToken(account: string): string {
function getEnvToken(account: string): string {
const envKey = `DISCORD_${account.toUpperCase()}_BOT_TOKEN`;
return process.env[envKey] ?? '';
}
async function resolveToken(args: { account?: string }, customer?: Customer): Promise<string> {
if (customer) {
const creds = await customer.getCredential<OAuthCredentials>('discord');
if (!creds) throw new Error('Discord not connected for this account');
return creds.accessToken;
}
const token = getEnvToken(args.account ?? 'default');
if (!token) throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN');
return token;
}
async function discordRequest(
token: string,
endpoint: string,
@@ -30,72 +45,60 @@ async function discordRequest(
return res.json();
}
export async function getMe(args: { account?: string }): Promise<{
id: string;
username: string;
bot: boolean;
}> {
const token = getToken(args.account ?? 'default');
if (!token) {
throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN');
}
export async function getMe(
args: { account?: string },
customer?: Customer
): Promise<{ id: string; username: string; bot: boolean }> {
const token = await resolveToken(args, customer);
return discordRequest(token, '/users/@me');
}
export async function getGuilds(args: { account?: string }): Promise<Array<{
id: string;
name: string;
icon?: string;
}>> {
const token = getToken(args.account ?? 'default');
if (!token) {
throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN');
}
export async function getGuilds(
args: { account?: string },
customer?: Customer
): Promise<Array<{ id: string; name: string; icon?: string }>> {
const token = await resolveToken(args, customer);
return discordRequest(token, '/users/@me/guilds');
}
export async function getChannels(args: { guild_id: string; account?: string }): Promise<Array<{
id: string;
name: string;
type: number;
}>> {
const token = getToken(args.account ?? 'default');
if (!token) {
throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN');
}
export async function getChannels(
args: { guild_id: string; account?: string },
customer?: Customer
): Promise<Array<{ id: string; name: string; type: number }>> {
const token = await resolveToken(args, customer);
return discordRequest(token, `/guilds/${args.guild_id}/channels`);
}
export async function sendMessage(args: {
channel_id: string;
content: string;
account?: string;
}): Promise<{ id: string; channel_id: string }> {
const token = getToken(args.account ?? 'default');
if (!token) {
throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN');
}
export async function sendMessage(
args: { channel_id: string; content: string; account?: string },
customer?: Customer
): Promise<{ id: string; channel_id: string }> {
const audit = customer ? createToolAudit(customer.id, 'discord:sendMessage') : null;
const auditArgs = { channel_id: args.channel_id };
const token = await resolveToken(args, customer);
return discordRequest(token, `/channels/${args.channel_id}/messages`, 'POST', {
content: args.content,
});
try {
const result = await discordRequest(token, `/channels/${args.channel_id}/messages`, 'POST', {
content: args.content,
});
if (audit) await audit.success(auditArgs);
return result;
} catch (err) {
if (audit) await audit.error(auditArgs, String(err));
throw err;
}
}
export async function getMessages(args: {
channel_id: string;
limit?: number;
account?: string;
}): Promise<Array<{
export async function getMessages(
args: { channel_id: string; limit?: number; account?: string },
customer?: Customer
): Promise<Array<{
id: string;
content: string;
author: { username: string; id: string };
timestamp: string;
}>> {
const token = getToken(args.account ?? 'default');
if (!token) {
throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN');
}
const token = await resolveToken(args, customer);
const limit = args.limit ?? 10;
return discordRequest(token, `/channels/${args.channel_id}/messages?limit=${limit}`);
}

View File

@@ -1,15 +1,41 @@
import type { Customer } from '../billing/middleware.js';
import type { OAuthCredentials } from '../multitenancy/credential-store.js';
import { createToolAudit } from '../multitenancy/audit-log.js';
const INSTAGRAM_API_BASE = 'https://graph.facebook.com/v18.0';
function getAccessToken(account: string): string {
interface InstagramCredentials extends OAuthCredentials {
businessAccountId: string;
}
function getEnvToken(account: string): string {
const envKey = `INSTAGRAM_${account.toUpperCase()}_ACCESS_TOKEN`;
return process.env[envKey] ?? '';
}
function getBusinessAccountId(account: string): string {
function getEnvBusinessId(account: string): string {
const envKey = `INSTAGRAM_${account.toUpperCase()}_BUSINESS_ACCOUNT_ID`;
return process.env[envKey] ?? '';
}
async function resolveCreds(
args: { account?: string },
customer?: Customer
): Promise<{ accessToken: string; businessAccountId: string }> {
if (customer) {
const creds = await customer.getCredential<InstagramCredentials>('instagram');
if (!creds) throw new Error('Instagram not connected for this account');
return { accessToken: creds.accessToken, businessAccountId: creds.businessAccountId };
}
const account = args.account ?? 'default';
const accessToken = getEnvToken(account);
const businessAccountId = getEnvBusinessId(account);
if (!accessToken || !businessAccountId) {
throw new Error('Missing Instagram credentials. Set INSTAGRAM_{ACCOUNT}_ACCESS_TOKEN and INSTAGRAM_{ACCOUNT}_BUSINESS_ACCOUNT_ID');
}
return { accessToken, businessAccountId };
}
async function instagramRequest(
endpoint: string,
accessToken: string,
@@ -34,7 +60,10 @@ async function instagramRequest(
return res.json();
}
export async function getProfile(args: { account?: string }): Promise<{
export async function getProfile(
args: { account?: string },
customer?: Customer
): Promise<{
id: string;
username: string;
name: string;
@@ -42,12 +71,7 @@ export async function getProfile(args: { account?: string }): Promise<{
follows_count: number;
media_count: number;
}> {
const accessToken = getAccessToken(args.account ?? 'default');
const businessAccountId = getBusinessAccountId(args.account ?? 'default');
if (!accessToken || !businessAccountId) {
throw new Error('Missing Instagram credentials. Set INSTAGRAM_{ACCOUNT}_ACCESS_TOKEN and INSTAGRAM_{ACCOUNT}_BUSINESS_ACCOUNT_ID');
}
const { accessToken, businessAccountId } = await resolveCreds(args, customer);
const data = await instagramRequest(
`/${businessAccountId}?fields=username,name,followers_count,follows_count,media_count`,
@@ -64,7 +88,10 @@ export async function getProfile(args: { account?: string }): Promise<{
};
}
export async function getMedia(args: { limit?: number; account?: string }): Promise<Array<{
export async function getMedia(
args: { limit?: number; account?: string },
customer?: Customer
): Promise<Array<{
id: string;
caption?: string;
media_type: string;
@@ -72,13 +99,7 @@ export async function getMedia(args: { limit?: number; account?: string }): Prom
permalink?: string;
timestamp?: string;
}>> {
const accessToken = getAccessToken(args.account ?? 'default');
const businessAccountId = getBusinessAccountId(args.account ?? 'default');
if (!accessToken || !businessAccountId) {
throw new Error('Missing Instagram credentials. Set INSTAGRAM_{ACCOUNT}_ACCESS_TOKEN and INSTAGRAM_{ACCOUNT}_BUSINESS_ACCOUNT_ID');
}
const { accessToken, businessAccountId } = await resolveCreds(args, customer);
const limit = args.limit ?? 10;
const data = await instagramRequest(
`/${businessAccountId}/media?fields=id,caption,media_type,media_url,permalink,timestamp&limit=${limit}`,
@@ -95,45 +116,37 @@ export async function getMedia(args: { limit?: number; account?: string }): Prom
}));
}
export async function createPost(args: {
image_url: string;
caption?: string;
account?: string;
}): Promise<{ success: boolean; media_id: string }> {
const accessToken = getAccessToken(args.account ?? 'default');
const businessAccountId = getBusinessAccountId(args.account ?? 'default');
export async function createPost(
args: { image_url: string; caption?: string; account?: string },
customer?: Customer
): Promise<{ success: boolean; media_id: string }> {
const audit = customer ? createToolAudit(customer.id, 'instagram:createPost') : null;
const auditArgs = { image_url: args.image_url };
const { accessToken, businessAccountId } = await resolveCreds(args, customer);
if (!accessToken || !businessAccountId) {
throw new Error('Missing Instagram credentials. Set INSTAGRAM_{ACCOUNT}_ACCESS_TOKEN and INSTAGRAM_{ACCOUNT}_BUSINESS_ACCOUNT_ID');
try {
const container = await instagramRequest(
`/${businessAccountId}/media`,
accessToken,
'POST',
{ image_url: args.image_url, caption: args.caption, media_type: 'REELS' }
);
const creationId = container.id;
if (!creationId) throw new Error('Failed to create Instagram media container');
const publish = await instagramRequest(
`/${businessAccountId}/media_publish`,
accessToken,
'POST',
{ creation_id: creationId }
);
const result = { success: true, media_id: publish.id };
if (audit) await audit.success(auditArgs);
return result;
} catch (err) {
if (audit) await audit.error(auditArgs, String(err));
throw err;
}
// Step 1: Create media container
const container = await instagramRequest(
`/${businessAccountId}/media`,
accessToken,
'POST',
{
image_url: args.image_url,
caption: args.caption,
media_type: 'REELS',
}
);
const creationId = container.id;
if (!creationId) {
throw new Error('Failed to create Instagram media container');
}
// Step 2: Publish the container
const publish = await instagramRequest(
`/${businessAccountId}/media_publish`,
accessToken,
'POST',
{ creation_id: creationId }
);
return {
success: true,
media_id: publish.id,
};
}

View File

@@ -1,12 +1,23 @@
import type { Customer } from '../billing/middleware.js';
import type { OAuthCredentials } from '../multitenancy/credential-store.js';
import { createToolAudit } from '../multitenancy/audit-log.js';
const LINKEDIN_API_BASE = 'https://api.linkedin.com/v2';
function getEnvVar(account: string, key: string): string {
const envKey = `LINKEDIN_${account.toUpperCase()}_${key}`;
function getEnvToken(account: string): string {
const envKey = `LINKEDIN_${account.toUpperCase()}_ACCESS_TOKEN`;
return process.env[envKey] ?? '';
}
function getAccessToken(account: string): string {
return getEnvVar(account, 'ACCESS_TOKEN');
async function resolveToken(args: { account?: string }, customer?: Customer): Promise<string> {
if (customer) {
const creds = await customer.getCredential<OAuthCredentials>('linkedin');
if (!creds) throw new Error('LinkedIn not connected for this account');
return creds.accessToken;
}
const token = getEnvToken(args.account ?? 'default');
if (!token) throw new Error('Missing LinkedIn credentials. Set LINKEDIN_{ACCOUNT}_ACCESS_TOKEN');
return token;
}
async function linkedinRequest(
@@ -35,23 +46,20 @@ async function linkedinRequest(
return res.json();
}
export async function getProfile(args: { account?: string }): Promise<{
export async function getProfile(
args: { account?: string },
customer?: Customer
): 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');
}
const accessToken = await resolveToken(args, customer);
// OpenID Connect userinfo endpoint (works with profile scope)
const res = await fetch(`${LINKEDIN_API_BASE}/userinfo`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
headers: { 'Authorization': `Bearer ${accessToken}` },
signal: AbortSignal.timeout(15000),
});
@@ -70,17 +78,15 @@ export async function getProfile(args: { account?: string }): Promise<{
};
}
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');
}
export async function createPost(
args: { text: string; visibility?: 'PUBLIC' | 'CONNECTIONS'; account?: string },
customer?: Customer
): Promise<{ success: boolean; post_id: string; url: string }> {
const audit = customer ? createToolAudit(customer.id, 'linkedin:createPost') : null;
const auditArgs = { text: args.text.slice(0, 100) };
const accessToken = await resolveToken(args, customer);
const profile = await getProfile({ account: args.account });
const profile = await getProfile(args, customer);
const authorUrn = `urn:li:person:${profile.id}`;
const body = {
@@ -88,9 +94,7 @@ export async function createPost(args: {
lifecycleState: 'PUBLISHED',
specificContent: {
'com.linkedin.ugc.ShareContent': {
shareCommentary: {
text: args.text,
},
shareCommentary: { text: args.text },
shareMediaCategory: 'NONE',
},
},
@@ -99,24 +103,26 @@ export async function createPost(args: {
},
};
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}` : '',
};
try {
const data = await linkedinRequest('/ugcPosts', accessToken, 'POST', body);
const postId = data.id ?? '';
const result = {
success: true,
post_id: postId,
url: postId ? `https://www.linkedin.com/feed/update/${postId}` : '',
};
if (audit) await audit.success(auditArgs);
return result;
} catch (err) {
if (audit) await audit.error(auditArgs, String(err));
throw err;
}
}
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');
}
export async function searchConnections(
args: { keywords?: string; account?: string },
_customer?: Customer
): Promise<{ message: string }> {
throw new Error(
'LinkedIn connections search requires the LinkedIn Partnership Program. ' +
'Public API access to connections was removed. ' +
@@ -124,16 +130,10 @@ export async function searchConnections(args: {
);
}
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');
}
export async function sendMessage(
args: { recipient_id: string; message: string; account?: string },
_customer?: Customer
): Promise<{ message: string }> {
throw new Error(
'LinkedIn messaging requires the LinkedIn Partnership Program. ' +
'Direct messaging is not available through the public API. ' +

View File

@@ -1,10 +1,25 @@
import type { Customer } from '../billing/middleware.js';
import type { OAuthCredentials } from '../multitenancy/credential-store.js';
import { createToolAudit } from '../multitenancy/audit-log.js';
const TELEGRAM_API_BASE = 'https://api.telegram.org';
function getToken(account: string): string {
function getEnvToken(account: string): string {
const envKey = `TELEGRAM_${account.toUpperCase()}_BOT_TOKEN`;
return process.env[envKey] ?? '';
}
async function resolveToken(args: { account?: string }, customer?: Customer): Promise<string> {
if (customer) {
const creds = await customer.getCredential<OAuthCredentials>('telegram');
if (!creds) throw new Error('Telegram not connected for this account');
return creds.accessToken;
}
const token = getEnvToken(args.account ?? 'default');
if (!token) throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN');
return token;
}
async function telegramRequest(
token: string,
method: string,
@@ -25,69 +40,69 @@ async function telegramRequest(
return data.result;
}
export async function getMe(args: { account?: string }): Promise<{
export async function getMe(
args: { account?: string },
customer?: Customer
): Promise<{
id: number;
first_name: string;
username: string;
is_bot: boolean;
}> {
const token = getToken(args.account ?? 'default');
if (!token) {
throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN');
}
const token = await resolveToken(args, customer);
return telegramRequest(token, 'getMe');
}
export async function sendMessage(args: {
chat_id: string | number;
text: string;
parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2';
account?: string;
}): Promise<{ message_id: number; chat_id: string | number }> {
const token = getToken(args.account ?? 'default');
if (!token) {
throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN');
export async function sendMessage(
args: { chat_id: string | number; text: string; parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2'; account?: string },
customer?: Customer
): Promise<{ message_id: number; chat_id: string | number }> {
const audit = customer ? createToolAudit(customer.id, 'telegram:sendMessage') : null;
const auditArgs = { chat_id: args.chat_id };
const token = await resolveToken(args, customer);
try {
const result = await telegramRequest(token, 'sendMessage', {
chat_id: args.chat_id,
text: args.text,
parse_mode: args.parse_mode,
});
const r = { message_id: result.message_id, chat_id: result.chat.id };
if (audit) await audit.success(auditArgs);
return r;
} catch (err) {
if (audit) await audit.error(auditArgs, String(err));
throw err;
}
const result = await telegramRequest(token, 'sendMessage', {
chat_id: args.chat_id,
text: args.text,
parse_mode: args.parse_mode,
});
return {
message_id: result.message_id,
chat_id: result.chat.id,
};
}
export async function sendPhoto(args: {
chat_id: string | number;
photo: string;
caption?: string;
account?: string;
}): Promise<{ message_id: number; chat_id: string | number }> {
const token = getToken(args.account ?? 'default');
if (!token) {
throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN');
export async function sendPhoto(
args: { chat_id: string | number; photo: string; caption?: string; account?: string },
customer?: Customer
): Promise<{ message_id: number; chat_id: string | number }> {
const audit = customer ? createToolAudit(customer.id, 'telegram:sendPhoto') : null;
const auditArgs = { chat_id: args.chat_id };
const token = await resolveToken(args, customer);
try {
const result = await telegramRequest(token, 'sendPhoto', {
chat_id: args.chat_id,
photo: args.photo,
caption: args.caption,
});
const r = { message_id: result.message_id, chat_id: result.chat.id };
if (audit) await audit.success(auditArgs);
return r;
} catch (err) {
if (audit) await audit.error(auditArgs, String(err));
throw err;
}
const result = await telegramRequest(token, 'sendPhoto', {
chat_id: args.chat_id,
photo: args.photo,
caption: args.caption,
});
return {
message_id: result.message_id,
chat_id: result.chat.id,
};
}
export async function getUpdates(args: {
limit?: number;
account?: string;
}): Promise<Array<{
export async function getUpdates(
args: { limit?: number; account?: string },
customer?: Customer
): Promise<Array<{
update_id: number;
message?: {
message_id: number;
@@ -97,20 +112,14 @@ export async function getUpdates(args: {
text?: string;
};
}>> {
const token = getToken(args.account ?? 'default');
if (!token) {
throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN');
}
return telegramRequest(token, 'getUpdates', {
limit: args.limit ?? 10,
});
const token = await resolveToken(args, customer);
return telegramRequest(token, 'getUpdates', { limit: args.limit ?? 10 });
}
export async function getChat(args: {
chat_id: string | number;
account?: string;
}): Promise<{
export async function getChat(
args: { chat_id: string | number; account?: string },
customer?: Customer
): Promise<{
id: number;
type: string;
title?: string;
@@ -118,12 +127,6 @@ export async function getChat(args: {
description?: string;
member_count?: number;
}> {
const token = getToken(args.account ?? 'default');
if (!token) {
throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN');
}
return telegramRequest(token, 'getChat', {
chat_id: args.chat_id,
});
const token = await resolveToken(args, customer);
return telegramRequest(token, 'getChat', { chat_id: args.chat_id });
}

View File

@@ -1,10 +1,24 @@
import type { Customer } from '../billing/middleware.js';
import type { OAuthCredentials } from '../multitenancy/credential-store.js';
const TWITTER_API_BASE = 'https://api.twitter.com/2';
function getBearerToken(account: string): string {
function getEnvToken(account: string): string {
const envKey = `TWITTER_${account.toUpperCase()}_BEARER_TOKEN`;
return process.env[envKey] ?? '';
}
async function resolveToken(args: { account?: string }, customer?: Customer): Promise<string> {
if (customer) {
const creds = await customer.getCredential<OAuthCredentials>('twitter');
if (!creds) throw new Error('Twitter not connected for this account');
return creds.accessToken;
}
const token = getEnvToken(args.account ?? 'default');
if (!token) throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN');
return token;
}
async function twitterRequest(
endpoint: string,
bearerToken: string,
@@ -18,9 +32,7 @@ async function twitterRequest(
}
const res = await fetch(url.toString(), {
headers: {
'Authorization': `Bearer ${bearerToken}`,
},
headers: { 'Authorization': `Bearer ${bearerToken}` },
signal: AbortSignal.timeout(15000),
});
@@ -32,20 +44,11 @@ async function twitterRequest(
return res.json();
}
export async function searchTweets(args: {
query: string;
max_results?: number;
account?: string;
}): Promise<Array<{
id: string;
text: string;
author_id?: string;
created_at?: string;
}>> {
const bearerToken = getBearerToken(args.account ?? 'default');
if (!bearerToken) {
throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN');
}
export async function searchTweets(
args: { query: string; max_results?: number; account?: string },
customer?: Customer
): Promise<Array<{ id: string; text: string; author_id?: string; created_at?: string }>> {
const bearerToken = await resolveToken(args, customer);
const data = await twitterRequest('/tweets/search/recent', bearerToken, {
query: args.query,
@@ -56,10 +59,10 @@ export async function searchTweets(args: {
return (data.data ?? []) as Array<{ id: string; text: string; author_id?: string; created_at?: string }>;
}
export async function getUserProfile(args: {
username: string;
account?: string;
}): Promise<{
export async function getUserProfile(
args: { username: string; account?: string },
customer?: Customer
): Promise<{
id: string;
name: string;
username: string;
@@ -68,10 +71,7 @@ export async function getUserProfile(args: {
following_count?: number;
tweet_count?: number;
}> {
const bearerToken = getBearerToken(args.account ?? 'default');
if (!bearerToken) {
throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN');
}
const bearerToken = await resolveToken(args, customer);
const data = await twitterRequest(`/users/by/username/${args.username}`, bearerToken, {
'user.fields': 'description,public_metrics',
@@ -90,26 +90,15 @@ export async function getUserProfile(args: {
};
}
export async function getUserTweets(args: {
username: string;
max_results?: number;
account?: string;
}): Promise<Array<{
id: string;
text: string;
created_at?: string;
}>> {
const bearerToken = getBearerToken(args.account ?? 'default');
if (!bearerToken) {
throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN');
}
export async function getUserTweets(
args: { username: string; max_results?: number; account?: string },
customer?: Customer
): Promise<Array<{ id: string; text: string; created_at?: string }>> {
const bearerToken = await resolveToken(args, customer);
// First get user ID
const userData = await twitterRequest(`/users/by/username/${args.username}`, bearerToken);
const userId = userData.data?.id;
if (!userId) {
throw new Error(`User @${args.username} not found`);
}
if (!userId) throw new Error(`User @${args.username} not found`);
const data = await twitterRequest(`/users/${userId}/tweets`, bearerToken, {
max_results: String(Math.min(args.max_results ?? 10, 100)),
@@ -119,15 +108,10 @@ export async function getUserTweets(args: {
return (data.data ?? []) as Array<{ id: string; text: string; created_at?: string }>;
}
export async function createTweet(args: {
text: string;
account?: string;
}): Promise<{ message: string }> {
const bearerToken = getBearerToken(args.account ?? 'default');
if (!bearerToken) {
throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN');
}
export async function createTweet(
args: { text: string; account?: string },
_customer?: Customer
): Promise<{ message: string }> {
throw new Error(
'Twitter/X posting requires a paid API tier. ' +
'The free tier is read-only (500 tweets/month). ' +

View File

@@ -1,23 +1,25 @@
import type { Customer } from '../billing/middleware.js';
import type { WhatsAppCredentials } from '../multitenancy/credential-store.js';
import { createToolAudit } from '../multitenancy/audit-log.js';
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 {
function getEnvVar(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');
return getEnvVar(account, 'PHONE_NUMBER_ID');
}
function getAccessToken(account: string): string {
return getEnvVar('WHATSAPP', account, 'ACCESS_TOKEN');
return getEnvVar(account, 'ACCESS_TOKEN');
}
function getBusinessAccountId(account: string): string {
return getEnvVar('WHATSAPP', account, 'BUSINESS_ACCOUNT_ID');
return getEnvVar(account, 'BUSINESS_ACCOUNT_ID');
}
interface WhatsAppMessageResponse {
@@ -52,13 +54,37 @@ async function whatsappApiRequest(
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');
async function resolveWhatsAppCreds(
args: { account?: string },
customer?: Customer
): Promise<{ phoneId: string; accessToken: string; businessAccountId: string }> {
if (customer) {
const creds = await customer.getCredential<WhatsAppCredentials>('whatsapp');
if (!creds) throw new Error('WhatsApp not connected for this account');
return {
phoneId: creds.phoneNumberId,
accessToken: creds.accessToken,
businessAccountId: creds.businessAccountId,
};
}
const account = args.account ?? 'default';
const phoneId = getPhoneNumberId(account);
const accessToken = getAccessToken(account);
const businessAccountId = getBusinessAccountId(account);
if (!phoneId || !accessToken) {
throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_PHONE_NUMBER_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN');
}
return { phoneId, accessToken, businessAccountId };
}
export async function sendMessage(
args: { to: string; message: string; account?: string },
customer?: Customer
): Promise<{ success: boolean; message_id: string }> {
const audit = customer ? createToolAudit(customer.id, 'whatsapp:sendMessage') : null;
const auditArgs = { to: args.to };
const { phoneId, accessToken } = await resolveWhatsAppCreds(args, customer);
const body = {
messaging_product: 'whatsapp',
@@ -67,18 +93,26 @@ export async function sendMessage(args: { to: string; message: string; account?:
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 ?? '' };
try {
const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body);
const response = data as WhatsAppMessageResponse;
const result = { success: true, message_id: response.messages?.[0]?.id ?? '' };
if (audit) await audit.success(auditArgs);
return result;
} catch (err) {
if (audit) await audit.error(auditArgs, String(err));
throw err;
}
}
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');
export async function sendTemplate(
args: { to: string; template_name: string; language?: string; components?: unknown[]; account?: string },
customer?: Customer
): Promise<{ success: boolean; message_id: string }> {
const audit = customer ? createToolAudit(customer.id, 'whatsapp:sendTemplate') : null;
const auditArgs = { to: args.to, template_name: args.template_name };
if (!phoneId || !accessToken) {
throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_PHONE_NUMBER_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN');
}
const { phoneId, accessToken } = await resolveWhatsAppCreds(args, customer);
const body: Record<string, unknown> = {
messaging_product: 'whatsapp',
@@ -94,31 +128,35 @@ export async function sendTemplate(args: { to: string; template_name: string; la
(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 ?? '' };
try {
const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body);
const response = data as WhatsAppMessageResponse;
const result = { success: true, message_id: response.messages?.[0]?.id ?? '' };
if (audit) await audit.success(auditArgs);
return result;
} catch (err) {
if (audit) await audit.error(auditArgs, String(err));
throw err;
}
}
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
export async function getMessageStatus(
args: { message_id: string; account?: string },
_customer?: Customer
): Promise<{ message_id: string; status: string; timestamp?: string }> {
// 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);
export async function listTemplates(
args: { account?: string },
customer?: Customer
): Promise<{ templates: Array<{ name: string; language: string; status: string }> }> {
const { businessAccountId, accessToken } = await resolveWhatsAppCreds(args, customer);
if (!businessAccountId || !accessToken) {
throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_BUSINESS_ACCOUNT_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN');
if (!businessAccountId) {
throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_BUSINESS_ACCOUNT_ID');
}
const url = `${WHATSAPP_BASE_URL}/${WHATSAPP_API_VERSION}/${businessAccountId}/message_templates?fields=name,language,status`;