feat: initial files
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Copy to .env for local development — never commit .env
|
||||
PORT=3000
|
||||
|
||||
# Meta WhatsApp Cloud API
|
||||
WHATSAPP_TOKEN=
|
||||
# Meta's numeric Phone Number ID from the Developer Console — NOT the phone number itself
|
||||
WA_PHONE_NUMBER_ID=
|
||||
VERIFY_TOKEN=my-secret-verify-token
|
||||
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Chatwoot
|
||||
CHATWOOT_URL=https://chatwoot.example.com
|
||||
CHATWOOT_API_KEY=
|
||||
CHATWOOT_ACCOUNT_ID=
|
||||
CHATWOOT_INBOX_ID=
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/rmcampos/shell-whats
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE }}:latest
|
||||
${{ env.IMAGE }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,139 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: [Build and Push]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/rmcampos/shell-whats
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
outputs:
|
||||
no_changes: ${{ steps.check-changes.outputs.no_changes }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: Write kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Terraform Fmt -check -diff
|
||||
working-directory: terraform
|
||||
run: terraform fmt -check -diff
|
||||
|
||||
- name: Terraform init
|
||||
working-directory: terraform
|
||||
run: terraform init -input=false
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
- name: Terraform Validate
|
||||
working-directory: terraform
|
||||
run: terraform validate
|
||||
|
||||
- name: Terraform Plan
|
||||
id: check-changes
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
timeout 1m terraform plan -input=false -out=tfplan \
|
||||
-var="db_user=${{ secrets.DB_USER }}" \
|
||||
-var="db_password=${{ secrets.DB_PASSWORD }}" \
|
||||
-var="db_name=${{ secrets.DB_NAME }}" \
|
||||
-var="security_key=${{ secrets.JWT_SECURITY_KEY }}" \
|
||||
-var="mailgun_apikey=${{ secrets.MAILGUN_API_KEY }}" \
|
||||
-var="r2_access_key=${{ secrets.R2_ACCESS_KEY_ID }}" \
|
||||
-var="r2_secret_key=${{ secrets.R2_SECRET_ACCESS_KEY }}" \
|
||||
-var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \
|
||||
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}"
|
||||
terraform show -json tfplan > tfplan.json
|
||||
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
|
||||
echo "no_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to apply."
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected. Proceeding with apply"
|
||||
echo "no_changes=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload plan artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform/tfplan
|
||||
|
||||
terraform-apply:
|
||||
runs-on: ubuntu-latest
|
||||
needs: terraform-plan
|
||||
if: >
|
||||
(github.event_name == 'push' || github.event_name == 'workflow_run' || inputs.apply == 'true')
|
||||
&& needs.terraform-plan.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: production
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Download plan artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform apply
|
||||
working-directory: terraform
|
||||
env:
|
||||
TF_VAR_image: "${{ env.IMAGE }}:${{ github.event.workflow_run.head_sha }}"
|
||||
TF_VAR_whatsapp_token: ${{ secrets.WHATSAPP_TOKEN }}
|
||||
TF_VAR_anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
TF_VAR_chatwoot_api_key: ${{ secrets.CHATWOOT_API_KEY }}
|
||||
TF_VAR_phone_number_id: ${{ secrets.PHONE_NUMBER_ID }}
|
||||
TF_VAR_verify_token: ${{ secrets.VERIFY_TOKEN }}
|
||||
TF_VAR_chatwoot_url: ${{ secrets.CHATWOOT_URL }}
|
||||
TF_VAR_chatwoot_inbox_id: ${{ secrets.CHATWOOT_INBOX_ID }}
|
||||
TF_VAR_chatwoot_account_id: ${{ secrets.CHATWOOT_ACCOUNT_ID }}
|
||||
TF_VAR_kubeconfig_path: "~/.kube/config"
|
||||
TF_VAR_kubeconfig_context: ${{ vars.KUBECONFIG_CONTEXT }}
|
||||
TF_VAR_namespace: ${{ vars.K8S_NAMESPACE || 'default' }}
|
||||
run: timeout 1m terraform apply tfplan
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.env
|
||||
terraform/.terraform/
|
||||
terraform/terraform.tfvars
|
||||
terraform/*.tfstate
|
||||
terraform/*.tfstate.backup
|
||||
terraform/.terraform.lock.hcl
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GoogleJavaFormatSettings">
|
||||
<option name="enabled" value="false" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KubernetesApiProvider">{}</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="25" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/shell-whats.iml" filepath="$PROJECT_DIR$/.idea/shell-whats.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY src ./src
|
||||
COPY package.json ./
|
||||
|
||||
EXPOSE 3000
|
||||
USER node
|
||||
CMD ["node", "src/index.js"]
|
||||
Generated
+1036
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "shell-whats",
|
||||
"version": "1.0.0",
|
||||
"description": "WhatsApp Business API chatbot with Claude AI and Chatwoot handoff",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0",
|
||||
"axios": "^1.9.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.19.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
const { getSession } = require('../store/conversations');
|
||||
const { sendText, sendInteractiveButtons } = require('../services/whatsapp');
|
||||
const { chat } = require('../services/claude');
|
||||
const { createConversation } = require('../services/chatwoot');
|
||||
|
||||
const WELCOME_TEXT =
|
||||
'Olá! 👋 Bem-vindo ao nosso suporte. Como podemos ajudá-lo hoje?';
|
||||
|
||||
const WELCOME_BUTTONS = [
|
||||
{ id: 'btn_products', title: '🛍️ Produtos/Preços' },
|
||||
{ id: 'btn_support', title: '🔧 Suporte Técnico' },
|
||||
{ id: 'btn_human', title: '👤 Falar com Agente' },
|
||||
];
|
||||
|
||||
const HUMAN_HANDOFF_REPLIES = new Set(['btn_human', 'humano', 'agente', 'atendente', 'falar com agente', 'falar com atendente']);
|
||||
|
||||
async function handleMessage({ message, phoneNumber, displayName }) {
|
||||
const session = getSession(phoneNumber);
|
||||
|
||||
// Already handed off — silently ignore (Chatwoot takes over)
|
||||
if (session.handedOff) return;
|
||||
|
||||
const msgType = message.type;
|
||||
|
||||
// Extract text from text or interactive button reply
|
||||
let userText = '';
|
||||
let buttonId = null;
|
||||
|
||||
if (msgType === 'text') {
|
||||
userText = message.text?.body?.trim() || '';
|
||||
} else if (msgType === 'interactive') {
|
||||
const reply = message.interactive?.button_reply;
|
||||
buttonId = reply?.id || '';
|
||||
userText = reply?.title || '';
|
||||
} else {
|
||||
// Unsupported message type
|
||||
try {
|
||||
await sendText(phoneNumber, 'Desculpe, no momento só consigo processar mensagens de texto.');
|
||||
} catch (err) {
|
||||
console.error('Meta API error details:', JSON.stringify(err.response?.data, null, 2));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// First contact — send welcome menu
|
||||
if (!session.greeted) {
|
||||
session.greeted = true;
|
||||
await sendInteractiveButtons(phoneNumber, WELCOME_TEXT, WELCOME_BUTTONS);
|
||||
return;
|
||||
}
|
||||
|
||||
// Human handoff requested
|
||||
const isHandoffRequest =
|
||||
HUMAN_HANDOFF_REPLIES.has(buttonId) ||
|
||||
HUMAN_HANDOFF_REPLIES.has(userText.toLowerCase());
|
||||
|
||||
if (isHandoffRequest) {
|
||||
session.handedOff = true;
|
||||
console.log(`[HANDOFF] ${displayName} (${phoneNumber}) requested a human agent`);
|
||||
await sendText(
|
||||
phoneNumber,
|
||||
'Estamos transferindo você para um de nossos atendentes. Por favor, aguarde — em breve alguém estará com você. 🙏\n\nQuando o atendimento for encerrado, envie qualquer mensagem para iniciar uma nova conversa.'
|
||||
);
|
||||
try {
|
||||
const conversationId = await createConversation(phoneNumber, displayName, session.history);
|
||||
console.log(`[HANDOFF] Chatwoot conversation created (id=${conversationId}) for ${displayName} (${phoneNumber})`);
|
||||
} catch (err) {
|
||||
console.error('Chatwoot handoff failed:', err?.response?.data || err.message);
|
||||
console.error('Meta API error details:', JSON.stringify(err.response?.data, null, 2));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Route to Claude
|
||||
try {
|
||||
const reply = await chat(session.history, userText);
|
||||
|
||||
// Persist turn in history
|
||||
session.history.push({ role: 'user', content: userText });
|
||||
session.history.push({ role: 'assistant', content: reply });
|
||||
|
||||
// Keep history bounded to last 20 turns (10 exchanges)
|
||||
if (session.history.length > 20) {
|
||||
session.history = session.history.slice(-20);
|
||||
}
|
||||
|
||||
await sendText(phoneNumber, reply);
|
||||
|
||||
// After every AI reply, offer the human handoff option
|
||||
await sendInteractiveButtons(
|
||||
phoneNumber,
|
||||
'Posso ajudar com mais alguma coisa?',
|
||||
[{ id: 'btn_human', title: '👤 Falar com Agente' }]
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Claude error:', err?.message);
|
||||
console.error('Meta API error details:', JSON.stringify(err.response?.data, null, 2));
|
||||
await sendText(
|
||||
phoneNumber,
|
||||
"Estamos com uma instabilidade no momento. Por favor, tente novamente em instantes ou digite 'atendente' para falar com um de nossos agentes."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleMessage };
|
||||
@@ -0,0 +1,15 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const webhookRouter = require('./routes/webhook');
|
||||
const chatwootWebhookRouter = require('./routes/chatwootWebhook');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/webhook', webhookRouter);
|
||||
app.use('/chatwoot-webhook', chatwootWebhookRouter);
|
||||
|
||||
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
|
||||
@@ -0,0 +1,31 @@
|
||||
const express = require('express');
|
||||
const { sendText } = require('../services/whatsapp');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
res.sendStatus(200);
|
||||
|
||||
try {
|
||||
const { event, message_type, content, private: isPrivate, conversation } = req.body;
|
||||
|
||||
// Only forward outgoing agent messages; ignore private notes and all other events
|
||||
if (event !== 'message_created' || message_type !== 'outgoing' || isPrivate) return;
|
||||
|
||||
const phoneNumber = conversation?.meta?.sender?.phone_number
|
||||
|| conversation?.additional_attributes?.phone_number;
|
||||
|
||||
if (!phoneNumber || !content) return;
|
||||
|
||||
// Strip leading + so it matches the format used throughout the app
|
||||
const to = phoneNumber.replace(/^\+/, '');
|
||||
|
||||
console.log(`Chatwoot agent reply → WhatsApp ${to}: ${content}`);
|
||||
await sendText(to, content);
|
||||
} catch (err) {
|
||||
console.error('Chatwoot webhook error:', err.message);
|
||||
console.error('Meta API error details:', JSON.stringify(err.response?.data, null, 2));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,43 @@
|
||||
const express = require('express');
|
||||
const { handleMessage } = require('../handlers/messageHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Meta webhook verification challenge
|
||||
router.get('/', (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.VERIFY_TOKEN) {
|
||||
console.log('Webhook verified');
|
||||
return res.status(200).send(challenge);
|
||||
}
|
||||
res.sendStatus(403);
|
||||
});
|
||||
|
||||
// Inbound messages
|
||||
router.post('/', async (req, res) => {
|
||||
// Acknowledge immediately — Meta requires 200 within 20s
|
||||
res.sendStatus(200);
|
||||
|
||||
try {
|
||||
const entry = req.body?.entry?.[0];
|
||||
const change = entry?.changes?.[0];
|
||||
const value = change?.value;
|
||||
|
||||
if (!value?.messages?.length) return;
|
||||
|
||||
const message = value.messages[0];
|
||||
const contact = value.contacts?.[0];
|
||||
const phoneNumber = message.from;
|
||||
const displayName = contact?.profile?.name || phoneNumber;
|
||||
|
||||
await handleMessage({ message, phoneNumber, displayName });
|
||||
} catch (err) {
|
||||
console.error('Webhook processing error:', err);
|
||||
console.error('Meta API error details:', JSON.stringify(err.response?.data, null, 2));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,66 @@
|
||||
const axios = require('axios');
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
api_access_token: process.env.CHATWOOT_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
function baseUrl() {
|
||||
return `${process.env.CHATWOOT_URL}/api/v1/accounts/${process.env.CHATWOOT_ACCOUNT_ID}`;
|
||||
}
|
||||
|
||||
async function findOrCreateContact(phoneNumber, displayName) {
|
||||
const search = await axios.get(`${baseUrl()}/contacts/search`, {
|
||||
params: { q: phoneNumber, include_contacts: true },
|
||||
headers: headers(),
|
||||
});
|
||||
|
||||
const existing = search.data?.payload?.find(
|
||||
(c) => c.phone_number === phoneNumber || c.phone_number === `+${phoneNumber}`
|
||||
);
|
||||
|
||||
if (existing) return existing.id;
|
||||
|
||||
const created = await axios.post(
|
||||
`${baseUrl()}/contacts`,
|
||||
{ name: displayName, phone_number: `+${phoneNumber}` },
|
||||
{ headers: headers() }
|
||||
);
|
||||
return created.data.payload.contact.id;
|
||||
}
|
||||
|
||||
async function createConversation(phoneNumber, displayName, history) {
|
||||
const contactId = await findOrCreateContact(phoneNumber, displayName);
|
||||
|
||||
const conversation = await axios.post(
|
||||
`${baseUrl()}/conversations`,
|
||||
{
|
||||
inbox_id: parseInt(process.env.CHATWOOT_INBOX_ID, 10),
|
||||
contact_id: contactId,
|
||||
additional_attributes: { phone_number: phoneNumber },
|
||||
},
|
||||
{ headers: headers() }
|
||||
);
|
||||
|
||||
const conversationId = conversation.data.id;
|
||||
|
||||
const transcript = history.length > 0
|
||||
? history.map((m) => `[${m.role.toUpperCase()}]: ${m.content}`).join('\n\n')
|
||||
: '_Nenhuma mensagem anterior — o cliente solicitou atendimento diretamente._';
|
||||
|
||||
await axios.post(
|
||||
`${baseUrl()}/conversations/${conversationId}/messages`,
|
||||
{
|
||||
content: `📋 *Contexto do Atendimento*\n\n*Cliente:* ${displayName}\n*WhatsApp:* +${phoneNumber}\n\n*Histórico da conversa com IA:*\n\n${transcript}`,
|
||||
message_type: 'outgoing',
|
||||
private: true,
|
||||
},
|
||||
{ headers: headers() }
|
||||
);
|
||||
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
module.exports = { createConversation };
|
||||
@@ -0,0 +1,39 @@
|
||||
const Anthropic = require('@anthropic-ai/sdk');
|
||||
|
||||
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||
|
||||
const SYSTEM_PROMPT = `Você é um assistente de atendimento da Shelllabs, empresa de desenvolvimento de software.
|
||||
|
||||
Serviços oferecidos:
|
||||
1. Desenvolvimento ou customização de software — projetos curtos (até 10 dias): R$ 300/hora
|
||||
2. Desenvolvimento ou customização de software — projetos longos (mais de 10 dias): R$ 250/hora
|
||||
3. Consultoria sob demanda: R$ 180/hora
|
||||
|
||||
Políticas:
|
||||
- Não há política de reembolso.
|
||||
- Não oferecemos outros serviços além dos listados acima.
|
||||
|
||||
Regras:
|
||||
- Responda apenas perguntas relacionadas aos serviços acima. Não se envolva em conversas gerais, tarefas criativas ou qualquer assunto não relacionado ao atendimento.
|
||||
- Se o cliente perguntar sobre algo fora do escopo, informe educadamente que não pode ajudar com isso e ofereça transferência para um atendente humano.
|
||||
- Seja conciso e profissional. Prefira respostas curtas e diretas.
|
||||
- Nunca revele que é baseado em Claude ou qualquer modelo de IA.
|
||||
- Responda sempre em português brasileiro (pt-BR), independentemente do idioma usado pelo cliente.`;
|
||||
|
||||
async function chat(history, userMessage) {
|
||||
const messages = [
|
||||
...history,
|
||||
{ role: 'user', content: userMessage },
|
||||
];
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages,
|
||||
});
|
||||
|
||||
return response.content[0].text;
|
||||
}
|
||||
|
||||
module.exports = { chat };
|
||||
@@ -0,0 +1,60 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const BASE_URL = 'https://graph.facebook.com/v25.0';
|
||||
|
||||
function headers() {
|
||||
return { Authorization: `Bearer ${process.env.WHATSAPP_TOKEN}` };
|
||||
}
|
||||
|
||||
// Brazilian mobile numbers arriving from the webhook may be missing the 9th digit.
|
||||
// Country code 55 + 2-digit area code + 8-digit number = 12 digits (legacy format).
|
||||
// Meta requires the full 13-digit form: 55 + area code + 9 + 8-digit number.
|
||||
function normalizeBrazilianNumber(to) {
|
||||
if (/^55\d{10}$/.test(to)) {
|
||||
const normalized = to.slice(0, 4) + '9' + to.slice(4);
|
||||
console.log(`Brazilian number normalized: ${to} → ${normalized}`);
|
||||
return normalized;
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
async function sendText(to, body) {
|
||||
to = normalizeBrazilianNumber(to);
|
||||
return axios.post(
|
||||
// WA_PHONE_NUMBER_ID is Meta's numeric Phone Number ID (found in Meta Developer Console),
|
||||
// NOT the WhatsApp phone number itself (e.g. +1234567890).
|
||||
`${BASE_URL}/${process.env.WA_PHONE_NUMBER_ID}/messages`,
|
||||
{
|
||||
messaging_product: 'whatsapp',
|
||||
to,
|
||||
type: 'text',
|
||||
text: { body },
|
||||
},
|
||||
{ headers: headers() }
|
||||
);
|
||||
}
|
||||
|
||||
async function sendInteractiveButtons(to, bodyText, buttons) {
|
||||
to = normalizeBrazilianNumber(to);
|
||||
return axios.post(
|
||||
`${BASE_URL}/${process.env.WA_PHONE_NUMBER_ID}/messages`,
|
||||
{
|
||||
messaging_product: 'whatsapp',
|
||||
to,
|
||||
type: 'interactive',
|
||||
interactive: {
|
||||
type: 'button',
|
||||
body: { text: bodyText },
|
||||
action: {
|
||||
buttons: buttons.map((btn) => ({
|
||||
type: 'reply',
|
||||
reply: { id: btn.id, title: btn.title },
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ headers: headers() }
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { sendText, sendInteractiveButtons };
|
||||
@@ -0,0 +1,19 @@
|
||||
// In-memory conversation store — replace with Redis for production
|
||||
const store = new Map();
|
||||
|
||||
function getSession(phoneNumber) {
|
||||
if (!store.has(phoneNumber)) {
|
||||
store.set(phoneNumber, {
|
||||
history: [], // Claude message history [{role, content}]
|
||||
handedOff: false, // true once forwarded to Chatwoot
|
||||
greeted: false, // true after welcome menu sent
|
||||
});
|
||||
}
|
||||
return store.get(phoneNumber);
|
||||
}
|
||||
|
||||
function clearSession(phoneNumber) {
|
||||
store.delete(phoneNumber);
|
||||
}
|
||||
|
||||
module.exports = { getSession, clearSession };
|
||||
@@ -0,0 +1,188 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
kubernetes = {
|
||||
source = "hashicorp/kubernetes"
|
||||
version = "~> 2.30"
|
||||
}
|
||||
}
|
||||
backend "s3" {
|
||||
bucket = "shell-whats"
|
||||
key = "kubernetes/terraform.tfstate"
|
||||
region = "auto"
|
||||
endpoints = { s3 = "https://d17eb09b6bce2f90e16e800bb2a6baf9.r2.cloudflarestorage.com" }
|
||||
skip_credentials_validation = true
|
||||
skip_region_validation = true
|
||||
skip_requesting_account_id = true
|
||||
skip_metadata_api_check = true
|
||||
skip_s3_checksum = true
|
||||
}
|
||||
required_version = ">= 1.6"
|
||||
}
|
||||
|
||||
provider "kubernetes" {
|
||||
config_path = var.kubeconfig_path
|
||||
config_context = var.kubeconfig_context
|
||||
}
|
||||
|
||||
locals {
|
||||
app_name = "shell-whats"
|
||||
labels = { app = local.app_name }
|
||||
}
|
||||
|
||||
# ── Secret ────────────────────────────────────────────────────────────────────
|
||||
|
||||
resource "kubernetes_secret" "app" {
|
||||
metadata {
|
||||
name = "${local.app_name}-secret"
|
||||
namespace = var.namespace
|
||||
}
|
||||
|
||||
data = {
|
||||
WHATSAPP_TOKEN = var.whatsapp_token
|
||||
ANTHROPIC_API_KEY = var.anthropic_api_key
|
||||
CHATWOOT_API_KEY = var.chatwoot_api_key
|
||||
}
|
||||
}
|
||||
|
||||
# ── ConfigMap ─────────────────────────────────────────────────────────────────
|
||||
|
||||
resource "kubernetes_config_map" "app" {
|
||||
metadata {
|
||||
name = "${local.app_name}-config"
|
||||
namespace = var.namespace
|
||||
}
|
||||
|
||||
data = {
|
||||
NODE_ENV = "production"
|
||||
PORT = "3000"
|
||||
PHONE_NUMBER_ID = var.phone_number_id
|
||||
VERIFY_TOKEN = var.verify_token
|
||||
CHATWOOT_URL = var.chatwoot_url
|
||||
CHATWOOT_INBOX_ID = var.chatwoot_inbox_id
|
||||
CHATWOOT_ACCOUNT_ID = var.chatwoot_account_id
|
||||
}
|
||||
}
|
||||
|
||||
# ── Deployment ────────────────────────────────────────────────────────────────
|
||||
|
||||
resource "kubernetes_deployment" "app" {
|
||||
metadata {
|
||||
name = local.app_name
|
||||
namespace = var.namespace
|
||||
labels = local.labels
|
||||
}
|
||||
|
||||
spec {
|
||||
replicas = var.replicas
|
||||
|
||||
selector {
|
||||
match_labels = local.labels
|
||||
}
|
||||
|
||||
template {
|
||||
metadata {
|
||||
labels = local.labels
|
||||
}
|
||||
|
||||
spec {
|
||||
container {
|
||||
name = local.app_name
|
||||
image = var.image
|
||||
|
||||
port {
|
||||
container_port = 3000
|
||||
protocol = "TCP"
|
||||
}
|
||||
|
||||
# Non-secret env vars from ConfigMap
|
||||
env_from {
|
||||
config_map_ref {
|
||||
name = kubernetes_config_map.app.metadata[0].name
|
||||
}
|
||||
}
|
||||
|
||||
# Secrets injected individually
|
||||
env {
|
||||
name = "WHATSAPP_TOKEN"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret.app.metadata[0].name
|
||||
key = "WHATSAPP_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
env {
|
||||
name = "ANTHROPIC_API_KEY"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret.app.metadata[0].name
|
||||
key = "ANTHROPIC_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
env {
|
||||
name = "CHATWOOT_API_KEY"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret.app.metadata[0].name
|
||||
key = "CHATWOOT_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resources {
|
||||
requests = {
|
||||
cpu = "100m"
|
||||
memory = "128Mi"
|
||||
}
|
||||
limits = {
|
||||
cpu = "500m"
|
||||
memory = "256Mi"
|
||||
}
|
||||
}
|
||||
|
||||
liveness_probe {
|
||||
http_get {
|
||||
path = "/health"
|
||||
port = 3000
|
||||
}
|
||||
initial_delay_seconds = 10
|
||||
period_seconds = 30
|
||||
}
|
||||
|
||||
readiness_probe {
|
||||
http_get {
|
||||
path = "/health"
|
||||
port = 3000
|
||||
}
|
||||
initial_delay_seconds = 5
|
||||
period_seconds = 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ── Service ───────────────────────────────────────────────────────────────────
|
||||
|
||||
resource "kubernetes_service" "app" {
|
||||
metadata {
|
||||
name = local.app_name
|
||||
namespace = var.namespace
|
||||
labels = local.labels
|
||||
}
|
||||
|
||||
spec {
|
||||
selector = local.labels
|
||||
type = var.service_type
|
||||
|
||||
port {
|
||||
port = 80
|
||||
target_port = 3000
|
||||
protocol = "TCP"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
output "service_name" {
|
||||
description = "Kubernetes service name"
|
||||
value = kubernetes_service.app.metadata[0].name
|
||||
}
|
||||
|
||||
output "service_cluster_ip" {
|
||||
description = "ClusterIP assigned to the service"
|
||||
value = kubernetes_service.app.spec[0].cluster_ip
|
||||
}
|
||||
|
||||
output "deployment_name" {
|
||||
description = "Kubernetes deployment name"
|
||||
value = kubernetes_deployment.app.metadata[0].name
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Copy to terraform.tfvars and fill in real values — never commit that file
|
||||
image = "registry.example.com/shell-whats:latest"
|
||||
namespace = "bots"
|
||||
replicas = 1
|
||||
service_type = "ClusterIP"
|
||||
kubeconfig_context = "k3s-prod"
|
||||
|
||||
phone_number_id = "1234567890"
|
||||
verify_token = "my-secret-verify-token"
|
||||
chatwoot_url = "https://chatwoot.example.com"
|
||||
chatwoot_inbox_id = "1"
|
||||
|
||||
# Sensitive — prefer TF_VAR_* env vars instead of this file
|
||||
whatsapp_token = ""
|
||||
anthropic_api_key = ""
|
||||
chatwoot_api_key = ""
|
||||
@@ -0,0 +1,81 @@
|
||||
variable "kubeconfig_path" {
|
||||
description = "Path to kubeconfig file"
|
||||
type = string
|
||||
default = "~/.kube/config"
|
||||
}
|
||||
|
||||
variable "kubeconfig_context" {
|
||||
description = "Kubernetes context to use"
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "namespace" {
|
||||
description = "Kubernetes namespace"
|
||||
type = string
|
||||
default = "default"
|
||||
}
|
||||
|
||||
variable "image" {
|
||||
description = "Docker image for the app (e.g. registry.example.com/shell-whats:latest)"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "replicas" {
|
||||
description = "Number of pod replicas"
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "service_type" {
|
||||
description = "Kubernetes service type (ClusterIP, NodePort, LoadBalancer)"
|
||||
type = string
|
||||
default = "ClusterIP"
|
||||
}
|
||||
|
||||
# ── App secrets (sensitive) ───────────────────────────────────────────────────
|
||||
|
||||
variable "whatsapp_token" {
|
||||
description = "Meta WhatsApp Cloud API access token"
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "anthropic_api_key" {
|
||||
description = "Anthropic API key"
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "chatwoot_api_key" {
|
||||
description = "Chatwoot API access token"
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
# ── App config ────────────────────────────────────────────────────────────────
|
||||
|
||||
variable "phone_number_id" {
|
||||
description = "Meta WhatsApp Phone Number ID"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "verify_token" {
|
||||
description = "Webhook verify token (self-defined, must match Meta dashboard)"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "chatwoot_url" {
|
||||
description = "Chatwoot base URL (e.g. https://chatwoot.example.com)"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "chatwoot_inbox_id" {
|
||||
description = "Chatwoot inbox ID for WhatsApp conversations"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "chatwoot_account_id" {
|
||||
description = "Chatwoot account ID"
|
||||
type = string
|
||||
}
|
||||
Reference in New Issue
Block a user