COMO CONFIGURAR O GLPI AO WHATSAPP

De Wiki Grupo LLAL

Implantação do GLPI com Integração de Atendimento via WhatsApp[editar]

Objetivo desta documentação
Documentar a implantação completa do ambiente GLPI + Bot de WhatsApp, incluindo instalação, configuração da API, criação do bot, autenticação via QR Code, fluxo de funcionamento, testes e erros comuns encontrados durante a implantação.

Visão geral[editar]

Esta solução foi implantada para permitir que usuários abram e consultem chamados de TI por meio do WhatsApp, com integração ao GLPI via API REST.

Funcionalidades entregues[editar]

  • Abertura de chamado via WhatsApp
  • Escolha de categoria por menu
  • Envio de descrição textual
  • Envio de imagem
  • Envio de PDF
  • Envio de texto junto com imagem/PDF
  • Consulta de chamado pelo número
  • Retorno do status do chamado
  • Exibição do último retorno registrado pela TI
  • Opção para contato direto com a equipe de TI por e-mail

Arquitetura da solução[editar]

Usuário -> WhatsApp -> Bot Node.js -> API REST do GLPI -> Ticket

Componentes utilizados[editar]

Componente Tecnologia / Ferramenta
Sistema operacional Debian 12
Servidor web Apache2
Banco de dados MariaDB
Plataforma de chamados GLPI
Runtime do bot Node.js
Navegador para sessão do WhatsApp Chromium
Biblioteca de integração WhatsApp whatsapp-web.js
Variáveis de ambiente dotenv
Cliente HTTP axios
Geração de QR Code no terminal qrcode-terminal

Pré-requisitos[editar]

Antes de iniciar, garantir que o servidor possua:

  • acesso à internet
  • privilégios administrativos
  • IP acessível na rede local
  • portas necessárias liberadas
  • ambiente Debian 12 atualizado

Nota
Os exemplos abaixo consideram que o GLPI será publicado localmente por IP e que o bot será executado no mesmo servidor ou em servidor com acesso HTTP ao GLPI.

Instalação do GLPI[editar]

Atualização do sistema[editar]

<syntaxhighlight lang="bash"> sudo apt update && sudo apt full-upgrade -y </syntaxhighlight>

Instalação dos pacotes necessários[editar]

<syntaxhighlight lang="bash"> sudo apt install -y \

 apache2 \
 mariadb-server \
 wget tar bzip2 unzip curl \
 php8.2 \
 libapache2-mod-php8.2 \
 php8.2-cli \
 php8.2-common \
 php8.2-mysql \
 php8.2-xml \
 php8.2-curl \
 php8.2-gd \
 php8.2-intl \
 php8.2-bcmath \
 php8.2-mbstring \
 php8.2-zip \
 php8.2-bz2 \
 php8.2-ldap \
 php8.2-opcache

</syntaxhighlight>

Habilitar os serviços[editar]

<syntaxhighlight lang="bash"> sudo systemctl enable --now apache2 sudo systemctl enable --now mariadb </syntaxhighlight>

Segurança inicial do banco[editar]

<syntaxhighlight lang="bash"> sudo mysql_secure_installation </syntaxhighlight>

Durante a execução, aplicar as opções recomendadas:

  • definir senha para o usuário root do banco
  • remover usuários anônimos
  • desabilitar login root remoto
  • remover base de testes
  • recarregar privilégios

Criação do banco do GLPI[editar]

Entrar no MariaDB:

<syntaxhighlight lang="bash"> sudo mariadb </syntaxhighlight>

Executar:

<syntaxhighlight lang="sql"> CREATE DATABASE glpidb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'glpi'@'localhost' IDENTIFIED BY 'SUA_SENHA_FORTE_AQUI'; GRANT ALL PRIVILEGES ON glpidb.* TO 'glpi'@'localhost'; FLUSH PRIVILEGES; EXIT; </syntaxhighlight>

Download e extração do GLPI[editar]

<syntaxhighlight lang="bash"> cd /tmp wget https://github.com/glpi-project/glpi/releases/download/11.0.6/glpi-11.0.6.tgz sudo tar -xzf glpi-11.0.6.tgz -C /var/www/ </syntaxhighlight>

O diretório final ficará em:

/var/www/glpi

Configuração do Apache[editar]

Criar o arquivo de virtual host:

<syntaxhighlight lang="bash"> sudo nano /etc/apache2/sites-available/glpi.conf </syntaxhighlight>

Inserir:

