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:
@@ -1,4 +1,14 @@
|
|||||||
services:
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: hermes-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- hermes-net
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
command: redis-server --save ""
|
||||||
|
|
||||||
hermes:
|
hermes:
|
||||||
build: .
|
build: .
|
||||||
container_name: hermes-mcp
|
container_name: hermes-mcp
|
||||||
@@ -6,6 +16,9 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
- MYSQL_HOST=mysql
|
- MYSQL_HOST=mysql
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
volumes:
|
volumes:
|
||||||
- /home/garfield/obsidian/vaults:/vaults
|
- /home/garfield/obsidian/vaults:/vaults
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
100
package-lock.json
generated
100
package-lock.json
generated
@@ -15,7 +15,8 @@
|
|||||||
"express": "^4.18.0",
|
"express": "^4.18.0",
|
||||||
"imapflow": "^1.0.0",
|
"imapflow": "^1.0.0",
|
||||||
"mysql2": "^3.14.0",
|
"mysql2": "^3.14.0",
|
||||||
"nodemailer": "^6.9.0"
|
"nodemailer": "^6.9.0",
|
||||||
|
"redis": "^5.12.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
@@ -813,6 +814,78 @@
|
|||||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@redis/bloom": {
|
||||||
|
"version": "5.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz",
|
||||||
|
"integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^5.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/client": {
|
||||||
|
"version": "5.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz",
|
||||||
|
"integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cluster-key-slot": "1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@node-rs/xxhash": "^1.1.0",
|
||||||
|
"@opentelemetry/api": ">=1 <2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@node-rs/xxhash": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/json": {
|
||||||
|
"version": "5.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz",
|
||||||
|
"integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^5.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/search": {
|
||||||
|
"version": "5.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz",
|
||||||
|
"integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^5.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/time-series": {
|
||||||
|
"version": "5.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz",
|
||||||
|
"integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^5.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -1107,6 +1180,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -2246,6 +2328,22 @@
|
|||||||
"node": ">= 12.13.0"
|
"node": ">= 12.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redis": {
|
||||||
|
"version": "5.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz",
|
||||||
|
"integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@redis/bloom": "5.12.1",
|
||||||
|
"@redis/client": "5.12.1",
|
||||||
|
"@redis/json": "5.12.1",
|
||||||
|
"@redis/search": "5.12.1",
|
||||||
|
"@redis/time-series": "5.12.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"express": "^4.18.0",
|
"express": "^4.18.0",
|
||||||
"imapflow": "^1.0.0",
|
"imapflow": "^1.0.0",
|
||||||
"mysql2": "^3.14.0",
|
"mysql2": "^3.14.0",
|
||||||
"nodemailer": "^6.9.0"
|
"nodemailer": "^6.9.0",
|
||||||
|
"redis": "^5.12.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
|
|||||||
208
product/incubation/ARCHITECTURE.md
Normal file
208
product/incubation/ARCHITECTURE.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# hermes-mcp — Architecture
|
||||||
|
|
||||||
|
**Version:** post multi-tenancy (2026-05-08)
|
||||||
|
**Deployed:** hermes.squaremcp.com (MicroK8s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
hermes-mcp is a TypeScript/Node.js MCP gateway that gives AI agents (Claude, ChatGPT, opencode) authenticated access to messaging and productivity platforms — WhatsApp, LinkedIn, Telegram, Discord, Instagram, Twitter, email, and Obsidian.
|
||||||
|
|
||||||
|
It was built first as a single-user prototype for the builder, then extended with multi-tenant credential isolation so multiple paying customers can connect their own platform accounts with zero data leakage between them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Runtime | Node.js 20, TypeScript, ESM |
|
||||||
|
| MCP transport | `@modelcontextprotocol/sdk` — Streamable HTTP + SSE |
|
||||||
|
| HTTP server | Express 4 |
|
||||||
|
| Database | MySQL 8 (`mysql2`) — OAuth clients, tokens, customers |
|
||||||
|
| Cache / credential store | Redis 7 (`redis` npm, v5) |
|
||||||
|
| Deployment | MicroK8s single-node, Traefik/nginx ingress, Let's Encrypt TLS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.ts Express server, MCP sessions, REST endpoints, OAuth
|
||||||
|
├── tools.ts Tool registry + handleToolCall(name, args, customer?)
|
||||||
|
├── db.ts MySQL pool init, schema migrations
|
||||||
|
├── oauth.ts OAuth 2.0 server (DCR, authorize, token)
|
||||||
|
├── imap.ts Multi-account IMAP email reader
|
||||||
|
├── smtp.ts Multi-account SMTP email sender
|
||||||
|
├── manifest.ts OpenAPI + ChatGPT plugin manifest generation
|
||||||
|
│
|
||||||
|
├── clients/ One file per platform
|
||||||
|
│ ├── whatsapp.ts Meta Cloud API
|
||||||
|
│ ├── linkedin.ts LinkedIn API v2
|
||||||
|
│ ├── telegram.ts Telegram Bot API
|
||||||
|
│ ├── discord.ts Discord API v10
|
||||||
|
│ ├── instagram.ts Meta Graph API (Instagram Business)
|
||||||
|
│ ├── twitter.ts Twitter API v2
|
||||||
|
│ └── obsidian.ts Local filesystem vault
|
||||||
|
│
|
||||||
|
├── multitenancy/ Added 2026-05-08
|
||||||
|
│ ├── credential-store.ts AES-256-GCM encrypted credentials in Redis
|
||||||
|
│ ├── webhook-router.ts WhatsApp phone_number_id → customerId routing
|
||||||
|
│ └── audit-log.ts Per-customer tool call audit trail (90-day TTL)
|
||||||
|
│
|
||||||
|
└── billing/ Added 2026-05-08
|
||||||
|
├── plans.ts Plan definitions (free/starter/growth/enterprise)
|
||||||
|
└── middleware.ts Customer resolution + meterMiddleware
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-tenancy design
|
||||||
|
|
||||||
|
### Credential isolation
|
||||||
|
|
||||||
|
Each customer's platform tokens are stored encrypted in Redis under a namespaced key:
|
||||||
|
|
||||||
|
```
|
||||||
|
creds:{customerId}:{platform}
|
||||||
|
```
|
||||||
|
|
||||||
|
Encryption is AES-256-GCM with a 32-byte key from `CREDENTIAL_ENCRYPTION_KEY` (env var). IV and auth tag are prepended to the ciphertext as hex. **The key must never be rotated without first re-encrypting all stored credentials.**
|
||||||
|
|
||||||
|
### Customer resolution
|
||||||
|
|
||||||
|
The `meterMiddleware` resolves an API key to a `Customer` object on every request:
|
||||||
|
|
||||||
|
1. Check Redis cache: `customer:apikey:{apiKey}` (60s TTL)
|
||||||
|
2. On miss: `SELECT id, plan, active, email FROM customers WHERE api_key = ?`
|
||||||
|
3. Attach `getCredential()` closure (not cacheable — functions can't be JSON serialized)
|
||||||
|
4. Write serialisable fields back to Redis cache
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Customer {
|
||||||
|
id: string;
|
||||||
|
plan: PlanKey;
|
||||||
|
active: boolean;
|
||||||
|
email: string;
|
||||||
|
getCredential: <T extends PlatformCredentials>(platform: Platform) => Promise<T | null>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The credential loader is attached at resolution time, capturing `id` in a closure. Tool handlers call `customer.getCredential('whatsapp')` — they cannot accidentally use the wrong customer's ID.
|
||||||
|
|
||||||
|
### Backward compatibility
|
||||||
|
|
||||||
|
All platform clients have `customer` as an optional second parameter. When absent (single-user mode via `MCP_API_KEY`), they fall back to env vars — the builder's existing setup is unchanged.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function sendMessage(args, customer?: Customer) {
|
||||||
|
if (customer) {
|
||||||
|
const creds = await customer.getCredential<WhatsAppCredentials>('whatsapp');
|
||||||
|
if (!creds) throw new Error('WhatsApp not connected for this account');
|
||||||
|
// use creds.phoneNumberId, creds.accessToken
|
||||||
|
} else {
|
||||||
|
// read WHATSAPP_DEFAULT_PHONE_NUMBER_ID etc from process.env
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### WhatsApp webhook routing
|
||||||
|
|
||||||
|
Meta sends all inbound messages for all connected numbers to one webhook endpoint. The router uses a Redis lookup table populated at onboarding:
|
||||||
|
|
||||||
|
```
|
||||||
|
wa_phone_id:{phoneNumberId} → customerId
|
||||||
|
```
|
||||||
|
|
||||||
|
The webhook endpoint acknowledges immediately (within Meta's 20-second SLA) and routes asynchronously:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.post('/webhook/whatsapp', express.json(), async (req, res) => {
|
||||||
|
res.status(200).send('EVENT_RECEIVED'); // sync — never blocked by routing
|
||||||
|
try {
|
||||||
|
const events = await routeWhatsAppWebhook(req.body);
|
||||||
|
for (const event of events) await handleInboundWhatsAppMessage(event);
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audit log
|
||||||
|
|
||||||
|
Every tool call (when a `Customer` is present) is logged to Redis:
|
||||||
|
|
||||||
|
```
|
||||||
|
log_seq:{customerId}:{date} INCR counter
|
||||||
|
logs:{customerId}:{date}:{seq} JSON entry, EX 7776000 (90 days)
|
||||||
|
```
|
||||||
|
|
||||||
|
The sequence key ensures chronological ordering without ULIDs. No cross-customer query path exists — all retrieval functions require `customerId` as the first argument.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redis key namespace summary
|
||||||
|
|
||||||
|
| Key | Value | TTL |
|
||||||
|
|-----|-------|-----|
|
||||||
|
| `creds:{customerId}:{platform}` | AES-256-GCM encrypted JSON | none (permanent until revoked) |
|
||||||
|
| `wa_phone_id:{phoneNumberId}` | customerId string | none |
|
||||||
|
| `customer:apikey:{apiKey}` | JSON (id, plan, active, email) | 60s |
|
||||||
|
| `log_seq:{customerId}:{date}` | integer counter | 95 days |
|
||||||
|
| `logs:{customerId}:{date}:{seq}` | JSON AuditEntry | 90 days |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request paths
|
||||||
|
|
||||||
|
### MCP tool calls (existing single-user)
|
||||||
|
```
|
||||||
|
Claude.ai → POST /mcp → requireAuth(MCP_API_KEY) → handleToolCall(name, args)
|
||||||
|
→ client(args, undefined)
|
||||||
|
→ env vars → Platform API
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-tenant REST tool calls
|
||||||
|
```
|
||||||
|
Customer → POST /api/whatsapp/send → requireAuth → handleToolCall(name, args)
|
||||||
|
→ client(args, undefined)
|
||||||
|
→ env vars
|
||||||
|
```
|
||||||
|
*(REST endpoints do not yet thread customer — future work)*
|
||||||
|
|
||||||
|
### Multi-tenant onboarding
|
||||||
|
```
|
||||||
|
Customer → POST /api/connect/whatsapp → meterMiddleware → storeCredential()
|
||||||
|
→ registerWhatsAppNumber()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inbound WhatsApp webhook
|
||||||
|
```
|
||||||
|
Meta → POST /webhook/whatsapp → 200 immediately
|
||||||
|
→ routeWhatsAppWebhook(body)
|
||||||
|
→ Redis lookup phone_number_id → customerId
|
||||||
|
→ getCredential(customerId, 'whatsapp')
|
||||||
|
→ handleInboundWhatsAppMessage(event)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PlantUML diagrams
|
||||||
|
|
||||||
|
- [`architecture-system.puml`](./architecture-system.puml) — component and dependency diagram
|
||||||
|
- [`architecture-userflows.puml`](./architecture-userflows.puml) — sequence diagrams for all 5 flows
|
||||||
|
|
||||||
|
Render with [plantuml.com](https://www.plantuml.com/plantuml/uml/), the PlantUML VS Code extension, or `plantuml -tsvg architecture-system.puml`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's not yet done
|
||||||
|
|
||||||
|
| Item | Notes |
|
||||||
|
|------|-------|
|
||||||
|
| Customer provisioning | `customers` table exists but needs an INSERT path (Stripe webhook → seed row) |
|
||||||
|
| MCP session → Customer | MCP calls don't resolve customers; sessions still use single-user env vars |
|
||||||
|
| Email multi-tenancy | `imap.ts` / `smtp.ts` use Account enum; `customer.getCredential('email')` not wired |
|
||||||
|
| Usage metering | `meter.ts` not implemented; plan limits not enforced |
|
||||||
|
| Obsidian per-customer vault | Currently one global vault path from env |
|
||||||
|
| Key rotation tooling | Script to re-encrypt all `creds:*` keys under a new `CREDENTIAL_ENCRYPTION_KEY` |
|
||||||
159
product/incubation/architecture-system.puml
Normal file
159
product/incubation/architecture-system.puml
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
@startuml hermes-mcp-architecture
|
||||||
|
|
||||||
|
skinparam backgroundColor #1a1a2e
|
||||||
|
skinparam defaultFontColor #e0e0e0
|
||||||
|
skinparam defaultFontSize 13
|
||||||
|
skinparam arrowColor #888888
|
||||||
|
skinparam roundCorner 8
|
||||||
|
skinparam shadowing false
|
||||||
|
|
||||||
|
skinparam component {
|
||||||
|
BackgroundColor #16213e
|
||||||
|
BorderColor #0f3460
|
||||||
|
FontColor #e0e0e0
|
||||||
|
ArrowColor #888888
|
||||||
|
}
|
||||||
|
skinparam package {
|
||||||
|
BackgroundColor #0d1b2a
|
||||||
|
BorderColor #2a4a6a
|
||||||
|
FontColor #aaaaaa
|
||||||
|
FontStyle bold
|
||||||
|
}
|
||||||
|
skinparam database {
|
||||||
|
BackgroundColor #0f2040
|
||||||
|
BorderColor #533483
|
||||||
|
FontColor #e0e0e0
|
||||||
|
}
|
||||||
|
skinparam cloud {
|
||||||
|
BackgroundColor #1a0d2e
|
||||||
|
BorderColor #e94560
|
||||||
|
FontColor #e0e0e0
|
||||||
|
}
|
||||||
|
skinparam note {
|
||||||
|
BackgroundColor #0d1b2a
|
||||||
|
BorderColor #2a4a6a
|
||||||
|
FontColor #aaaaaa
|
||||||
|
}
|
||||||
|
|
||||||
|
title hermes-mcp -- System Architecture (2026-05-08)
|
||||||
|
|
||||||
|
' ── AI Clients ────────────────────────────────────────────────────
|
||||||
|
package "AI Clients" {
|
||||||
|
[Claude.ai\nMCP connector] as claude
|
||||||
|
[ChatGPT\ncustom GPT] as chatgpt
|
||||||
|
[opencode / Codex] as opencode
|
||||||
|
}
|
||||||
|
|
||||||
|
' ── hermes-mcp server ─────────────────────────────────────────────
|
||||||
|
package "hermes-mcp | Node.js / TypeScript | hermes.squaremcp.com" {
|
||||||
|
|
||||||
|
package "Transports" {
|
||||||
|
[Streamable HTTP\n/mcp] as mcp_t
|
||||||
|
[SSE legacy\n/sse] as sse_t
|
||||||
|
[REST API\n/api/*] as rest_t
|
||||||
|
[Webhook\n/webhook/whatsapp] as wh_t
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Auth" {
|
||||||
|
[requireAuth\nMCP_API_KEY + OAuth Bearer] as req_auth
|
||||||
|
[meterMiddleware\nAPI key -> Customer] as meter
|
||||||
|
[OAuth 2.0\n/oauth/*] as oauth
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Core" {
|
||||||
|
[handleToolCall\nname, args, customer?] as dispatch
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Platform Clients src/clients/*" {
|
||||||
|
[whatsapp.ts] as c_wa
|
||||||
|
[linkedin.ts] as c_li
|
||||||
|
[telegram.ts] as c_tg
|
||||||
|
[discord.ts] as c_dc
|
||||||
|
[instagram.ts] as c_ig
|
||||||
|
[twitter.ts] as c_tw
|
||||||
|
[obsidian.ts] as c_ob
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Multi-tenancy src/multitenancy/*" {
|
||||||
|
[credential-store\nAES-256-GCM] as cred_store
|
||||||
|
[webhook-router\nphone_id -> customerId] as wh_router
|
||||||
|
[audit-log\n90-day per-customer trail] as audit_log
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Billing src/billing/*" {
|
||||||
|
[middleware.ts\nCustomer + meterMiddleware] as billing
|
||||||
|
[plans.ts\nfree/starter/growth/enterprise] as plans
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
' ── Storage ───────────────────────────────────────────────────────
|
||||||
|
database "Redis 7" as redis
|
||||||
|
note right of redis
|
||||||
|
creds:{cid}:{platform} (AES-256-GCM encrypted)
|
||||||
|
wa_phone_id:{phoneId} (-> customerId)
|
||||||
|
customer:apikey:{key} (60s TTL cache)
|
||||||
|
logs:{cid}:{date}:{seq} (90-day audit trail)
|
||||||
|
end note
|
||||||
|
|
||||||
|
database "MySQL 8\nhermes_oauth" as mysql
|
||||||
|
note right of mysql
|
||||||
|
customers (id, api_key, plan, active, email)
|
||||||
|
oauth_clients / oauth_auth_codes / oauth_tokens
|
||||||
|
end note
|
||||||
|
|
||||||
|
' ── Platform APIs ─────────────────────────────────────────────────
|
||||||
|
cloud "External Platform APIs" {
|
||||||
|
[Meta Graph API\nWhatsApp + Instagram] as meta_api
|
||||||
|
[LinkedIn API v2] as li_api
|
||||||
|
[Telegram Bot API] as tg_api
|
||||||
|
[Discord API v10] as dc_api
|
||||||
|
[Twitter API v2] as tw_api
|
||||||
|
[Obsidian Vault\nfilesystem] as ob_vault
|
||||||
|
}
|
||||||
|
|
||||||
|
' ── Connections ───────────────────────────────────────────────────
|
||||||
|
claude --> mcp_t : MCP / Bearer
|
||||||
|
chatgpt --> mcp_t : MCP / Bearer
|
||||||
|
opencode --> rest_t : REST / x-api-key
|
||||||
|
|
||||||
|
mcp_t --> req_auth
|
||||||
|
sse_t --> req_auth
|
||||||
|
rest_t --> req_auth
|
||||||
|
rest_t --> meter : /api/connect/*
|
||||||
|
wh_t --> wh_router
|
||||||
|
|
||||||
|
req_auth --> dispatch
|
||||||
|
meter --> billing : resolve Customer
|
||||||
|
billing --> mysql : SELECT customers
|
||||||
|
billing --> redis : customer cache
|
||||||
|
|
||||||
|
dispatch --> c_wa
|
||||||
|
dispatch --> c_li
|
||||||
|
dispatch --> c_tg
|
||||||
|
dispatch --> c_dc
|
||||||
|
dispatch --> c_ig
|
||||||
|
dispatch --> c_tw
|
||||||
|
dispatch --> c_ob
|
||||||
|
|
||||||
|
c_wa --> cred_store : getCredential
|
||||||
|
c_li --> cred_store : getCredential
|
||||||
|
c_tg --> cred_store : getCredential
|
||||||
|
c_dc --> cred_store : getCredential
|
||||||
|
c_ig --> cred_store : getCredential
|
||||||
|
c_tw --> cred_store : getCredential
|
||||||
|
|
||||||
|
cred_store --> redis
|
||||||
|
wh_router --> redis
|
||||||
|
audit_log --> redis
|
||||||
|
|
||||||
|
c_wa --> meta_api
|
||||||
|
c_ig --> meta_api
|
||||||
|
c_li --> li_api
|
||||||
|
c_tg --> tg_api
|
||||||
|
c_dc --> dc_api
|
||||||
|
c_tw --> tw_api
|
||||||
|
c_ob --> ob_vault
|
||||||
|
|
||||||
|
oauth --> mysql
|
||||||
|
|
||||||
|
@enduml
|
||||||
151
product/incubation/architecture-userflows.puml
Normal file
151
product/incubation/architecture-userflows.puml
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
@startuml hermes-mcp-userflows
|
||||||
|
|
||||||
|
skinparam backgroundColor #1a1a2e
|
||||||
|
skinparam defaultFontColor #e0e0e0
|
||||||
|
skinparam defaultFontSize 12
|
||||||
|
skinparam sequenceArrowColor #aaaaaa
|
||||||
|
skinparam sequenceLifeLineBorderColor #34495e
|
||||||
|
skinparam sequenceParticipantBorderColor #0f3460
|
||||||
|
skinparam sequenceParticipantBackgroundColor #16213e
|
||||||
|
skinparam sequenceParticipantFontColor #e0e0e0
|
||||||
|
skinparam noteBackgroundColor #0d1b2a
|
||||||
|
skinparam noteBorderColor #533483
|
||||||
|
skinparam noteFontColor #e0e0e0
|
||||||
|
skinparam sequenceGroupBorderColor #e94560
|
||||||
|
skinparam sequenceGroupFontColor #e94560
|
||||||
|
skinparam sequenceGroupBodyBackgroundColor #0d0d1a
|
||||||
|
|
||||||
|
title hermes-mcp - User Flows (post multi-tenancy, 2026-05-08)
|
||||||
|
|
||||||
|
' ════════════════════════════════════════════════════════════════
|
||||||
|
== Flow 1: Single-User Tool Call (builder prototype - unchanged) ==
|
||||||
|
' ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
participant "Claude.ai" as claude #16213e
|
||||||
|
participant "MCP Transport\n/mcp" as mcp #16213e
|
||||||
|
participant "requireAuth" as auth #16213e
|
||||||
|
participant "handleToolCall\n(no customer)" as handler #16213e
|
||||||
|
participant "Platform Client\nsrc/clients/*" as client #16213e
|
||||||
|
participant "Platform API" as api #0f3460
|
||||||
|
|
||||||
|
claude -> mcp : POST /mcp Bearer token
|
||||||
|
mcp -> auth : validate token
|
||||||
|
auth -> auth : match MCP_API_KEY env var
|
||||||
|
auth --> mcp : OK
|
||||||
|
mcp -> handler : handleToolCall(name, args)\ncustomer = undefined
|
||||||
|
handler -> client : sendMessage(args, undefined)
|
||||||
|
note right of client
|
||||||
|
customer is undefined ->
|
||||||
|
falls back to env vars
|
||||||
|
WHATSAPP_DEFAULT_ACCESS_TOKEN etc.
|
||||||
|
end note
|
||||||
|
client -> api : POST with env-var token
|
||||||
|
api --> client : response
|
||||||
|
client --> claude : tool result
|
||||||
|
|
||||||
|
' ════════════════════════════════════════════════════════════════
|
||||||
|
== Flow 2: Customer Onboarding - Connect WhatsApp ==
|
||||||
|
' ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
participant "Customer\n(squaremcp.com)" as cust #16213e
|
||||||
|
participant "POST /api/connect/whatsapp" as conn_ep #16213e
|
||||||
|
participant "meterMiddleware" as meter #16213e
|
||||||
|
participant "MySQL\ncustomers table" as mysql #0f3460
|
||||||
|
participant "Redis" as redis #0f3460
|
||||||
|
participant "credential-store\n(AES-256-GCM)" as cs #16213e
|
||||||
|
participant "webhook-router" as wr #16213e
|
||||||
|
|
||||||
|
cust -> conn_ep : POST /api/connect/whatsapp\nx-api-key: cust_abc123\n{phoneNumberId, accessToken, businessAccountId}
|
||||||
|
conn_ep -> meter : resolve customer
|
||||||
|
meter -> redis : GET customer:apikey:cust_abc123
|
||||||
|
redis --> meter : (miss - first request)
|
||||||
|
meter -> mysql : SELECT id,plan,active,email\nFROM customers WHERE api_key=?
|
||||||
|
mysql --> meter : {id:'cust_001', plan:'starter', active:true}
|
||||||
|
meter -> redis : SETEX customer:apikey:cust_abc123 60 {...}
|
||||||
|
meter --> conn_ep : req.customer = Customer{id:'cust_001', getCredential()}
|
||||||
|
conn_ep -> cs : storeCredential('cust_001', 'whatsapp', creds)
|
||||||
|
cs -> cs : randomBytes(12) IV\nAES-256-GCM encrypt
|
||||||
|
cs -> redis : SET creds:cust_001:whatsapp [encrypted]
|
||||||
|
conn_ep -> wr : registerWhatsAppNumber('cust_001', phoneNumberId)
|
||||||
|
wr -> redis : SET wa_phone_id:{phoneNumberId} cust_001
|
||||||
|
conn_ep --> cust : 200 {connected: true, platform: 'whatsapp'}
|
||||||
|
|
||||||
|
' ════════════════════════════════════════════════════════════════
|
||||||
|
== Flow 3: Multi-Tenant Tool Call (customer sends WhatsApp via MCP/REST) ==
|
||||||
|
' ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
participant "Customer\nMCP session" as cust2 #16213e
|
||||||
|
participant "handleToolCall\n(customer passed)" as handler2 #16213e
|
||||||
|
participant "whatsapp.ts\nclient" as wac #16213e
|
||||||
|
participant "credential-store" as cs2 #16213e
|
||||||
|
participant "Redis" as redis2 #0f3460
|
||||||
|
participant "audit-log" as al #16213e
|
||||||
|
participant "WhatsApp\nGraph API" as wa_api #0f3460
|
||||||
|
|
||||||
|
cust2 -> handler2 : whatsapp_send_message\n{to, message}\ncustomer = Customer{id:'cust_001'}
|
||||||
|
handler2 -> wac : sendMessage(args, customer)
|
||||||
|
wac -> cs2 : customer.getCredential<WhatsAppCredentials>('whatsapp')
|
||||||
|
cs2 -> redis2 : GET creds:cust_001:whatsapp
|
||||||
|
redis2 --> cs2 : [AES-256-GCM ciphertext]
|
||||||
|
cs2 -> cs2 : decrypt -> {phoneNumberId, accessToken, businessAccountId}
|
||||||
|
cs2 --> wac : WhatsAppCredentials
|
||||||
|
wac -> wa_api : POST /{phoneNumberId}/messages\nAuthorization: Bearer {accessToken}
|
||||||
|
wa_api --> wac : {messages: [{id: 'wamid.xxx'}]}
|
||||||
|
wac -> al : audit.success({to})
|
||||||
|
al -> redis2 : SET logs:cust_001:2026-05-08:00000001\nEX 7776000 (90 days)
|
||||||
|
wac --> cust2 : {success: true, message_id: 'wamid.xxx'}
|
||||||
|
|
||||||
|
' ════════════════════════════════════════════════════════════════
|
||||||
|
== Flow 4: Inbound WhatsApp Webhook (Meta -> correct customer) ==
|
||||||
|
' ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
participant "Meta\nCloud API" as meta #0f3460
|
||||||
|
participant "POST /webhook/whatsapp" as whook #16213e
|
||||||
|
participant "webhook-router" as wr2 #16213e
|
||||||
|
participant "Redis" as redis3 #0f3460
|
||||||
|
participant "credential-store" as cs3 #16213e
|
||||||
|
participant "handleInbound\n(stub -> future agent)" as inbound #16213e
|
||||||
|
|
||||||
|
meta -> whook : POST /webhook/whatsapp\n{object:'whatsapp_business_account',\nentry:[{changes:[{value:{metadata:\n{phone_number_id:'111'},messages:[...]}}]}]}
|
||||||
|
note right of whook
|
||||||
|
Must respond within 20s
|
||||||
|
or Meta retries the webhook
|
||||||
|
end note
|
||||||
|
whook --> meta : 200 EVENT_RECEIVED <- immediate
|
||||||
|
whook -> wr2 : routeWhatsAppWebhook(body) [async]
|
||||||
|
wr2 -> redis3 : GET wa_phone_id:111
|
||||||
|
redis3 --> wr2 : 'cust_001'
|
||||||
|
wr2 -> cs3 : getCredential('cust_001', 'whatsapp')
|
||||||
|
cs3 -> redis3 : GET creds:cust_001:whatsapp -> decrypt
|
||||||
|
cs3 --> wr2 : WhatsAppCredentials
|
||||||
|
wr2 --> whook : RoutedWebhookEvent{customerId, phoneNumberId, message, credentials}
|
||||||
|
whook -> inbound : handleInboundWhatsAppMessage(event)
|
||||||
|
note right of inbound
|
||||||
|
Currently: log only
|
||||||
|
Future: route to customer's
|
||||||
|
AI agent / queue
|
||||||
|
end note
|
||||||
|
|
||||||
|
' ════════════════════════════════════════════════════════════════
|
||||||
|
== Flow 5: Customer Checks Connection Status ==
|
||||||
|
' ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
participant "Customer" as cust3 #16213e
|
||||||
|
participant "GET /api/connections" as conn_get #16213e
|
||||||
|
participant "meterMiddleware" as meter3 #16213e
|
||||||
|
participant "credential-store" as cs4 #16213e
|
||||||
|
participant "Redis" as redis4 #0f3460
|
||||||
|
|
||||||
|
cust3 -> conn_get : GET /api/connections\nx-api-key: cust_abc123
|
||||||
|
conn_get -> meter3 : resolve (cache hit this time)
|
||||||
|
meter3 -> redis4 : GET customer:apikey:cust_abc123
|
||||||
|
redis4 --> meter3 : Customer{id:'cust_001'}
|
||||||
|
meter3 --> conn_get : req.customer attached
|
||||||
|
loop for each platform in [email, whatsapp, linkedin, telegram, discord, instagram, twitter, obsidian]
|
||||||
|
conn_get -> cs4 : customer.getCredential(platform)
|
||||||
|
cs4 -> redis4 : GET creds:cust_001:{platform}
|
||||||
|
redis4 --> cs4 : value or null
|
||||||
|
end
|
||||||
|
conn_get --> cust3 : 200 {customerId:'cust_001',\nconnections:{whatsapp:true, linkedin:false, ...}}
|
||||||
|
|
||||||
|
@enduml
|
||||||
92
src/billing/middleware.ts
Normal file
92
src/billing/middleware.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { createClient } from 'redis';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
import { getPool } from '../db.js';
|
||||||
|
import { getCredential, Platform, PlatformCredentials } from '../multitenancy/credential-store.js';
|
||||||
|
import type { PlanKey } from './plans.js';
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
const redis = createClient({ url: process.env.REDIS_URL });
|
||||||
|
redis.connect().catch((err) => console.error('[billing] Redis connect error:', err));
|
||||||
|
|
||||||
|
export interface Customer {
|
||||||
|
id: string;
|
||||||
|
plan: PlanKey;
|
||||||
|
active: boolean;
|
||||||
|
email: string;
|
||||||
|
// Credential loader — tool handlers call this to get their platform credentials
|
||||||
|
getCredential: <T extends PlatformCredentials>(platform: Platform) => Promise<T | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomerRow extends RowDataPacket {
|
||||||
|
id: string;
|
||||||
|
plan: PlanKey;
|
||||||
|
active: boolean;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCustomer(apiKey: string): Promise<Customer | null> {
|
||||||
|
const cached = await redis.get(`customer:apikey:${apiKey}`);
|
||||||
|
if (cached) {
|
||||||
|
const base = JSON.parse(cached) as Omit<Customer, 'getCredential'>;
|
||||||
|
// Re-attach the credential loader (functions can't be cached)
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||||
|
getCredential<T>(base.id, platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await getPool().query<CustomerRow[]>(
|
||||||
|
'SELECT id, plan, active, email FROM customers WHERE api_key = ?',
|
||||||
|
[apiKey]
|
||||||
|
);
|
||||||
|
if (!rows.length) return null;
|
||||||
|
|
||||||
|
const { id, plan, active, email } = rows[0];
|
||||||
|
const customer: Customer = {
|
||||||
|
id,
|
||||||
|
plan,
|
||||||
|
active: Boolean(active),
|
||||||
|
email,
|
||||||
|
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||||
|
getCredential<T>(id, platform),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache only the serialisable fields (not the function)
|
||||||
|
await redis.setEx(`customer:apikey:${apiKey}`, 60, JSON.stringify({ id, plan, active, email }));
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Express middleware: resolve API key → Customer and attach to req.customer
|
||||||
|
export async function meterMiddleware(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const apiKey =
|
||||||
|
(req.headers['x-api-key'] as string | undefined) ||
|
||||||
|
(req.query.key as string | undefined);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
res.status(401).json({ error: 'Missing API key' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await resolveCustomer(apiKey);
|
||||||
|
if (!customer) {
|
||||||
|
res.status(401).json({ error: 'Invalid API key' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customer.active) {
|
||||||
|
res.status(403).json({ error: 'Account suspended' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(req as Request & { customer: Customer }).customer = customer;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/billing/plans.ts
Normal file
30
src/billing/plans.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export type PlanKey = 'free' | 'starter' | 'growth' | 'enterprise';
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
name: string;
|
||||||
|
monthlyCallLimit: number;
|
||||||
|
platforms: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLANS: Record<PlanKey, Plan> = {
|
||||||
|
free: {
|
||||||
|
name: 'Free',
|
||||||
|
monthlyCallLimit: 100,
|
||||||
|
platforms: ['email', 'obsidian'],
|
||||||
|
},
|
||||||
|
starter: {
|
||||||
|
name: 'Starter',
|
||||||
|
monthlyCallLimit: 1000,
|
||||||
|
platforms: ['email', 'obsidian', 'whatsapp', 'telegram'],
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
name: 'Growth',
|
||||||
|
monthlyCallLimit: 10000,
|
||||||
|
platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter'],
|
||||||
|
},
|
||||||
|
enterprise: {
|
||||||
|
name: 'Enterprise',
|
||||||
|
monthlyCallLimit: -1,
|
||||||
|
platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter'],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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';
|
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`;
|
const envKey = `DISCORD_${account.toUpperCase()}_BOT_TOKEN`;
|
||||||
return process.env[envKey] ?? '';
|
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(
|
async function discordRequest(
|
||||||
token: string,
|
token: string,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
@@ -30,72 +45,60 @@ async function discordRequest(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMe(args: { account?: string }): Promise<{
|
export async function getMe(
|
||||||
id: string;
|
args: { account?: string },
|
||||||
username: string;
|
customer?: Customer
|
||||||
bot: boolean;
|
): Promise<{ id: string; username: string; bot: boolean }> {
|
||||||
}> {
|
const token = await resolveToken(args, customer);
|
||||||
const token = getToken(args.account ?? 'default');
|
|
||||||
if (!token) {
|
|
||||||
throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN');
|
|
||||||
}
|
|
||||||
return discordRequest(token, '/users/@me');
|
return discordRequest(token, '/users/@me');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGuilds(args: { account?: string }): Promise<Array<{
|
export async function getGuilds(
|
||||||
id: string;
|
args: { account?: string },
|
||||||
name: string;
|
customer?: Customer
|
||||||
icon?: string;
|
): Promise<Array<{ id: string; name: string; icon?: string }>> {
|
||||||
}>> {
|
const token = await resolveToken(args, customer);
|
||||||
const token = getToken(args.account ?? 'default');
|
|
||||||
if (!token) {
|
|
||||||
throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN');
|
|
||||||
}
|
|
||||||
return discordRequest(token, '/users/@me/guilds');
|
return discordRequest(token, '/users/@me/guilds');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChannels(args: { guild_id: string; account?: string }): Promise<Array<{
|
export async function getChannels(
|
||||||
id: string;
|
args: { guild_id: string; account?: string },
|
||||||
name: string;
|
customer?: Customer
|
||||||
type: number;
|
): Promise<Array<{ id: string; name: string; type: number }>> {
|
||||||
}>> {
|
const token = await resolveToken(args, customer);
|
||||||
const token = getToken(args.account ?? 'default');
|
|
||||||
if (!token) {
|
|
||||||
throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN');
|
|
||||||
}
|
|
||||||
return discordRequest(token, `/guilds/${args.guild_id}/channels`);
|
return discordRequest(token, `/guilds/${args.guild_id}/channels`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendMessage(args: {
|
export async function sendMessage(
|
||||||
channel_id: string;
|
args: { channel_id: string; content: string; account?: string },
|
||||||
content: string;
|
customer?: Customer
|
||||||
account?: string;
|
): Promise<{ id: string; channel_id: string }> {
|
||||||
}): Promise<{ id: string; channel_id: string }> {
|
const audit = customer ? createToolAudit(customer.id, 'discord:sendMessage') : null;
|
||||||
const token = getToken(args.account ?? 'default');
|
const auditArgs = { channel_id: args.channel_id };
|
||||||
if (!token) {
|
const token = await resolveToken(args, customer);
|
||||||
throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN');
|
|
||||||
}
|
|
||||||
|
|
||||||
return discordRequest(token, `/channels/${args.channel_id}/messages`, 'POST', {
|
try {
|
||||||
content: args.content,
|
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: {
|
export async function getMessages(
|
||||||
channel_id: string;
|
args: { channel_id: string; limit?: number; account?: string },
|
||||||
limit?: number;
|
customer?: Customer
|
||||||
account?: string;
|
): Promise<Array<{
|
||||||
}): Promise<Array<{
|
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
author: { username: string; id: string };
|
author: { username: string; id: string };
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}>> {
|
}>> {
|
||||||
const token = getToken(args.account ?? 'default');
|
const token = await resolveToken(args, customer);
|
||||||
if (!token) {
|
|
||||||
throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN');
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = args.limit ?? 10;
|
const limit = args.limit ?? 10;
|
||||||
return discordRequest(token, `/channels/${args.channel_id}/messages?limit=${limit}`);
|
return discordRequest(token, `/channels/${args.channel_id}/messages?limit=${limit}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
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`;
|
const envKey = `INSTAGRAM_${account.toUpperCase()}_ACCESS_TOKEN`;
|
||||||
return process.env[envKey] ?? '';
|
return process.env[envKey] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBusinessAccountId(account: string): string {
|
function getEnvBusinessId(account: string): string {
|
||||||
const envKey = `INSTAGRAM_${account.toUpperCase()}_BUSINESS_ACCOUNT_ID`;
|
const envKey = `INSTAGRAM_${account.toUpperCase()}_BUSINESS_ACCOUNT_ID`;
|
||||||
return process.env[envKey] ?? '';
|
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(
|
async function instagramRequest(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -34,7 +60,10 @@ async function instagramRequest(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProfile(args: { account?: string }): Promise<{
|
export async function getProfile(
|
||||||
|
args: { account?: string },
|
||||||
|
customer?: Customer
|
||||||
|
): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -42,12 +71,7 @@ export async function getProfile(args: { account?: string }): Promise<{
|
|||||||
follows_count: number;
|
follows_count: number;
|
||||||
media_count: number;
|
media_count: number;
|
||||||
}> {
|
}> {
|
||||||
const accessToken = getAccessToken(args.account ?? 'default');
|
const { accessToken, businessAccountId } = await resolveCreds(args, customer);
|
||||||
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 data = await instagramRequest(
|
const data = await instagramRequest(
|
||||||
`/${businessAccountId}?fields=username,name,followers_count,follows_count,media_count`,
|
`/${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;
|
id: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
media_type: string;
|
media_type: string;
|
||||||
@@ -72,13 +99,7 @@ export async function getMedia(args: { limit?: number; account?: string }): Prom
|
|||||||
permalink?: string;
|
permalink?: string;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
}>> {
|
}>> {
|
||||||
const accessToken = getAccessToken(args.account ?? 'default');
|
const { accessToken, businessAccountId } = await resolveCreds(args, customer);
|
||||||
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 limit = args.limit ?? 10;
|
const limit = args.limit ?? 10;
|
||||||
const data = await instagramRequest(
|
const data = await instagramRequest(
|
||||||
`/${businessAccountId}/media?fields=id,caption,media_type,media_url,permalink,timestamp&limit=${limit}`,
|
`/${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: {
|
export async function createPost(
|
||||||
image_url: string;
|
args: { image_url: string; caption?: string; account?: string },
|
||||||
caption?: string;
|
customer?: Customer
|
||||||
account?: string;
|
): Promise<{ success: boolean; media_id: string }> {
|
||||||
}): Promise<{ success: boolean; media_id: string }> {
|
const audit = customer ? createToolAudit(customer.id, 'instagram:createPost') : null;
|
||||||
const accessToken = getAccessToken(args.account ?? 'default');
|
const auditArgs = { image_url: args.image_url };
|
||||||
const businessAccountId = getBusinessAccountId(args.account ?? 'default');
|
const { accessToken, businessAccountId } = await resolveCreds(args, customer);
|
||||||
|
|
||||||
if (!accessToken || !businessAccountId) {
|
try {
|
||||||
throw new Error('Missing Instagram credentials. Set INSTAGRAM_{ACCOUNT}_ACCESS_TOKEN and INSTAGRAM_{ACCOUNT}_BUSINESS_ACCOUNT_ID');
|
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
const LINKEDIN_API_BASE = 'https://api.linkedin.com/v2';
|
||||||
|
|
||||||
function getEnvVar(account: string, key: string): string {
|
function getEnvToken(account: string): string {
|
||||||
const envKey = `LINKEDIN_${account.toUpperCase()}_${key}`;
|
const envKey = `LINKEDIN_${account.toUpperCase()}_ACCESS_TOKEN`;
|
||||||
return process.env[envKey] ?? '';
|
return process.env[envKey] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAccessToken(account: string): string {
|
async function resolveToken(args: { account?: string }, customer?: Customer): Promise<string> {
|
||||||
return getEnvVar(account, 'ACCESS_TOKEN');
|
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(
|
async function linkedinRequest(
|
||||||
@@ -35,23 +46,20 @@ async function linkedinRequest(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProfile(args: { account?: string }): Promise<{
|
export async function getProfile(
|
||||||
|
args: { account?: string },
|
||||||
|
customer?: Customer
|
||||||
|
): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
picture?: string;
|
picture?: string;
|
||||||
}> {
|
}> {
|
||||||
const accessToken = getAccessToken(args.account ?? 'default');
|
const accessToken = await resolveToken(args, customer);
|
||||||
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`, {
|
const res = await fetch(`${LINKEDIN_API_BASE}/userinfo`, {
|
||||||
headers: {
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(15000),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,17 +78,15 @@ export async function getProfile(args: { account?: string }): Promise<{
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPost(args: {
|
export async function createPost(
|
||||||
text: string;
|
args: { text: string; visibility?: 'PUBLIC' | 'CONNECTIONS'; account?: string },
|
||||||
visibility?: 'PUBLIC' | 'CONNECTIONS';
|
customer?: Customer
|
||||||
account?: string;
|
): Promise<{ success: boolean; post_id: string; url: string }> {
|
||||||
}): Promise<{ success: boolean; post_id: string; url: string }> {
|
const audit = customer ? createToolAudit(customer.id, 'linkedin:createPost') : null;
|
||||||
const accessToken = getAccessToken(args.account ?? 'default');
|
const auditArgs = { text: args.text.slice(0, 100) };
|
||||||
if (!accessToken) {
|
const accessToken = await resolveToken(args, customer);
|
||||||
throw new Error('Missing LinkedIn credentials. Set LINKEDIN_{ACCOUNT}_ACCESS_TOKEN');
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await getProfile({ account: args.account });
|
const profile = await getProfile(args, customer);
|
||||||
const authorUrn = `urn:li:person:${profile.id}`;
|
const authorUrn = `urn:li:person:${profile.id}`;
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
@@ -88,9 +94,7 @@ export async function createPost(args: {
|
|||||||
lifecycleState: 'PUBLISHED',
|
lifecycleState: 'PUBLISHED',
|
||||||
specificContent: {
|
specificContent: {
|
||||||
'com.linkedin.ugc.ShareContent': {
|
'com.linkedin.ugc.ShareContent': {
|
||||||
shareCommentary: {
|
shareCommentary: { text: args.text },
|
||||||
text: args.text,
|
|
||||||
},
|
|
||||||
shareMediaCategory: 'NONE',
|
shareMediaCategory: 'NONE',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -99,24 +103,26 @@ export async function createPost(args: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = await linkedinRequest('/ugcPosts', accessToken, 'POST', body);
|
try {
|
||||||
const postId = data.id ?? '';
|
const data = await linkedinRequest('/ugcPosts', accessToken, 'POST', body);
|
||||||
return {
|
const postId = data.id ?? '';
|
||||||
success: true,
|
const result = {
|
||||||
post_id: postId,
|
success: true,
|
||||||
url: postId ? `https://www.linkedin.com/feed/update/${postId}` : '',
|
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: {
|
export async function searchConnections(
|
||||||
keywords?: string;
|
args: { keywords?: string; account?: string },
|
||||||
account?: string;
|
_customer?: Customer
|
||||||
}): Promise<{ message: 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(
|
throw new Error(
|
||||||
'LinkedIn connections search requires the LinkedIn Partnership Program. ' +
|
'LinkedIn connections search requires the LinkedIn Partnership Program. ' +
|
||||||
'Public API access to connections was removed. ' +
|
'Public API access to connections was removed. ' +
|
||||||
@@ -124,16 +130,10 @@ export async function searchConnections(args: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendMessage(args: {
|
export async function sendMessage(
|
||||||
recipient_id: string;
|
args: { recipient_id: string; message: string; account?: string },
|
||||||
message: string;
|
_customer?: Customer
|
||||||
account?: string;
|
): Promise<{ message: 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(
|
throw new Error(
|
||||||
'LinkedIn messaging requires the LinkedIn Partnership Program. ' +
|
'LinkedIn messaging requires the LinkedIn Partnership Program. ' +
|
||||||
'Direct messaging is not available through the public API. ' +
|
'Direct messaging is not available through the public API. ' +
|
||||||
|
|||||||
@@ -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';
|
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`;
|
const envKey = `TELEGRAM_${account.toUpperCase()}_BOT_TOKEN`;
|
||||||
return process.env[envKey] ?? '';
|
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(
|
async function telegramRequest(
|
||||||
token: string,
|
token: string,
|
||||||
method: string,
|
method: string,
|
||||||
@@ -25,69 +40,69 @@ async function telegramRequest(
|
|||||||
return data.result;
|
return data.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMe(args: { account?: string }): Promise<{
|
export async function getMe(
|
||||||
|
args: { account?: string },
|
||||||
|
customer?: Customer
|
||||||
|
): Promise<{
|
||||||
id: number;
|
id: number;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
username: string;
|
username: string;
|
||||||
is_bot: boolean;
|
is_bot: boolean;
|
||||||
}> {
|
}> {
|
||||||
const token = getToken(args.account ?? 'default');
|
const token = await resolveToken(args, customer);
|
||||||
if (!token) {
|
|
||||||
throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN');
|
|
||||||
}
|
|
||||||
return telegramRequest(token, 'getMe');
|
return telegramRequest(token, 'getMe');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendMessage(args: {
|
export async function sendMessage(
|
||||||
chat_id: string | number;
|
args: { chat_id: string | number; text: string; parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2'; account?: string },
|
||||||
text: string;
|
customer?: Customer
|
||||||
parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2';
|
): Promise<{ message_id: number; chat_id: string | number }> {
|
||||||
account?: string;
|
const audit = customer ? createToolAudit(customer.id, 'telegram:sendMessage') : null;
|
||||||
}): Promise<{ message_id: number; chat_id: string | number }> {
|
const auditArgs = { chat_id: args.chat_id };
|
||||||
const token = getToken(args.account ?? 'default');
|
const token = await resolveToken(args, customer);
|
||||||
if (!token) {
|
|
||||||
throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN');
|
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: {
|
export async function sendPhoto(
|
||||||
chat_id: string | number;
|
args: { chat_id: string | number; photo: string; caption?: string; account?: string },
|
||||||
photo: string;
|
customer?: Customer
|
||||||
caption?: string;
|
): Promise<{ message_id: number; chat_id: string | number }> {
|
||||||
account?: string;
|
const audit = customer ? createToolAudit(customer.id, 'telegram:sendPhoto') : null;
|
||||||
}): Promise<{ message_id: number; chat_id: string | number }> {
|
const auditArgs = { chat_id: args.chat_id };
|
||||||
const token = getToken(args.account ?? 'default');
|
const token = await resolveToken(args, customer);
|
||||||
if (!token) {
|
|
||||||
throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN');
|
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: {
|
export async function getUpdates(
|
||||||
limit?: number;
|
args: { limit?: number; account?: string },
|
||||||
account?: string;
|
customer?: Customer
|
||||||
}): Promise<Array<{
|
): Promise<Array<{
|
||||||
update_id: number;
|
update_id: number;
|
||||||
message?: {
|
message?: {
|
||||||
message_id: number;
|
message_id: number;
|
||||||
@@ -97,20 +112,14 @@ export async function getUpdates(args: {
|
|||||||
text?: string;
|
text?: string;
|
||||||
};
|
};
|
||||||
}>> {
|
}>> {
|
||||||
const token = getToken(args.account ?? 'default');
|
const token = await resolveToken(args, customer);
|
||||||
if (!token) {
|
return telegramRequest(token, 'getUpdates', { limit: args.limit ?? 10 });
|
||||||
throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN');
|
|
||||||
}
|
|
||||||
|
|
||||||
return telegramRequest(token, 'getUpdates', {
|
|
||||||
limit: args.limit ?? 10,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChat(args: {
|
export async function getChat(
|
||||||
chat_id: string | number;
|
args: { chat_id: string | number; account?: string },
|
||||||
account?: string;
|
customer?: Customer
|
||||||
}): Promise<{
|
): Promise<{
|
||||||
id: number;
|
id: number;
|
||||||
type: string;
|
type: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -118,12 +127,6 @@ export async function getChat(args: {
|
|||||||
description?: string;
|
description?: string;
|
||||||
member_count?: number;
|
member_count?: number;
|
||||||
}> {
|
}> {
|
||||||
const token = getToken(args.account ?? 'default');
|
const token = await resolveToken(args, customer);
|
||||||
if (!token) {
|
return telegramRequest(token, 'getChat', { chat_id: args.chat_id });
|
||||||
throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN');
|
|
||||||
}
|
|
||||||
|
|
||||||
return telegramRequest(token, 'getChat', {
|
|
||||||
chat_id: args.chat_id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
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`;
|
const envKey = `TWITTER_${account.toUpperCase()}_BEARER_TOKEN`;
|
||||||
return process.env[envKey] ?? '';
|
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(
|
async function twitterRequest(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
bearerToken: string,
|
bearerToken: string,
|
||||||
@@ -18,9 +32,7 @@ async function twitterRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(url.toString(), {
|
const res = await fetch(url.toString(), {
|
||||||
headers: {
|
headers: { 'Authorization': `Bearer ${bearerToken}` },
|
||||||
'Authorization': `Bearer ${bearerToken}`,
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(15000),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,20 +44,11 @@ async function twitterRequest(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchTweets(args: {
|
export async function searchTweets(
|
||||||
query: string;
|
args: { query: string; max_results?: number; account?: string },
|
||||||
max_results?: number;
|
customer?: Customer
|
||||||
account?: string;
|
): Promise<Array<{ id: string; text: string; author_id?: string; created_at?: string }>> {
|
||||||
}): Promise<Array<{
|
const bearerToken = await resolveToken(args, customer);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await twitterRequest('/tweets/search/recent', bearerToken, {
|
const data = await twitterRequest('/tweets/search/recent', bearerToken, {
|
||||||
query: args.query,
|
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 }>;
|
return (data.data ?? []) as Array<{ id: string; text: string; author_id?: string; created_at?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserProfile(args: {
|
export async function getUserProfile(
|
||||||
username: string;
|
args: { username: string; account?: string },
|
||||||
account?: string;
|
customer?: Customer
|
||||||
}): Promise<{
|
): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -68,10 +71,7 @@ export async function getUserProfile(args: {
|
|||||||
following_count?: number;
|
following_count?: number;
|
||||||
tweet_count?: number;
|
tweet_count?: number;
|
||||||
}> {
|
}> {
|
||||||
const bearerToken = getBearerToken(args.account ?? 'default');
|
const bearerToken = await resolveToken(args, customer);
|
||||||
if (!bearerToken) {
|
|
||||||
throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await twitterRequest(`/users/by/username/${args.username}`, bearerToken, {
|
const data = await twitterRequest(`/users/by/username/${args.username}`, bearerToken, {
|
||||||
'user.fields': 'description,public_metrics',
|
'user.fields': 'description,public_metrics',
|
||||||
@@ -90,26 +90,15 @@ export async function getUserProfile(args: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserTweets(args: {
|
export async function getUserTweets(
|
||||||
username: string;
|
args: { username: string; max_results?: number; account?: string },
|
||||||
max_results?: number;
|
customer?: Customer
|
||||||
account?: string;
|
): Promise<Array<{ id: string; text: string; created_at?: string }>> {
|
||||||
}): Promise<Array<{
|
const bearerToken = await resolveToken(args, customer);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// First get user ID
|
|
||||||
const userData = await twitterRequest(`/users/by/username/${args.username}`, bearerToken);
|
const userData = await twitterRequest(`/users/by/username/${args.username}`, bearerToken);
|
||||||
const userId = userData.data?.id;
|
const userId = userData.data?.id;
|
||||||
if (!userId) {
|
if (!userId) throw new Error(`User @${args.username} not found`);
|
||||||
throw new Error(`User @${args.username} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await twitterRequest(`/users/${userId}/tweets`, bearerToken, {
|
const data = await twitterRequest(`/users/${userId}/tweets`, bearerToken, {
|
||||||
max_results: String(Math.min(args.max_results ?? 10, 100)),
|
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 }>;
|
return (data.data ?? []) as Array<{ id: string; text: string; created_at?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTweet(args: {
|
export async function createTweet(
|
||||||
text: string;
|
args: { text: string; account?: string },
|
||||||
account?: string;
|
_customer?: Customer
|
||||||
}): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
const bearerToken = getBearerToken(args.account ?? 'default');
|
|
||||||
if (!bearerToken) {
|
|
||||||
throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN');
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Twitter/X posting requires a paid API tier. ' +
|
'Twitter/X posting requires a paid API tier. ' +
|
||||||
'The free tier is read-only (500 tweets/month). ' +
|
'The free tier is read-only (500 tweets/month). ' +
|
||||||
|
|||||||
@@ -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_API_VERSION = 'v18.0';
|
||||||
const WHATSAPP_BASE_URL = process.env['WHATSAPP_API_BASE_URL'] ?? 'https://graph.facebook.com';
|
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}`;
|
const envKey = `WHATSAPP_${account.toUpperCase()}_${key}`;
|
||||||
return process.env[envKey] ?? '';
|
return process.env[envKey] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPhoneNumberId(account: string): string {
|
function getPhoneNumberId(account: string): string {
|
||||||
return getEnvVar('WHATSAPP', account, 'PHONE_NUMBER_ID');
|
return getEnvVar(account, 'PHONE_NUMBER_ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAccessToken(account: string): string {
|
function getAccessToken(account: string): string {
|
||||||
return getEnvVar('WHATSAPP', account, 'ACCESS_TOKEN');
|
return getEnvVar(account, 'ACCESS_TOKEN');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBusinessAccountId(account: string): string {
|
function getBusinessAccountId(account: string): string {
|
||||||
return getEnvVar('WHATSAPP', account, 'BUSINESS_ACCOUNT_ID');
|
return getEnvVar(account, 'BUSINESS_ACCOUNT_ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WhatsAppMessageResponse {
|
interface WhatsAppMessageResponse {
|
||||||
@@ -52,13 +54,37 @@ async function whatsappApiRequest(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendMessage(args: { to: string; message: string; account?: string }): Promise<{ success: boolean; message_id: string }> {
|
async function resolveWhatsAppCreds(
|
||||||
const phoneId = getPhoneNumberId(args.account ?? 'default');
|
args: { account?: string },
|
||||||
const accessToken = getAccessToken(args.account ?? 'default');
|
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) {
|
if (!phoneId || !accessToken) {
|
||||||
throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_PHONE_NUMBER_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN');
|
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 = {
|
const body = {
|
||||||
messaging_product: 'whatsapp',
|
messaging_product: 'whatsapp',
|
||||||
@@ -67,18 +93,26 @@ export async function sendMessage(args: { to: string; message: string; account?:
|
|||||||
text: { body: args.message },
|
text: { body: args.message },
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body);
|
try {
|
||||||
const response = data as WhatsAppMessageResponse;
|
const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body);
|
||||||
return { success: true, message_id: response.messages?.[0]?.id ?? '' };
|
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 }> {
|
export async function sendTemplate(
|
||||||
const phoneId = getPhoneNumberId(args.account ?? 'default');
|
args: { to: string; template_name: string; language?: string; components?: unknown[]; account?: string },
|
||||||
const accessToken = getAccessToken(args.account ?? 'default');
|
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) {
|
const { phoneId, accessToken } = await resolveWhatsAppCreds(args, customer);
|
||||||
throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_PHONE_NUMBER_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
messaging_product: 'whatsapp',
|
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;
|
(body.template as Record<string, unknown>).components = args.components;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body);
|
try {
|
||||||
const response = data as WhatsAppMessageResponse;
|
const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body);
|
||||||
return { success: true, message_id: response.messages?.[0]?.id ?? '' };
|
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 }> {
|
export async function getMessageStatus(
|
||||||
const phoneId = getPhoneNumberId(args.account ?? 'default');
|
args: { message_id: string; account?: string },
|
||||||
const accessToken = getAccessToken(args.account ?? 'default');
|
_customer?: Customer
|
||||||
|
): Promise<{ message_id: string; status: string; timestamp?: string }> {
|
||||||
if (!phoneId || !accessToken) {
|
// Meta Cloud API doesn't support polling message status via GET
|
||||||
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)
|
// 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.');
|
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 }> }> {
|
export async function listTemplates(
|
||||||
const account = args.account ?? 'default';
|
args: { account?: string },
|
||||||
const businessAccountId = getBusinessAccountId(account);
|
customer?: Customer
|
||||||
const accessToken = getAccessToken(account);
|
): Promise<{ templates: Array<{ name: string; language: string; status: string }> }> {
|
||||||
|
const { businessAccountId, accessToken } = await resolveWhatsAppCreds(args, customer);
|
||||||
|
|
||||||
if (!businessAccountId || !accessToken) {
|
if (!businessAccountId) {
|
||||||
throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_BUSINESS_ACCOUNT_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN');
|
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`;
|
const url = `${WHATSAPP_BASE_URL}/${WHATSAPP_API_VERSION}/${businessAccountId}/message_templates?fields=name,language,status`;
|
||||||
|
|||||||
12
src/db.ts
12
src/db.ts
@@ -73,6 +73,18 @@ export async function initDatabase(): Promise<void> {
|
|||||||
INDEX idx_expires (expires_at)
|
INDEX idx_expires (expires_at)
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
api_key VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
plan ENUM('free', 'starter', 'growth', 'enterprise') DEFAULT 'free',
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_api_key (api_key)
|
||||||
|
)
|
||||||
|
`);
|
||||||
} finally {
|
} finally {
|
||||||
db.release();
|
db.release();
|
||||||
}
|
}
|
||||||
|
|||||||
118
src/index.ts
118
src/index.ts
@@ -12,6 +12,9 @@ import {
|
|||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { tools, handleToolCall } from './tools.js';
|
import { tools, handleToolCall } from './tools.js';
|
||||||
import { getManifest, getOpenApiSpec } from './manifest.js';
|
import { getManifest, getOpenApiSpec } from './manifest.js';
|
||||||
|
import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js';
|
||||||
|
import { storeCredential, type Platform } from './multitenancy/credential-store.js';
|
||||||
|
import { meterMiddleware, type Customer } from './billing/middleware.js';
|
||||||
import {
|
import {
|
||||||
registerClient,
|
registerClient,
|
||||||
getClient,
|
getClient,
|
||||||
@@ -580,6 +583,121 @@ app.get('/api/whatsapp/templates', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── WhatsApp webhook (multi-tenant) ─────────────────────────────
|
||||||
|
async function handleInboundWhatsAppMessage(event: RoutedWebhookEvent): Promise<void> {
|
||||||
|
console.log(`[webhook/whatsapp] inbound message from=${event.message.from} customer=${event.customerId} type=${event.message.type}`);
|
||||||
|
// Future: route to customer's agent or queue for processing
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsApp webhook verification (GET)
|
||||||
|
app.get('/webhook/whatsapp', (req, res) => {
|
||||||
|
const mode = req.query['hub.mode'];
|
||||||
|
const token = req.query['hub.verify_token'];
|
||||||
|
const challenge = req.query['hub.challenge'];
|
||||||
|
|
||||||
|
if (mode === 'subscribe' && token === process.env.WA_VERIFY_TOKEN) {
|
||||||
|
res.status(200).send(challenge);
|
||||||
|
} else {
|
||||||
|
res.status(403).send('Forbidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// WhatsApp webhook delivery (POST) — multi-tenant routed
|
||||||
|
app.post('/webhook/whatsapp', express.json(), async (req, res) => {
|
||||||
|
// Always acknowledge immediately to prevent Meta retries (20s window)
|
||||||
|
res.status(200).send('EVENT_RECEIVED');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await routeWhatsAppWebhook(req.body as Record<string, unknown>);
|
||||||
|
for (const event of events) {
|
||||||
|
await handleInboundWhatsAppMessage(event);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[webhook/whatsapp] routing error:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Customer onboarding endpoints ───────────────────────────────
|
||||||
|
|
||||||
|
// Connect WhatsApp — called after customer enters their Meta credentials
|
||||||
|
app.post('/api/connect/whatsapp', meterMiddleware, async (req, res) => {
|
||||||
|
const customer = (req as unknown as { customer: Customer }).customer;
|
||||||
|
const { phoneNumberId, accessToken, businessAccountId } = req.body as Record<string, string>;
|
||||||
|
|
||||||
|
if (!phoneNumberId || !accessToken || !businessAccountId) {
|
||||||
|
res.status(400).json({ error: 'missing_fields' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storeCredential(customer.id, 'whatsapp', { phoneNumberId, accessToken, businessAccountId });
|
||||||
|
await registerWhatsAppNumber(customer.id, phoneNumberId);
|
||||||
|
|
||||||
|
res.json({ connected: true, platform: 'whatsapp' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect email (IMAP/SMTP)
|
||||||
|
app.post('/api/connect/email', meterMiddleware, async (req, res) => {
|
||||||
|
const customer = (req as unknown as { customer: Customer }).customer;
|
||||||
|
const { host, port, user, password, smtpHost, smtpPort } = req.body as Record<string, string>;
|
||||||
|
|
||||||
|
if (!host || !port || !user || !password) {
|
||||||
|
res.status(400).json({ error: 'missing_fields' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storeCredential(customer.id, 'email', {
|
||||||
|
host,
|
||||||
|
port: parseInt(port, 10),
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
smtpHost,
|
||||||
|
smtpPort: smtpPort ? parseInt(smtpPort, 10) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ connected: true, platform: 'email' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect OAuth platforms (LinkedIn, Telegram, Discord, Instagram, Twitter)
|
||||||
|
app.post('/api/connect/:platform', meterMiddleware, async (req, res) => {
|
||||||
|
const customer = (req as unknown as { customer: Customer }).customer;
|
||||||
|
const platform = req.params.platform as Platform;
|
||||||
|
const { accessToken, refreshToken, expiresAt, scope } = req.body as Record<string, string>;
|
||||||
|
|
||||||
|
const validPlatforms: Platform[] = ['linkedin', 'telegram', 'discord', 'instagram', 'twitter'];
|
||||||
|
if (!validPlatforms.includes(platform)) {
|
||||||
|
res.status(400).json({ error: 'unknown_platform' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
res.status(400).json({ error: 'missing_fields' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storeCredential(customer.id, platform, {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiresAt: expiresAt ? parseInt(expiresAt, 10) : undefined,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ connected: true, platform });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get connection status for a customer
|
||||||
|
app.get('/api/connections', meterMiddleware, async (req, res) => {
|
||||||
|
const customer = (req as unknown as { customer: Customer }).customer;
|
||||||
|
const platforms: Platform[] = ['email', 'whatsapp', 'linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'obsidian'];
|
||||||
|
|
||||||
|
const status: Record<string, boolean> = {};
|
||||||
|
for (const platform of platforms) {
|
||||||
|
const cred = await customer.getCredential(platform);
|
||||||
|
status[platform] = cred !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ customerId: customer.id, connections: status });
|
||||||
|
});
|
||||||
|
|
||||||
// ── LinkedIn REST endpoints ─────────────────────────────────────
|
// ── LinkedIn REST endpoints ─────────────────────────────────────
|
||||||
app.get('/api/linkedin/profile', requireAuth, async (req, res) => {
|
app.get('/api/linkedin/profile', requireAuth, async (req, res) => {
|
||||||
const account = req.query.account as string | undefined;
|
const account = req.query.account as string | undefined;
|
||||||
|
|||||||
325
src/manifest.ts
325
src/manifest.ts
@@ -186,6 +186,329 @@ export function getOpenApiSpec(serverUrl: string) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Email ───────────────────────────────────────────────────
|
||||||
|
'/api/email/profile': {
|
||||||
|
get: {
|
||||||
|
operationId: 'get_profile',
|
||||||
|
summary: 'Get email account profile',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' }, description: 'Mailbox account (yahoo, fetcherpay, garfield, sales, leads, founder, gmail)' },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'Profile info' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/email/search': {
|
||||||
|
get: {
|
||||||
|
operationId: 'search_messages',
|
||||||
|
summary: 'Search email messages',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'q', in: 'query', required: true, schema: { type: 'string' } },
|
||||||
|
{ name: 'maxResults', in: 'query', schema: { type: 'integer', default: 20 } },
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' } },
|
||||||
|
{ name: 'folder', in: 'query', schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'Search results' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/email/read': {
|
||||||
|
get: {
|
||||||
|
operationId: 'read_message',
|
||||||
|
summary: 'Read email by UID',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'uid', in: 'query', required: true, schema: { type: 'integer' } },
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' } },
|
||||||
|
{ name: 'folder', in: 'query', schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'Message body' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/email/send': {
|
||||||
|
post: {
|
||||||
|
operationId: 'send_email',
|
||||||
|
summary: 'Send email',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['to', 'subject', 'body'],
|
||||||
|
properties: {
|
||||||
|
to: { type: 'string' },
|
||||||
|
subject: { type: 'string' },
|
||||||
|
body: { type: 'string' },
|
||||||
|
account: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { '200': { description: 'Email sent' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── WhatsApp ────────────────────────────────────────────────
|
||||||
|
'/api/whatsapp/send': {
|
||||||
|
post: {
|
||||||
|
operationId: 'whatsapp_send_message',
|
||||||
|
summary: 'Send WhatsApp message',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['to', 'message'],
|
||||||
|
properties: {
|
||||||
|
to: { type: 'string', description: 'Phone number in international format' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
account: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { '200': { description: 'Message sent' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/whatsapp/template': {
|
||||||
|
post: {
|
||||||
|
operationId: 'whatsapp_send_template',
|
||||||
|
summary: 'Send WhatsApp template',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['to', 'template_name'],
|
||||||
|
properties: {
|
||||||
|
to: { type: 'string' },
|
||||||
|
template_name: { type: 'string' },
|
||||||
|
language: { type: 'string' },
|
||||||
|
components: { type: 'array', items: { type: 'object' } },
|
||||||
|
account: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { '200': { description: 'Template sent' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/whatsapp/templates': {
|
||||||
|
get: {
|
||||||
|
operationId: 'whatsapp_list_templates',
|
||||||
|
summary: 'List WhatsApp templates',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'Template list' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── LinkedIn ────────────────────────────────────────────────
|
||||||
|
'/api/linkedin/profile': {
|
||||||
|
get: {
|
||||||
|
operationId: 'linkedin_get_profile',
|
||||||
|
summary: 'Get LinkedIn profile',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'Profile info' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/linkedin/post': {
|
||||||
|
post: {
|
||||||
|
operationId: 'linkedin_create_post',
|
||||||
|
summary: 'Create LinkedIn post',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['text'],
|
||||||
|
properties: {
|
||||||
|
text: { type: 'string' },
|
||||||
|
visibility: { type: 'string', enum: ['PUBLIC', 'CONNECTIONS'] },
|
||||||
|
account: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { '200': { description: 'Post created' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Telegram ────────────────────────────────────────────────
|
||||||
|
'/api/telegram/message': {
|
||||||
|
post: {
|
||||||
|
operationId: 'telegram_send_message',
|
||||||
|
summary: 'Send Telegram message',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['chat_id', 'text'],
|
||||||
|
properties: {
|
||||||
|
chat_id: { type: 'string' },
|
||||||
|
text: { type: 'string' },
|
||||||
|
parse_mode: { type: 'string' },
|
||||||
|
account: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { '200': { description: 'Message sent' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/telegram/updates': {
|
||||||
|
get: {
|
||||||
|
operationId: 'telegram_get_updates',
|
||||||
|
summary: 'Get Telegram updates',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'Updates list' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Discord ─────────────────────────────────────────────────
|
||||||
|
'/api/discord/guilds': {
|
||||||
|
get: {
|
||||||
|
operationId: 'discord_get_guilds',
|
||||||
|
summary: 'List Discord servers',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'Guild list' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/discord/message': {
|
||||||
|
post: {
|
||||||
|
operationId: 'discord_send_message',
|
||||||
|
summary: 'Send Discord message',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['channel_id', 'content'],
|
||||||
|
properties: {
|
||||||
|
channel_id: { type: 'string' },
|
||||||
|
content: { type: 'string' },
|
||||||
|
account: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { '200': { description: 'Message sent' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/discord/messages': {
|
||||||
|
get: {
|
||||||
|
operationId: 'discord_get_messages',
|
||||||
|
summary: 'Get Discord messages',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'channel_id', in: 'query', required: true, schema: { type: 'string' } },
|
||||||
|
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'Message list' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Instagram ───────────────────────────────────────────────
|
||||||
|
'/api/instagram/profile': {
|
||||||
|
get: {
|
||||||
|
operationId: 'instagram_get_profile',
|
||||||
|
summary: 'Get Instagram profile',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'Profile info' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/instagram/media': {
|
||||||
|
get: {
|
||||||
|
operationId: 'instagram_get_media',
|
||||||
|
summary: 'Get Instagram media',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'Media list' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/instagram/post': {
|
||||||
|
post: {
|
||||||
|
operationId: 'instagram_create_post',
|
||||||
|
summary: 'Create Instagram post',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['image_url'],
|
||||||
|
properties: {
|
||||||
|
image_url: { type: 'string' },
|
||||||
|
caption: { type: 'string' },
|
||||||
|
account: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { '200': { description: 'Post created' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Twitter/X ───────────────────────────────────────────────
|
||||||
|
'/api/twitter/search': {
|
||||||
|
get: {
|
||||||
|
operationId: 'twitter_search_tweets',
|
||||||
|
summary: 'Search tweets',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'query', in: 'query', required: true, schema: { type: 'string' } },
|
||||||
|
{ name: 'max_results', in: 'query', schema: { type: 'integer' } },
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'Tweet list' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/twitter/user': {
|
||||||
|
get: {
|
||||||
|
operationId: 'twitter_get_user_profile',
|
||||||
|
summary: 'Get Twitter user profile',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'username', in: 'query', required: true, schema: { type: 'string' } },
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'User profile' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/twitter/tweets': {
|
||||||
|
get: {
|
||||||
|
operationId: 'twitter_get_user_tweets',
|
||||||
|
summary: 'Get user tweets',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'username', in: 'query', required: true, schema: { type: 'string' } },
|
||||||
|
{ name: 'max_results', in: 'query', schema: { type: 'integer' } },
|
||||||
|
{ name: 'account', in: 'query', schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: 'Tweet list' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -428,7 +751,7 @@ export function getManifest(serverUrl: string, authEnabled: boolean) {
|
|||||||
to: { type: 'string', description: 'Recipient phone number in international format' },
|
to: { type: 'string', description: 'Recipient phone number in international format' },
|
||||||
template_name: { type: 'string', description: 'Name of the approved WhatsApp template' },
|
template_name: { type: 'string', description: 'Name of the approved WhatsApp template' },
|
||||||
language: { type: 'string', description: 'Template language code (default: "en")' },
|
language: { type: 'string', description: 'Template language code (default: "en")' },
|
||||||
components: { type: 'array', description: 'Template components (header, body, buttons) with parameters' },
|
components: { type: 'array', items: { type: 'object' }, description: 'Template components (header, body, buttons) with parameters' },
|
||||||
account: { type: 'string', description: 'Which WhatsApp account to use (default: "default")' },
|
account: { type: 'string', description: 'Which WhatsApp account to use (default: "default")' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
81
src/multitenancy/audit-log.ts
Normal file
81
src/multitenancy/audit-log.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { createClient } from 'redis';
|
||||||
|
|
||||||
|
const redis = createClient({ url: process.env.REDIS_URL });
|
||||||
|
redis.connect().catch((err) => console.error('[audit-log] Redis connect error:', err));
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
customerId: string;
|
||||||
|
tool: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
result: 'success' | 'error';
|
||||||
|
errorMessage?: string;
|
||||||
|
durationMs: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retain 90 days of logs per customer. Key: logs:{customerId}:{ISO-date}:{seq}
|
||||||
|
export async function logToolCall(entry: AuditEntry): Promise<void> {
|
||||||
|
const date = entry.timestamp.slice(0, 10); // YYYY-MM-DD
|
||||||
|
const seqKey = `log_seq:${entry.customerId}:${date}`;
|
||||||
|
const seq = await redis.incr(seqKey);
|
||||||
|
// Expire the sequence key after 95 days so it cleans up
|
||||||
|
if (seq === 1) await redis.expire(seqKey, 60 * 60 * 24 * 95);
|
||||||
|
|
||||||
|
const logKey = `logs:${entry.customerId}:${date}:${String(seq).padStart(8, '0')}`;
|
||||||
|
await redis.set(logKey, JSON.stringify(entry), { EX: 60 * 60 * 24 * 90 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve logs for one customer for a date range — customerId is REQUIRED
|
||||||
|
export async function getCustomerLogs(
|
||||||
|
customerId: string,
|
||||||
|
fromDate: string, // YYYY-MM-DD
|
||||||
|
toDate: string // YYYY-MM-DD
|
||||||
|
): Promise<AuditEntry[]> {
|
||||||
|
const dates = getDatesInRange(fromDate, toDate);
|
||||||
|
const entries: AuditEntry[] = [];
|
||||||
|
|
||||||
|
for (const date of dates) {
|
||||||
|
const pattern = `logs:${customerId}:${date}:*`;
|
||||||
|
const keys = await redis.keys(pattern);
|
||||||
|
for (const key of keys) {
|
||||||
|
const raw = await redis.get(key);
|
||||||
|
if (raw) entries.push(JSON.parse(raw) as AuditEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDatesInRange(from: string, to: string): string[] {
|
||||||
|
const dates: string[] = [];
|
||||||
|
const current = new Date(from);
|
||||||
|
const end = new Date(to);
|
||||||
|
while (current <= end) {
|
||||||
|
dates.push(current.toISOString().slice(0, 10));
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience wrapper for tool handlers — call before and after
|
||||||
|
export function createToolAudit(customerId: string, tool: string) {
|
||||||
|
const start = Date.now();
|
||||||
|
return {
|
||||||
|
success: async (args: Record<string, unknown>) => {
|
||||||
|
await logToolCall({
|
||||||
|
customerId, tool, args,
|
||||||
|
result: 'success',
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: async (args: Record<string, unknown>, errorMessage: string) => {
|
||||||
|
await logToolCall({
|
||||||
|
customerId, tool, args,
|
||||||
|
result: 'error', errorMessage,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
84
src/multitenancy/credential-store.ts
Normal file
84
src/multitenancy/credential-store.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
|
||||||
|
const redis = createClient({ url: process.env.REDIS_URL });
|
||||||
|
redis.connect().catch((err) => console.error('[credential-store] Redis connect error:', err));
|
||||||
|
|
||||||
|
const ENCRYPTION_KEY = Buffer.from(process.env.CREDENTIAL_ENCRYPTION_KEY ?? '0'.repeat(64), 'hex');
|
||||||
|
// CREDENTIAL_ENCRYPTION_KEY must be a 64-char hex string (32 bytes)
|
||||||
|
// Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
|
||||||
|
function encrypt(plaintext: string): string {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(ciphertext: string): string {
|
||||||
|
const [ivHex, tagHex, encHex] = ciphertext.split(':');
|
||||||
|
const iv = Buffer.from(ivHex, 'hex');
|
||||||
|
const tag = Buffer.from(tagHex, 'hex');
|
||||||
|
const encrypted = Buffer.from(encHex, 'hex');
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return decipher.update(encrypted) + decipher.final('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Platform = 'email' | 'whatsapp' | 'linkedin' | 'telegram' | 'discord' | 'instagram' | 'twitter' | 'obsidian';
|
||||||
|
|
||||||
|
export interface EmailCredentials {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
smtpHost?: string;
|
||||||
|
smtpPort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthCredentials {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhatsAppCredentials {
|
||||||
|
phoneNumberId: string;
|
||||||
|
accessToken: string;
|
||||||
|
businessAccountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlatformCredentials = EmailCredentials | OAuthCredentials | WhatsAppCredentials | Record<string, string>;
|
||||||
|
|
||||||
|
export async function storeCredential(
|
||||||
|
customerId: string,
|
||||||
|
platform: Platform,
|
||||||
|
credentials: PlatformCredentials
|
||||||
|
): Promise<void> {
|
||||||
|
const key = `creds:${customerId}:${platform}`;
|
||||||
|
const encrypted = encrypt(JSON.stringify(credentials));
|
||||||
|
// Credentials don't expire — they persist until explicitly revoked
|
||||||
|
await redis.set(key, encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCredential<T extends PlatformCredentials>(
|
||||||
|
customerId: string,
|
||||||
|
platform: Platform
|
||||||
|
): Promise<T | null> {
|
||||||
|
const key = `creds:${customerId}:${platform}`;
|
||||||
|
const encrypted = await redis.get(key);
|
||||||
|
if (!encrypted) return null;
|
||||||
|
return JSON.parse(decrypt(encrypted)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeCredential(customerId: string, platform: Platform): Promise<void> {
|
||||||
|
await redis.del(`creds:${customerId}:${platform}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeAllCredentials(customerId: string): Promise<void> {
|
||||||
|
const pattern = `creds:${customerId}:*`;
|
||||||
|
const keys = await redis.keys(pattern);
|
||||||
|
if (keys.length > 0) await redis.del(keys);
|
||||||
|
}
|
||||||
104
src/multitenancy/webhook-router.ts
Normal file
104
src/multitenancy/webhook-router.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { createClient } from 'redis';
|
||||||
|
import { getCredential, WhatsAppCredentials } from './credential-store.js';
|
||||||
|
|
||||||
|
const redis = createClient({ url: process.env.REDIS_URL });
|
||||||
|
redis.connect().catch((err) => console.error('[webhook-router] Redis connect error:', err));
|
||||||
|
|
||||||
|
// Call this at customer onboarding when they connect their WhatsApp Business number
|
||||||
|
export async function registerWhatsAppNumber(
|
||||||
|
customerId: string,
|
||||||
|
phoneNumberId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await redis.set(`wa_phone_id:${phoneNumberId}`, customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterWhatsAppNumber(phoneNumberId: string): Promise<void> {
|
||||||
|
await redis.del(`wa_phone_id:${phoneNumberId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveCustomerFromPhoneNumberId(
|
||||||
|
phoneNumberId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
return redis.get(`wa_phone_id:${phoneNumberId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsApp Cloud API webhook payload types
|
||||||
|
interface WhatsAppWebhookEntry {
|
||||||
|
id: string;
|
||||||
|
changes: Array<{
|
||||||
|
value: {
|
||||||
|
messaging_product: string;
|
||||||
|
metadata: {
|
||||||
|
display_phone_number: string;
|
||||||
|
phone_number_id: string;
|
||||||
|
};
|
||||||
|
messages?: WhatsAppInboundMessage[];
|
||||||
|
statuses?: WhatsAppStatus[];
|
||||||
|
};
|
||||||
|
field: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhatsAppInboundMessage {
|
||||||
|
from: string;
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
text?: { body: string };
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WhatsAppStatus {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
timestamp: string;
|
||||||
|
recipient_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutedWebhookEvent {
|
||||||
|
customerId: string;
|
||||||
|
phoneNumberId: string;
|
||||||
|
message: WhatsAppInboundMessage;
|
||||||
|
credentials: WhatsAppCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the incoming webhook and return one routed event per message
|
||||||
|
export async function routeWhatsAppWebhook(
|
||||||
|
body: Record<string, unknown>
|
||||||
|
): Promise<RoutedWebhookEvent[]> {
|
||||||
|
const events: RoutedWebhookEvent[] = [];
|
||||||
|
|
||||||
|
if (body.object !== 'whatsapp_business_account') return events;
|
||||||
|
|
||||||
|
const entries = (body.entry as WhatsAppWebhookEntry[]) ?? [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
for (const change of entry.changes) {
|
||||||
|
if (change.field !== 'messages') continue;
|
||||||
|
|
||||||
|
const { phone_number_id } = change.value.metadata;
|
||||||
|
const messages = change.value.messages ?? [];
|
||||||
|
|
||||||
|
if (messages.length === 0) continue;
|
||||||
|
|
||||||
|
// Resolve which customer owns this phone number
|
||||||
|
const customerId = await resolveCustomerFromPhoneNumberId(phone_number_id);
|
||||||
|
if (!customerId) {
|
||||||
|
console.warn(`[webhook-router] Unroutable WhatsApp message to phone_number_id=${phone_number_id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load that customer's WhatsApp credentials
|
||||||
|
const credentials = await getCredential<WhatsAppCredentials>(customerId, 'whatsapp');
|
||||||
|
if (!credentials) {
|
||||||
|
console.error(`[webhook-router] No WhatsApp credentials for customerId=${customerId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
events.push({ customerId, phoneNumberId: phone_number_id, message, credentials });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
56
src/tools.ts
56
src/tools.ts
@@ -1,4 +1,5 @@
|
|||||||
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { Customer } from './billing/middleware.js';
|
||||||
import { searchMessages, readMessage, getProfile, listFolders, type Account } from './imap.js';
|
import { searchMessages, readMessage, getProfile, listFolders, type Account } from './imap.js';
|
||||||
import { sendEmail, createDraft } from './smtp.js';
|
import { sendEmail, createDraft } from './smtp.js';
|
||||||
import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.js';
|
import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.js';
|
||||||
@@ -206,7 +207,7 @@ export const tools: Tool[] = [
|
|||||||
to: { type: 'string', description: 'Recipient phone number in international format' },
|
to: { type: 'string', description: 'Recipient phone number in international format' },
|
||||||
template_name: { type: 'string', description: 'Name of the approved WhatsApp template' },
|
template_name: { type: 'string', description: 'Name of the approved WhatsApp template' },
|
||||||
language: { type: 'string', description: 'Template language code (default: "en")' },
|
language: { type: 'string', description: 'Template language code (default: "en")' },
|
||||||
components: { type: 'array', description: 'Template components (header, body, buttons) with parameters' },
|
components: { type: 'array', items: { type: 'object' }, description: 'Template components (header, body, buttons) with parameters' },
|
||||||
account: { type: 'string', description: 'Which WhatsApp account to use (default: "default")' },
|
account: { type: 'string', description: 'Which WhatsApp account to use (default: "default")' },
|
||||||
},
|
},
|
||||||
required: ['to', 'template_name'],
|
required: ['to', 'template_name'],
|
||||||
@@ -524,7 +525,8 @@ function acct(args: Record<string, unknown>): Account {
|
|||||||
|
|
||||||
export async function handleToolCall(
|
export async function handleToolCall(
|
||||||
name: string,
|
name: string,
|
||||||
args: Record<string, unknown>
|
args: Record<string, unknown>,
|
||||||
|
customer?: Customer
|
||||||
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
||||||
console.log(`[tool] ${name}`, JSON.stringify(args));
|
console.log(`[tool] ${name}`, JSON.stringify(args));
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
@@ -593,7 +595,7 @@ export async function handleToolCall(
|
|||||||
to: args.to as string,
|
to: args.to as string,
|
||||||
message: args.message as string,
|
message: args.message as string,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'whatsapp_send_template':
|
case 'whatsapp_send_template':
|
||||||
@@ -603,27 +605,27 @@ export async function handleToolCall(
|
|||||||
language: args.language as string | undefined,
|
language: args.language as string | undefined,
|
||||||
components: args.components as unknown[] | undefined,
|
components: args.components as unknown[] | undefined,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'whatsapp_get_message_status':
|
case 'whatsapp_get_message_status':
|
||||||
result = await getMessageStatus({
|
result = await getMessageStatus({
|
||||||
message_id: args.message_id as string,
|
message_id: args.message_id as string,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'whatsapp_list_templates':
|
case 'whatsapp_list_templates':
|
||||||
result = await listTemplates({
|
result = await listTemplates({
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ── LinkedIn ───────────────────────────────────────────────
|
// ── LinkedIn ───────────────────────────────────────────────
|
||||||
case 'linkedin_get_profile':
|
case 'linkedin_get_profile':
|
||||||
result = await getLinkedInProfile({
|
result = await getLinkedInProfile({
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'linkedin_create_post':
|
case 'linkedin_create_post':
|
||||||
@@ -631,14 +633,14 @@ export async function handleToolCall(
|
|||||||
text: args.text as string,
|
text: args.text as string,
|
||||||
visibility: (args.visibility as 'PUBLIC' | 'CONNECTIONS') ?? 'PUBLIC',
|
visibility: (args.visibility as 'PUBLIC' | 'CONNECTIONS') ?? 'PUBLIC',
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'linkedin_search_connections':
|
case 'linkedin_search_connections':
|
||||||
result = await searchConnections({
|
result = await searchConnections({
|
||||||
keywords: args.keywords as string | undefined,
|
keywords: args.keywords as string | undefined,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'linkedin_send_message':
|
case 'linkedin_send_message':
|
||||||
@@ -646,14 +648,14 @@ export async function handleToolCall(
|
|||||||
recipient_id: args.recipient_id as string,
|
recipient_id: args.recipient_id as string,
|
||||||
message: args.message as string,
|
message: args.message as string,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ── Telegram ───────────────────────────────────────────────
|
// ── Telegram ───────────────────────────────────────────────
|
||||||
case 'telegram_get_me':
|
case 'telegram_get_me':
|
||||||
result = await getTelegramMe({
|
result = await getTelegramMe({
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'telegram_send_message':
|
case 'telegram_send_message':
|
||||||
@@ -662,7 +664,7 @@ export async function handleToolCall(
|
|||||||
text: args.text as string,
|
text: args.text as string,
|
||||||
parse_mode: (args.parse_mode as 'HTML' | 'Markdown' | 'MarkdownV2') ?? undefined,
|
parse_mode: (args.parse_mode as 'HTML' | 'Markdown' | 'MarkdownV2') ?? undefined,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'telegram_send_photo':
|
case 'telegram_send_photo':
|
||||||
@@ -671,41 +673,41 @@ export async function handleToolCall(
|
|||||||
photo: args.photo as string,
|
photo: args.photo as string,
|
||||||
caption: args.caption as string | undefined,
|
caption: args.caption as string | undefined,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'telegram_get_updates':
|
case 'telegram_get_updates':
|
||||||
result = await getTelegramUpdates({
|
result = await getTelegramUpdates({
|
||||||
limit: (args.limit as number) ?? 10,
|
limit: (args.limit as number) ?? 10,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'telegram_get_chat':
|
case 'telegram_get_chat':
|
||||||
result = await getTelegramChat({
|
result = await getTelegramChat({
|
||||||
chat_id: args.chat_id as string | number,
|
chat_id: args.chat_id as string | number,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ── Discord ─────────────────────────────────────────────────
|
// ── Discord ─────────────────────────────────────────────────
|
||||||
case 'discord_get_me':
|
case 'discord_get_me':
|
||||||
result = await getDiscordMe({
|
result = await getDiscordMe({
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'discord_get_guilds':
|
case 'discord_get_guilds':
|
||||||
result = await getGuilds({
|
result = await getGuilds({
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'discord_get_channels':
|
case 'discord_get_channels':
|
||||||
result = await getChannels({
|
result = await getChannels({
|
||||||
guild_id: args.guild_id as string,
|
guild_id: args.guild_id as string,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'discord_send_message':
|
case 'discord_send_message':
|
||||||
@@ -713,7 +715,7 @@ export async function handleToolCall(
|
|||||||
channel_id: args.channel_id as string,
|
channel_id: args.channel_id as string,
|
||||||
content: args.content as string,
|
content: args.content as string,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'discord_get_messages':
|
case 'discord_get_messages':
|
||||||
@@ -721,21 +723,21 @@ export async function handleToolCall(
|
|||||||
channel_id: args.channel_id as string,
|
channel_id: args.channel_id as string,
|
||||||
limit: (args.limit as number) ?? 10,
|
limit: (args.limit as number) ?? 10,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ── Instagram ───────────────────────────────────────────────
|
// ── Instagram ───────────────────────────────────────────────
|
||||||
case 'instagram_get_profile':
|
case 'instagram_get_profile':
|
||||||
result = await getInstagramProfile({
|
result = await getInstagramProfile({
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'instagram_get_media':
|
case 'instagram_get_media':
|
||||||
result = await getInstagramMedia({
|
result = await getInstagramMedia({
|
||||||
limit: (args.limit as number) ?? 10,
|
limit: (args.limit as number) ?? 10,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'instagram_create_post':
|
case 'instagram_create_post':
|
||||||
@@ -743,7 +745,7 @@ export async function handleToolCall(
|
|||||||
image_url: args.image_url as string,
|
image_url: args.image_url as string,
|
||||||
caption: args.caption as string | undefined,
|
caption: args.caption as string | undefined,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ── Twitter/X ───────────────────────────────────────────────
|
// ── Twitter/X ───────────────────────────────────────────────
|
||||||
@@ -752,14 +754,14 @@ export async function handleToolCall(
|
|||||||
query: args.query as string,
|
query: args.query as string,
|
||||||
max_results: (args.max_results as number) ?? 10,
|
max_results: (args.max_results as number) ?? 10,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'twitter_get_user_profile':
|
case 'twitter_get_user_profile':
|
||||||
result = await getUserProfile({
|
result = await getUserProfile({
|
||||||
username: args.username as string,
|
username: args.username as string,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'twitter_get_user_tweets':
|
case 'twitter_get_user_tweets':
|
||||||
@@ -767,14 +769,14 @@ export async function handleToolCall(
|
|||||||
username: args.username as string,
|
username: args.username as string,
|
||||||
max_results: (args.max_results as number) ?? 10,
|
max_results: (args.max_results as number) ?? 10,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'twitter_create_tweet':
|
case 'twitter_create_tweet':
|
||||||
result = await createTweet({
|
result = await createTweet({
|
||||||
text: args.text as string,
|
text: args.text as string,
|
||||||
account: args.account as string | undefined,
|
account: args.account as string | undefined,
|
||||||
});
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Legacy Yahoo-prefixed names — keep working for any cached Claude sessions
|
// Legacy Yahoo-prefixed names — keep working for any cached Claude sessions
|
||||||
|
|||||||
Reference in New Issue
Block a user