feat: initial files

This commit is contained in:
2026-06-02 13:18:41 -03:00
parent 7114c4f379
commit ea84dbe2d4
25 changed files with 1993 additions and 0 deletions
+17
View File
@@ -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=
+39
View File
@@ -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
+139
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
node_modules/
.env
terraform/.terraform/
terraform/terraform.tfvars
terraform/*.tfstate
terraform/*.tfstate.backup
terraform/.terraform.lock.hcl
+10
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoogleJavaFormatSettings">
<option name="enabled" value="false" />
</component>
</project>
+7
View File
@@ -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>
+8
View File
@@ -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>
+9
View File
@@ -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
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+16
View File
@@ -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"]
+1036
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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"
}
}
+105
View File
@@ -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 };
+15
View File
@@ -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}`));
+31
View File
@@ -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;
+43
View File
@@ -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;
+66
View File
@@ -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 };
+39
View File
@@ -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 };
+60
View File
@@ -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 };
+19
View File
@@ -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 };
+188
View File
@@ -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"
}
}
}
+14
View File
@@ -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
}
+16
View File
@@ -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 = ""
+81
View File
@@ -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
}