<syntaxhighlight lang="apache"> <VirtualHost *:80>

   ServerName glpi.local
   DocumentRoot /var/www/glpi/public
   <Directory /var/www/glpi/public>
       Require all granted
       RewriteEngine On
       RewriteCond %{HTTP:Authorization} ^(.+)$
       RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
       RewriteCond %{REQUEST_FILENAME} !-f
       RewriteRule ^(.*)$ index.php [QSA,L]
   </Directory>
   ErrorLog ${APACHE_LOG_DIR}/glpi_error.log
   CustomLog ${APACHE_LOG_DIR}/glpi_access.log combined

</VirtualHost> </syntaxhighlight>

Ativar a configuração:

<syntaxhighlight lang="bash"> sudo a2enmod rewrite sudo a2dissite 000-default.conf sudo a2ensite glpi.conf sudo systemctl reload apache2 </syntaxhighlight>

Finalização pelo navegador[editar]

Acessar:

http://IP_DO_SERVIDOR

Seguir o assistente gráfico do GLPI e informar:

  • banco: glpidb
  • usuário: glpi
  • senha: a definida na criação do banco

Configuração da API do GLPI[editar]

Habilitar a API REST[editar]

No GLPI, acessar:

Configurar > Geral > API

Habilitar:

  • API REST
  • login com credenciais
  • login externo por token

Salvar as alterações.

Criar cliente da API[editar]

Ainda em:

Configurar > Geral > API

Criar um cliente com o nome:

bot-whatsapp

Após salvar, copiar o App-Token gerado.

Esse valor será usado na variável:

GLPI_APP_TOKEN

Criar usuário técnico do bot[editar]

No GLPI, acessar:

Administração > Usuários

Criar o usuário técnico:

Campo Valor
Usuário wabot
Nome Bot WhatsApp
Ativo Sim

Gerar token do usuário[editar]

No cadastro do usuário wabot, acessar:

Senhas e chaves de acesso

Gerar ou regenerar o Token de API.

Esse valor será usado na variável:

GLPI_USER_TOKEN

Atenção
Não confundir:

  • GLPI_APP_TOKEN = token do cliente da API
  • GLPI_USER_TOKEN = token do usuário técnico

Testes iniciais da API[editar]

Teste de sessão[editar]

<syntaxhighlight lang="bash"> curl -s -X GET \

 -H "Content-Type: application/json" \
 -H "Authorization: user_token SEU_TOKEN_USUARIO" \
 -H "App-Token: SEU_APP_TOKEN" \
 "http://SEU_IP/apirest.php/initSession"

</syntaxhighlight>

Saída esperada:

<syntaxhighlight lang="json"> {"session_token":"TOKEN_AQUI"} </syntaxhighlight>

Teste de criação de ticket[editar]

<syntaxhighlight lang="bash"> curl -s -X POST \

 -H "Content-Type: application/json" \
 -H "Session-Token: SEU_SESSION_TOKEN" \
 -H "App-Token: SEU_APP_TOKEN" \
 -d '{"input":{"name":"Teste API GLPI","content":"Chamado criado via API","urgency":3,"impact":3,"priority":3}}' \
 "http://SEU_IP/apirest.php/Ticket/"

</syntaxhighlight>

Instalação do bot do WhatsApp[editar]

Instalar Node.js[editar]

<syntaxhighlight lang="bash"> sudo apt update sudo apt install -y curl ca-certificates gnupg curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt install -y nodejs </syntaxhighlight>

Validar a instalação:

<syntaxhighlight lang="bash"> node -v npm -v </syntaxhighlight>

Instalar Chromium e dependências[editar]

<syntaxhighlight lang="bash"> sudo apt install -y chromium fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 xdg-utils </syntaxhighlight>

Criar diretório do projeto[editar]

<syntaxhighlight lang="bash"> mkdir -p /opt/wa-glpi-bot cd /opt/wa-glpi-bot </syntaxhighlight>

Inicializar projeto Node.js[editar]

<syntaxhighlight lang="bash"> npm init -y npm install whatsapp-web.js qrcode-terminal axios dotenv </syntaxhighlight>

Configuração do arquivo .env[editar]

Criar o arquivo:

<syntaxhighlight lang="bash"> nano /opt/wa-glpi-bot/.env </syntaxhighlight>

Conteúdo:

<syntaxhighlight lang="ini"> GLPI_URL=http://192.168.1.81 GLPI_APP_TOKEN=SEU_APP_TOKEN GLPI_USER_TOKEN=SEU_TOKEN_USUARIO CHROMIUM_PATH=/usr/bin/chromium TICKET_PREFIX=WhatsApp </syntaxhighlight>

