Files
opensourcepos/app/Plugins/WhatsAppPlugin/Controllers/WhatsAppController.php
Joshua Fernandes a97e579635 feat(plugins): add sale document view hooks, webhook CSRF exemption, and WhatsApp plugin
Add three missing plugin hook points for sale documents so plugins can
inject buttons into invoice, quote, and work order views alongside the
existing receipt hook. All four pass ['saleId' => $sale_id_num] and
follow the same naming pattern (view:sales_{type}_buttons).

Exempt plugins/*/webhook from CSRF filtering to support server-to-server
provider callbacks. Also convert the CSRF except list from a string to an
array — the previous 'login|migrate' string produced a single unanchored
pattern, making login/anything CSRF-exempt. Separate array entries anchor
each one individually. Plugin webhook handlers are responsible for their
own authentication.

Add WhatsApp Business Cloud API plugin (app/Plugins/WhatsAppPlugin/):
- Free-form messaging page registered as the 'whatsapp' office module
  with its own permission, plus per-customer modal and thread view
- "Send via WhatsApp" button on all four sale document types via the new
  hooks; renders only when the customer has a phone number
- PDF delivery using the same sales/{type}_email view core uses for email
- Inbound webhook at plugins/whatsapp/webhook authenticated by
  X-Hub-Signature-256 HMAC; fails closed on missing/bad signature;
  always returns 200 to suppress Meta retries
- Out-of-order status callbacks cannot downgrade sent → delivered → read
- Conversation log table via plugin migration; dropped on uninstall with
  version reset so re-install recreates it cleanly
- Access token and app secret encrypted at rest in plugin_config; no
  writes to app_config or initial_schema.sql
- utf8mb4_unicode_520_ci collation throughout (MySQL and MariaDB compat)
- Language file stubs for all existing locales; English strings complete
- README covering credentials, install, webhook setup, and uninstall
2026-09-07 12:32:37 +04:00

184 lines
6.4 KiB
PHP

<?php
namespace App\Plugins\WhatsAppPlugin\Controllers;
use App\Controllers\Secure_Controller;
use App\Models\Person;
use App\Plugins\WhatsAppPlugin\Libraries\SaleDocument;
use App\Plugins\WhatsAppPlugin\Libraries\WhatsAppConnector;
use App\Plugins\WhatsAppPlugin\Models\WhatsAppMessage;
use App\Plugins\WhatsAppPlugin\WhatsAppPlugin;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\ResponseInterface;
/**
* WhatsApp messaging controller.
*
* Mirrors the SMS Messages controller: a page to send free-form WhatsApp messages
* plus a per-person modal form, and additionally exposes the full conversation
* (outbound sends and inbound replies) with a customer.
*
* Guarded by the 'whatsapp' permission registered in WhatsAppPlugin::install().
*/
class WhatsAppController extends Secure_Controller
{
private WhatsAppPlugin $plugin;
private WhatsAppConnector $connector;
private WhatsAppMessage $messageModel;
public function __construct()
{
parent::__construct('whatsapp');
$plugin = service('pluginManager')->getPlugin('whatsapp');
// Routes are registered for every plugin directory, enabled or not, so a
// disabled plugin must refuse to serve rather than rely on the route
// simply not existing.
if (! $plugin instanceof WhatsAppPlugin || ! $plugin->isEnabled()) {
throw PageNotFoundException::forPageNotFound();
}
$this->plugin = $plugin;
$this->connector = $plugin->connector();
$this->messageModel = model(WhatsAppMessage::class);
}
public function getIndex(): string
{
return $this->plugin->renderPluginView('whatsapp', [
'conversations' => $this->messageModel->getRecentConversations(),
'configured' => $this->connector->isConfigured(),
'saved_message' => $this->plugin->savedMessage(),
]);
}
/**
* Per-person modal: prefilled form plus that person's conversation thread.
*/
public function getView(int $personId = NEW_ENTRY): string
{
$info = model(Person::class)->getInfo($personId);
$phone = $this->connector->normalizePhone($info->phone_number ?? '');
return $this->plugin->renderPluginView('form_whatsapp', [
'person_info' => $info,
'phone' => $phone,
'saved_message' => $this->plugin->savedMessage(),
'messages' => $phone !== ''
? $this->messageModel->getConversation($phone)->getResult()
: [],
]);
}
/**
* Returns the conversation thread partial for a phone number (AJAX refresh).
*/
public function getConversation(string $phone = ''): string
{
$phone = $this->connector->normalizePhone($phone);
return $this->plugin->renderPluginView('conversation', [
'messages' => $phone !== ''
? $this->messageModel->getConversation($phone)->getResult()
: [],
]);
}
public function postSend(): ResponseInterface
{
$phone = $this->request->getPost('phone', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
// Send the message body verbatim: WhatsApp renders plain text, not HTML.
// Entity-encoding here would surface literal &amp;/&#039; to the customer
// and double-encode when the log is later shown via esc().
$message = trim((string) $this->request->getPost('message'));
$sent = $this->connector->sendText((string) $phone, $message);
return $this->response->setJSON([
'success' => $sent,
'message' => lang($sent ? 'WhatsAppPlugin.successfully_sent' : 'WhatsAppPlugin.unsuccessfully_sent') . ' ' . esc($phone),
]);
}
/**
* Used in Views/form_whatsapp.php.
*
* @noinspection PhpUnused
*/
public function postSendForm(int $personId = NEW_ENTRY): ResponseInterface
{
$phone = $this->request->getPost('phone', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
// See postSend() for why the body is not escaped here.
$message = trim((string) $this->request->getPost('message'));
$sent = $this->connector->sendText(
(string) $phone,
$message,
$personId === NEW_ENTRY ? null : $personId,
);
return $this->response->setJSON([
'success' => $sent,
'message' => lang($sent ? 'WhatsAppPlugin.successfully_sent' : 'WhatsAppPlugin.unsuccessfully_sent') . ' ' . esc($phone),
'person_id' => $sent ? $personId : NEW_ENTRY,
]);
}
/**
* Sends a sale document (invoice/quote/work order/receipt) as a PDF over
* WhatsApp. Triggered by the button injected into the sale document views.
*
* @noinspection PhpUnused
*/
public function postSendDocument(int $saleId, string $type = 'invoice'): ResponseInterface
{
// $type is interpolated into a view path, so restrict it to known types.
if (! in_array($type, WhatsAppPlugin::DOCUMENT_TYPES, true)) {
$type = 'invoice';
}
$saleDocument = new SaleDocument();
// Checked before rendering so a missing phone number and a failed PDF are
// never reported as each other.
$phone = $this->connector->normalizePhone($saleDocument->customerPhone($saleId));
if ($phone === '') {
return $this->response->setJSON([
'success' => false,
'message' => lang('WhatsAppPlugin.no_phone'),
'id' => $saleId,
]);
}
$document = $saleDocument->renderPdf($saleId, $type);
if ($document === null) {
return $this->response->setJSON([
'success' => false,
'message' => lang('WhatsAppPlugin.document_failed'),
'id' => $saleId,
]);
}
$sent = $this->connector->sendDocument(
$document['phone'],
$document['path'],
$document['display_name'],
$document['caption'],
$document['person_id'],
);
if (is_file($document['path'])) {
unlink($document['path']);
}
return $this->response->setJSON([
'success' => $sent,
'message' => lang('WhatsAppPlugin.' . $type . ($sent ? '_sent' : '_unsent')) . ' ' . $document['phone'],
'id' => $saleId,
]);
}
}