Significado das variáveis[editar]

Variável Função
GLPI_URL Endereço base do GLPI
GLPI_APP_TOKEN Token do cliente da API
GLPI_USER_TOKEN Token do usuário técnico
CHROMIUM_PATH Caminho do executável do Chromium
TICKET_PREFIX Prefixo usado no título dos chamados

Criação do arquivo principal do bot[editar]

Remover arquivo antigo, se existir[editar]

<syntaxhighlight lang="bash"> rm -f /opt/wa-glpi-bot/index.js </syntaxhighlight>

Criar novo arquivo[editar]

<syntaxhighlight lang="bash"> nano /opt/wa-glpi-bot/index.js </syntaxhighlight>

Código-fonte completo[editar]

<syntaxhighlight lang="javascript"> require('dotenv').config();

const axios = require('axios'); const qrcode = require('qrcode-terminal'); const { Client, LocalAuth } = require('whatsapp-web.js');

const GLPI_URL = (process.env.GLPI_URL || ).replace(/\/$/, ); const GLPI_APP_TOKEN = process.env.GLPI_APP_TOKEN; const GLPI_USER_TOKEN = process.env.GLPI_USER_TOKEN; const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium'; const TICKET_PREFIX = process.env.TICKET_PREFIX || 'WhatsApp';

if (!GLPI_URL || !GLPI_APP_TOKEN || !GLPI_USER_TOKEN) {

 console.error('Faltam variáveis no arquivo .env');
 process.exit(1);

}

const TI_EMAIL = 'tecnologia@grupollal.com.br'; const SESSION_TIMEOUT_MS = 15 * 60 * 1000; const MIN_MESSAGE_INTERVAL_MS = 1200; const MAX_DESCRIPTION_LENGTH = 1800; const MAX_ATTACHMENTS = 5;

const ETAPAS = {

 MENU_PRINCIPAL: 'menu_principal',
 AGUARDANDO_CATEGORIA: 'aguardando_categoria',
 AGUARDANDO_DESCRICAO: 'aguardando_descricao',
 AGUARDANDO_CONSULTA: 'aguardando_consulta'

};

const conversas = new Map(); const ultimoProcessamento = new Map();

const MSG_MENU_PRINCIPAL = `👋 *Olá! Sou o assistente de chamados da TI.*

Escolha uma opção:

  • 1* - 📝 Abrir chamado
  • 2* - 🔎 Consultar chamado
  • 3* - 👨‍💻 Falar com TI

Comandos:

  • 0* - ↩️ Voltar
      • - 🏠 Menu inicial`;

const MSG_MENU_CATEGORIA = `🗂️ *Escolha a categoria do chamado:*

  • 1* - 🌐 Rede / Internet
  • 2* - 💻 Computador / Notebook
  • 3* - 🖨️ Impressora
  • 4* - 🔐 Acesso / Senha
  • 5* - 🧩 Sistema / ERP
  • 6* - 📦 Outro

Comandos:

  • 0* - ↩️ Voltar
      • - 🏠 Menu inicial`;

const MSG_PEDIR_DESCRICAO = `✍️ *Agora descreva o problema com detalhes.*

Você também pode enviar: 📷 imagem 📄 PDF 📝 texto + imagem/PDF na mesma mensagem

🚫 Vídeos não são aceitos.

Comandos:

  • 0* - ↩️ Voltar
      • - 🏠 Menu inicial`;

const MSG_PEDIR_NUMERO_CHAMADO = `🔎 *Informe o número do chamado.*

Exemplo:

  • 154*

Comandos:

  • 0* - ↩️ Voltar
      • - 🏠 Menu inicial`;

function textoInvalido(menuTexto) {

 return `❌ *Opção inválida.*\n\n${menuTexto}`;

}

function normalizarTexto(texto) {

 return (texto || ).trim().replace(/\s+/g, ' ');

}

function isMenuCommand(texto) {

 const t = normalizarTexto(texto).toLowerCase();
 return t === '#' || t === 'menu' || t === 'inicio' || t === 'início' || t === 'oi' || t === 'olá' || t === 'ola';

}

function criarEstadoInicial() {

 return {
   etapa: ETAPAS.MENU_PRINCIPAL,
   historico: [],
   updatedAt: Date.now(),
   categoria: null,
   anexos: []
 };

}

function atualizarEstado(numero, novoEstado) {

 conversas.set(numero, {
   ...novoEstado,
   updatedAt: Date.now()
 });

}

function expirarSeNecessario(numero) {

 const estado = conversas.get(numero);
 if (!estado) return null;
 if (Date.now() - (estado.updatedAt || 0) > SESSION_TIMEOUT_MS) {
   conversas.delete(numero);
   return null;
 }
 return estado;

}

function pushHistorico(estado, etapaAtual) {

 const historico = Array.isArray(estado.historico) ? [...estado.historico] : [];
 historico.push(etapaAtual);
 return historico;

}

function getCategoria(opcao) {

 const mapa = {
   '1': { nome: '🌐 Rede / Internet' },
   '2': { nome: '💻 Computador / Notebook' },
   '3': { nome: '🖨️ Impressora' },
   '4': { nome: '🔐 Acesso / Senha' },
   '5': { nome: '🧩 Sistema / ERP' },
   '6': { nome: '📦 Outro' }
 };
 return mapa[opcao] || null;

}

function voltarEstado(numero) {

 const estado = conversas.get(numero);
 if (!estado) {
   const inicial = criarEstadoInicial();
   atualizarEstado(numero, inicial);
   return { mensagem: MSG_MENU_PRINCIPAL };
 }
 const historico = Array.isArray(estado.historico) ? [...estado.historico] : [];
 const etapaAnterior = historico.pop();
 if (!etapaAnterior) {
   const inicial = criarEstadoInicial();
   atualizarEstado(numero, inicial);
   return { mensagem: `↩️ Você já está no início.\n\n${MSG_MENU_PRINCIPAL}` };
 }
 let novoEstado = { ...estado, historico };
 if (etapaAnterior === ETAPAS.MENU_PRINCIPAL) {
   novoEstado = criarEstadoInicial();
   atualizarEstado(numero, novoEstado);
   return { mensagem: MSG_MENU_PRINCIPAL };
 }
 if (etapaAnterior === ETAPAS.AGUARDANDO_CATEGORIA) {
   novoEstado.etapa = ETAPAS.AGUARDANDO_CATEGORIA;
   novoEstado.categoria = null;
   atualizarEstado(numero, novoEstado);
   return { mensagem: MSG_MENU_CATEGORIA };
 }
 if (etapaAnterior === ETAPAS.AGUARDANDO_DESCRICAO) {
   novoEstado.etapa = ETAPAS.AGUARDANDO_DESCRICAO;
   atualizarEstado(numero, novoEstado);
   return { mensagem: MSG_PEDIR_DESCRICAO };
 }
 if (etapaAnterior === ETAPAS.AGUARDANDO_CONSULTA) {
   novoEstado.etapa = ETAPAS.AGUARDANDO_CONSULTA;
   atualizarEstado(numero, novoEstado);
   return { mensagem: MSG_PEDIR_NUMERO_CHAMADO };
 }
 novoEstado = criarEstadoInicial();
 atualizarEstado(numero, novoEstado);
 return { mensagem: MSG_MENU_PRINCIPAL };

}

function statusTicketLabel(status) {

 const mapa = {
   1: 'Novo',
   2: 'Em atendimento',
   3: 'Planejado',
   4: 'Pendente',
   5: 'Solucionado',
   6: 'Fechado'
 };
 return mapa[status] || `Status ${status}`;

}

function statusUsuarioFinal(status) {

 if (status === 5 || status === 6) return 'Finalizado';
 return 'Em andamento';

}

function formatDateBr(value) {

 if (!value) return 'Não informado';
 const d = new Date(value);
 if (isNaN(d.getTime())) return String(value);
 return d.toLocaleString('pt-BR');

}

function stripHtml(html) {

 if (!html) return ;
 return String(html)
   .replace(/<br\s*\/?>/gi, '\n')
   .replace(/<\/p>/gi, '\n')
   .replace(/<[^>]*>/g, )
   .replace(/ /g, ' ')
   .replace(/&/g, '&')
   .replace(/</g, '<')
   .replace(/>/g, '>')
   .trim();

}

function extractTicketId(texto) {

 const m = normalizarTexto(texto).match(/\d+/);
 return m ? parseInt(m[0], 10) : null;

}

function sanitizeFilename(name) {

 return (name || 'arquivo')
   .replace(/[^\w.\-]/g, '_')
   .slice(0, 120);

}

function getMediaTypeInfo(mimetype) {

 const mt = (mimetype || ).toLowerCase();
 if (mt === 'application/pdf') {
   return { accepted: true, type: 'pdf', ext: '.pdf' };
 }
 if (mt === 'image/jpeg' || mt === 'image/jpg') {
   return { accepted: true, type: 'image', ext: '.jpg' };
 }
 if (mt === 'image/png') {
   return { accepted: true, type: 'image', ext: '.png' };
 }
 if (mt === 'image/webp') {
   return { accepted: true, type: 'image', ext: '.webp' };
 }
 if (mt.startsWith('video/')) {
   return { accepted: false, type: 'video', ext:  };
 }
 return { accepted: false, type: 'other', ext:  };

}

async function initGlpiSession() {

 const response = await axios.get(`${GLPI_URL}/apirest.php/initSession`, {
   headers: {
     'Content-Type': 'application/json',
     'Authorization': `user_token ${GLPI_USER_TOKEN}`,
     'App-Token': GLPI_APP_TOKEN
   },
   timeout: 15000
 });
 return response.data.session_token;

}

async function killGlpiSession(sessionToken) {

 try {
   await axios.get(`${GLPI_URL}/apirest.php/killSession`, {
     headers: {
       'Session-Token': sessionToken,
       'App-Token': GLPI_APP_TOKEN
     },
     timeout: 10000
   });
 } catch (_) {}

}

async function criarTicketGLPI({ numero, nome, categoria, descricao, anexos }) {

 const sessionToken = await initGlpiSession();
 try {
   const qtdAnexos = Array.isArray(anexos) ? anexos.length : 0;
   const linhaAnexos = qtdAnexos > 0
     ? `\n\n📎 Anexos enviados pelo WhatsApp: ${qtdAnexos}`
     : ;
   const payload = {
     input: {
       name: `${TICKET_PREFIX} - ${categoria.nome} - ${numero}`,
       content:

`📲 Origem: WhatsApp 👤 Nome: ${nome || 'Não informado'} 📞 Número: ${numero} 🗂️ Categoria: ${categoria.nome}

📝 Descrição: ${descricao}${linhaAnexos}`,

       urgency: 3,
       impact: 3,
       priority: 3
     }
   };
   const response = await axios.post(`${GLPI_URL}/apirest.php/Ticket/`, payload, {
     headers: {
       'Content-Type': 'application/json',
       'Session-Token': sessionToken,
       'App-Token': GLPI_APP_TOKEN
     },
     timeout: 20000
   });
   return response.data.id;
 } finally {
   await killGlpiSession(sessionToken);
 }

}

async function consultarTicketResumo(ticketId) {

 const sessionToken = await initGlpiSession();
 try {
   const ticketRes = await axios.get(`${GLPI_URL}/apirest.php/Ticket/${ticketId}`, {
     headers: {
       'Session-Token': sessionToken,
       'App-Token': GLPI_APP_TOKEN
     },
     timeout: 15000
   });
   const ticket = ticketRes.data;
   let followups = [];
   try {
     const followRes = await axios.get(`${GLPI_URL}/apirest.php/Ticket/${ticketId}/ITILFollowup`, {
       headers: {
         'Session-Token': sessionToken,
         'App-Token': GLPI_APP_TOKEN
       },
       timeout: 15000
     });
     followups = Array.isArray(followRes.data) ? followRes.data : [];
   } catch (_) {
     followups = [];
   }
   let ultimaMensagem = ;
   if (followups.length > 0) {
     const ultima = followups[followups.length - 1];
     ultimaMensagem = stripHtml(ultima.content || ultima.name || );
   }
   if (!ultimaMensagem && ticket.content) {
     ultimaMensagem = stripHtml(ticket.content);
   }
   return {
     id: ticket.id,
     status: ticket.status,
     statusLabel: statusTicketLabel(ticket.status),
     statusUsuario: statusUsuarioFinal(ticket.status),
     date_mod: ticket.date_mod,
     mensagemUsuario: ultimaMensagem || 'Ainda não há retorno registrado pela TI.'
   };
 } finally {
   await killGlpiSession(sessionToken);
 }

}

async function receberAnexo(msg, estado) {

 if (!msg.hasMedia) return { saved: false };
 if (!estado || estado.etapa !== ETAPAS.AGUARDANDO_DESCRICAO) {
   return {
     saved: false,
     warning: '📎 Recebi um arquivo, mas primeiro escolha *1 - Abrir chamado* no menu.'
   };
 }
 if ((estado.anexos || []).length >= MAX_ATTACHMENTS) {
   return {
     saved: false,
     warning: `⚠️ Limite de anexos atingido. Máximo: ${MAX_ATTACHMENTS} arquivos.`
   };
 }
 const media = await msg.downloadMedia();
 if (!media) {
   return {
     saved: false,
     warning: '⚠️ Não consegui baixar o arquivo. Tente enviar novamente.'
   };
 }
 const mediaInfo = getMediaTypeInfo(media.mimetype);
 if (!mediaInfo.accepted) {
   if (mediaInfo.type === 'video') {
     return {
       saved: false,
       warning: '🚫 *Vídeos não são aceitos.*\n\nEnvie apenas *imagens* ou *PDF*.'
     };
   }
   return {
     saved: false,
     warning: '⚠️ Tipo de arquivo não aceito.\n\nEnvie apenas *imagens* ou *PDF*.'
   };
 }
 const file = {
   filename: sanitizeFilename(media.filename || `anexo_${Date.now()}${mediaInfo.ext}`),
   mimetype: media.mimetype,
   data: media.data
 };
 estado.anexos = estado.anexos || [];
 estado.anexos.push(file);
 return {
   saved: true,
   count: estado.anexos.length,
   file
 };

}

const client = new Client({

 authStrategy: new LocalAuth({ clientId: 'glpi-whatsapp-bot' }),
 puppeteer: {
   headless: true,
   executablePath: CHROMIUM_PATH,
   args: [
     '--no-sandbox',
     '--disable-setuid-sandbox',
     '--disable-dev-shm-usage',
     '--disable-gpu'
   ]
 },
 qrMaxRetries: 5

});

client.on('qr', (qr) => {

 console.log('\n=== ESCANEIE O QR CODE NO WHATSAPP ===\n');
 qrcode.generate(qr, { small: true });

});

client.on('authenticated', () => {

 console.log('WhatsApp autenticado com sucesso.');

});

client.on('ready', () => {

 console.log('Bot do WhatsApp pronto.');

});

client.on('auth_failure', (msg) => {

 console.error('Falha na autenticação:', msg);

});

client.on('disconnected', (reason) => {

 console.error('WhatsApp desconectado:', reason);

});

client.on('message', async (msg) => {

 try {
   if (msg.fromMe) return;
   if (msg.from.includes('@g.us')) return;
   const agora = Date.now();
   const ultimo = ultimoProcessamento.get(msg.from) || 0;
   if (agora - ultimo < MIN_MESSAGE_INTERVAL_MS) return;
   ultimoProcessamento.set(msg.from, agora);
   const numero = msg.from.replace('@c.us', );
   const contato = await msg.getContact();
   const nome = contato.pushname || contato.name || 'Sem nome';
   const texto = normalizarTexto(msg.body || );
   let estado = expirarSeNecessario(numero);
   if (isMenuCommand(texto)) {
     estado = criarEstadoInicial();
     atualizarEstado(numero, estado);
     await msg.reply(MSG_MENU_PRINCIPAL);
     return;
   }
   if (texto === '0') {
     const retorno = voltarEstado(numero);
     await msg.reply(retorno.mensagem);
     return;
   }
   if (!estado) {
     estado = criarEstadoInicial();
     atualizarEstado(numero, estado);
     await msg.reply(MSG_MENU_PRINCIPAL);
     return;
   }
   if (estado.etapa === ETAPAS.MENU_PRINCIPAL) {
     if (texto === '1') {
       atualizarEstado(numero, {
         ...estado,
         etapa: ETAPAS.AGUARDANDO_CATEGORIA,
         historico: pushHistorico(estado, ETAPAS.MENU_PRINCIPAL),
         anexos: []
       });
       await msg.reply(MSG_MENU_CATEGORIA);
       return;
     }
     if (texto === '2') {
       atualizarEstado(numero, {
         ...estado,
         etapa: ETAPAS.AGUARDANDO_CONSULTA,
         historico: pushHistorico(estado, ETAPAS.MENU_PRINCIPAL)
       });
       await msg.reply(MSG_PEDIR_NUMERO_CHAMADO);
       return;
     }
     if (texto === '3') {
       await msg.reply(

`👨‍💻 *Falar com TI*

Você pode entrar em contato pelo e-mail: 📧 *${TI_EMAIL}*

Se quiser voltar, envie *#*.`

       );
       return;
     }
     await msg.reply(textoInvalido(MSG_MENU_PRINCIPAL));
     return;
   }
   if (estado.etapa === ETAPAS.AGUARDANDO_CATEGORIA) {
     const categoria = getCategoria(texto);
     if (!categoria) {
       await msg.reply(textoInvalido(MSG_MENU_CATEGORIA));
       return;
     }
     atualizarEstado(numero, {
       ...estado,
       etapa: ETAPAS.AGUARDANDO_DESCRICAO,
       historico: pushHistorico(estado, ETAPAS.AGUARDANDO_CATEGORIA),
       categoria,
       anexos: estado.anexos || []
     });
     await msg.reply(`✅ Categoria selecionada: ${categoria.nome}\n\n${MSG_PEDIR_DESCRICAO}`);
     return;
   }
   if (estado.etapa === ETAPAS.AGUARDANDO_DESCRICAO) {
     const mediaResult = await receberAnexo(msg, estado);
     if (mediaResult.warning) {
       await msg.reply(mediaResult.warning);
       return;
     }
     atualizarEstado(numero, estado);
     if (msg.hasMedia && !texto) {
       await msg.reply(`📎 Arquivo recebido com sucesso.\nTotal de anexos: *${estado.anexos.length}*\n\nAgora envie a descrição do chamado.`);
       return;
     }
     if (!texto) {
       await msg.reply(MSG_PEDIR_DESCRICAO);
       return;
     }
     if (texto.length < 5) {
       await msg.reply(`❌ *Descrição muito curta.*\n\n${MSG_PEDIR_DESCRICAO}`);
       return;
     }
     const descricao = texto.slice(0, MAX_DESCRIPTION_LENGTH);
     const ticketId = await criarTicketGLPI({
       numero,
       nome,
       categoria: estado.categoria,
       descricao,
       anexos: estado.anexos
     });
     const anexosOk = Array.isArray(estado.anexos) ? estado.anexos.length : 0;
     const linhaAnexo = anexosOk > 0
       ? `📎 Arquivos recebidos: *${anexosOk}*\n`
       : ;
     await msg.reply(

`✅ *Chamado criado com sucesso!*

🎫 Número: *#${ticketId}* 🗂️ Categoria: ${estado.categoria.nome} ${linhaAnexo}Se quiser consultar depois, envie *#* e escolha *2 - Consultar chamado*.`

     );
     conversas.delete(numero);
     return;
   }
   if (estado.etapa === ETAPAS.AGUARDANDO_CONSULTA) {
     const ticketId = extractTicketId(texto);
     if (!ticketId) {
       await msg.reply(`❌ Número inválido.\n\n${MSG_PEDIR_NUMERO_CHAMADO}`);
       return;
     }
     const resumo = await consultarTicketResumo(ticketId);
     await msg.reply(

`🔎 *Chamado #${resumo.id}*

📌 Situação: *${resumo.statusUsuario}* 📍 Status interno: ${resumo.statusLabel} 🕒 Última atualização: ${formatDateBr(resumo.date_mod)}

💬 Retorno da TI: ${resumo.mensagemUsuario}

Se quiser voltar ao menu, envie *#*.`

     );
     conversas.delete(numero);
     return;
   }
   const inicial = criarEstadoInicial();
   atualizarEstado(numero, inicial);
   await msg.reply(MSG_MENU_PRINCIPAL);
 } catch (error) {
   console.error('Erro ao processar mensagem:', error.response?.data || error.message);
   try {
     await msg.reply(`⚠️ *Não consegui processar sua solicitação agora.*\n\nEnvie *#* para voltar ao menu inicial.`);
   } catch (_) {}
 }

});

client.initialize(); </syntaxhighlight>

Salvar e validar[editar]

Salvar no nano:

  • Ctrl + O
  • Enter
  • Ctrl + X

Testar sintaxe:

<syntaxhighlight lang="bash"> node --check /opt/wa-glpi-bot/index.js </syntaxhighlight>

Execução do bot[editar]

Iniciar manualmente[editar]

<syntaxhighlight lang="bash"> cd /opt/wa-glpi-bot node index.js </syntaxhighlight>

Saída esperada:

Bot do WhatsApp pronto.

Se a sessão ainda não estiver autenticada, aparecerá o QR Code no terminal.

Autenticação no WhatsApp[editar]

Como ler o QR Code[editar]

No celular:

  • abrir o WhatsApp
  • acessar Aparelhos conectados
  • clicar em Conectar um aparelho
  • escanear o QR Code exibido no terminal do servidor

Como forçar um novo QR Code[editar]

Se o aparelho foi desconectado ou a sessão foi perdida:

<syntaxhighlight lang="bash"> pkill -f "node index.js" pkill -f chromium pkill -f chrome rm -rf /opt/wa-glpi-bot/.wwebjs_auth rm -rf /opt/wa-glpi-bot/.wwebjs_cache cd /opt/wa-glpi-bot node index.js </syntaxhighlight>

Fluxo funcional para o usuário[editar]

Menu principal[editar]

Opção Ação
1 Abrir chamado
2 Consultar chamado
3 Falar com TI

Abertura do chamado[editar]

O usuário pode enviar:

  • texto
  • imagem
  • PDF
  • texto + anexo na mesma interação

Exemplo de fluxo[editar]

oi
1
2
notebook não liga

Ou:

oi
1
2
[envio de imagem com legenda]

Resultado esperado:

  • o bot recebe a categoria
  • recebe a descrição
  • registra que houve anexo, quando existir
  • cria o chamado no GLPI
  • devolve o número do ticket ao usuário

Consulta de chamado[editar]

Fluxo exemplo:

2
154

Retorno esperado:

  • situação do chamado
  • status interno
  • data da última atualização
  • último retorno da TI

Comportamento atual dos anexos[editar]

O que acontece nesta versão[editar]

Registrado no GLPI:

  • ticket
  • descrição textual
  • categoria
  • quantidade de anexos recebidos

Ainda não enviado fisicamente ao ticket:

  • imagem
  • PDF

Observação técnica
O bot recebe os anexos, mas o vínculo físico do documento ao ticket no GLPI foi removido nesta versão porque a associação via API não funcionou corretamente no ambiente implantado.

Erros encontrados e correções[editar]

ERROR_WRONG_APP_TOKEN_PARAMETER[editar]

Causa provável:

  • App-Token incorreto
  • token do usuário usado no lugar do App-Token

Correção:

Revisar o arquivo .env:

<syntaxhighlight lang="ini"> GLPI_APP_TOKEN=TOKEN_DO_CLIENTE_API GLPI_USER_TOKEN=TOKEN_DO_USUARIO_WABOT </syntaxhighlight>

Cannot find module '/home/glpi/index.js'[editar]

Causa provável:

Execução do comando no diretório errado.

Correção:

<syntaxhighlight lang="bash"> cd /opt/wa-glpi-bot node index.js </syntaxhighlight>

The browser is already running[editar]

Causa provável:

Há processo antigo do Chromium ou Node ainda em execução.

Correção:

<syntaxhighlight lang="bash"> pkill -f "node index.js" pkill -f chromium pkill -f chrome cd /opt/wa-glpi-bot node index.js </syntaxhighlight>

QR Code não aparece[editar]

Correção recomendada:

<syntaxhighlight lang="bash"> rm -rf /opt/wa-glpi-bot/.wwebjs_auth rm -rf /opt/wa-glpi-bot/.wwebjs_cache cd /opt/wa-glpi-bot node index.js </syntaxhighlight>

Unexpected token / arquivo JS quebrado[editar]

Causa provável:

  • comandos bash colados dentro do arquivo JavaScript
  • código JavaScript executado direto no shell

Correção:

  • apagar o arquivo
  • recriar no editor correto
  • colar apenas o código JavaScript
  • validar com node --check

Comandos operacionais rápidos[editar]

Iniciar o bot[editar]

<syntaxhighlight lang="bash"> cd /opt/wa-glpi-bot node index.js </syntaxhighlight>

Validar sintaxe do código[editar]

<syntaxhighlight lang="bash"> node --check /opt/wa-glpi-bot/index.js </syntaxhighlight>

Resetar sessão do WhatsApp[editar]

<syntaxhighlight lang="bash"> pkill -f "node index.js" pkill -f chromium pkill -f chrome rm -rf /opt/wa-glpi-bot/.wwebjs_auth rm -rf /opt/wa-glpi-bot/.wwebjs_cache cd /opt/wa-glpi-bot node index.js </syntaxhighlight>

Boas práticas operacionais[editar]

  • manter backup do arquivo .env
  • nunca expor tokens em prints ou documentação pública
  • validar o código com node --check antes de executar
  • manter o Chromium instalado e funcional
  • documentar qualquer alteração de menu, categorias ou comportamento do bot

Conclusão[editar]

A implantação documentada permite operar um fluxo básico e funcional de abertura e consulta de chamados via WhatsApp integrado ao GLPI, com autenticação por QR Code e uso da API REST do GLPI para criação e leitura de tickets.

A solução atende bem ao cenário de suporte inicial e pode ser evoluída futuramente para:

  • anexar arquivos fisicamente ao ticket
  • identificar usuário por base cadastral
  • direcionar categoria automaticamente
  • executar como serviço no sistema
  • registrar logs estruturados
  • integrar com banco de conhecimento

Criado por: Iran Ribeiro - Analista de Infraestrutura 25/03/2026 - 16:30PM