Files
opensourcepos/app/Plugins/WhatsAppPlugin/WhatsAppPlugin.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

335 lines
10 KiB
PHP

<?php
namespace App\Plugins\WhatsAppPlugin;
use App\Libraries\Plugins\BasePlugin;
use App\Models\PluginMigrationModel;
use App\Plugins\WhatsAppPlugin\Libraries\SaleDocument;
use App\Plugins\WhatsAppPlugin\Libraries\WhatsAppConnector;
use CodeIgniter\Events\Events;
use Config\Database;
use Config\Services;
use Throwable;
/**
* Plugin that sends WhatsApp messages through the WhatsApp Business Cloud API
* (Meta / Graph API) and records the resulting conversation.
*
* Copyright (c) 2026 Joshua Fernandez (aka joshua1234511)
*
* @see https://developers.facebook.com/documentation/business-messaging/whatsapp/get-started
*/
class WhatsAppPlugin extends BasePlugin
{
/**
* Doubles as the allowlist for saveSettings(), so a crafted form post cannot
* write keys the plugin does not own.
*/
private const DEFAULT_SETTINGS = [
'api_url' => 'https://graph.facebook.com',
'api_version' => 'v21.0',
'phone_id' => '',
'business_id' => '',
'token' => '',
'default_country_code' => '',
'saved_message' => '',
'verify_token' => '',
'app_secret' => '',
];
private const ENCRYPTED_SETTINGS = ['token', 'app_secret'];
/**
* api_url builds every outbound call, so an unrestricted one would let anyone
* who can save settings aim authenticated server-side requests at any host.
*/
private const ALLOWED_API_HOSTS = ['graph.facebook.com'];
/**
* Also guards the $type route segment, which is interpolated into a view path.
*/
public const DOCUMENT_TYPES = ['invoice', 'quote', 'work_order', 'receipt'];
public function getPluginId(): string
{
return 'whatsapp';
}
public function getPluginName(): string
{
return 'WhatsApp';
}
public function getPluginDescription(): string
{
return lang('WhatsAppPlugin.description');
}
public function getVersion(): string
{
return '1.0.0';
}
public function registerEvents(): void
{
// One callback for all four sale documents; the hook name carries the type.
Events::on('view:sales_receipt_buttons', fn (array $data) => $this->injectSaleDocumentButton('receipt', $data));
Events::on('view:sales_invoice_buttons', fn (array $data) => $this->injectSaleDocumentButton('invoice', $data));
Events::on('view:sales_quote_buttons', fn (array $data) => $this->injectSaleDocumentButton('quote', $data));
Events::on('view:sales_work_order_buttons', fn (array $data) => $this->injectSaleDocumentButton('work_order', $data));
Events::on('view:module_icon_whatsapp', [$this, 'injectModuleIcon']);
}
public function install(): bool
{
$this->log('info', 'Installing WhatsApp plugin');
foreach (self::DEFAULT_SETTINGS as $key => $default) {
$this->setSetting($key, $default);
}
$this->registerModule('whatsapp', 101);
return true;
}
/**
* Uninstall is documented as destructive: leave the database exactly as it was
* before install, so the conversation log is dropped here.
*
* The migration version is also reset, otherwise a later re-install would skip
* CreateWhatsAppMessagesTable and leave the plugin without its table.
*/
public function uninstall(): bool
{
$this->log('info', 'Uninstalling WhatsApp plugin');
$this->unregisterModule('whatsapp');
Database::forge()->dropTable('whatsapp_messages', true);
(new PluginMigrationModel())->setVersion($this->getPluginId(), 0);
return true;
}
public function getConfigView(): ?string
{
return __DIR__ . DIRECTORY_SEPARATOR . 'Views' . DIRECTORY_SEPARATOR . 'config';
}
/**
* Settings for the config form. This array is rendered into the browser, so
* the secrets are replaced by a flag saying whether each one is set; writing
* a decrypted token into the DOM would undo storing it encrypted.
*/
public function getSettings(): array
{
$settings = $this->allSettings();
$settings['token_configured'] = $settings['token'] !== '';
$settings['app_secret_configured'] = $settings['app_secret'] !== '';
$settings['token'] = '';
$settings['app_secret'] = '';
return $settings;
}
/**
* Includes the decrypted secrets, for callers that talk to the API. Never
* hand this to a view.
*/
private function allSettings(): array
{
$settings = [];
foreach (self::DEFAULT_SETTINGS as $key => $default) {
$value = (string) $this->getSetting($key, $default);
$settings[$key] = in_array($key, self::ENCRYPTED_SETTINGS, true)
? $this->decryptSetting($value)
: $value;
}
return $settings;
}
public function getConfigViewData(): array
{
return ['webhook_url' => site_url('plugins/whatsapp/webhook')];
}
/**
* Persists settings, encrypting secrets.
*
* The form never renders a stored secret, so an empty secret field means
* "unchanged"; clearing one is the separate clear_* checkbox. Nothing is
* persisted when a secret cannot be encrypted, so a failing encrypter can
* never downgrade a stored token to plaintext.
*/
public function saveSettings(array $settings): bool
{
$normalized = [];
foreach ($settings as $key => $value) {
if (! array_key_exists($key, self::DEFAULT_SETTINGS)) {
continue;
}
$raw = (string) $value;
if (in_array($key, self::ENCRYPTED_SETTINGS, true)) {
if (! empty($settings['clear_' . $key])) {
$normalized[$key] = '';
continue;
}
if ($raw === '') {
continue;
}
$encrypted = $this->encryptSetting($raw);
if ($encrypted === null) {
return false;
}
$normalized[$key] = $encrypted;
continue;
}
if ($key === 'api_url') {
$raw = rtrim(trim($raw), '/');
if ($raw === '') {
$raw = self::DEFAULT_SETTINGS['api_url'];
}
if (! $this->apiUrlAllowed($raw)) {
$this->log('warning', 'Refusing to store an API base URL outside the WhatsApp Cloud API: ' . $raw);
return false;
}
}
$normalized[$key] = $raw;
}
return parent::saveSettings($normalized);
}
public function connector(): WhatsAppConnector
{
$settings = $this->allSettings();
return new WhatsAppConnector([
'enabled' => $this->isEnabled(),
'api_url' => $settings['api_url'],
'api_version' => $settings['api_version'],
'phone_id' => $settings['phone_id'],
'token' => $settings['token'],
'default_country_code' => $settings['default_country_code'],
]);
}
/**
* Returns a single decrypted setting, for callers that need one secret without
* building the whole settings array.
*/
public function secret(string $key): string
{
return $this->decryptSetting((string) $this->getSetting($key, ''));
}
public function savedMessage(): string
{
return (string) $this->getSetting('saved_message', '');
}
/**
* BasePlugin::renderView() is protected, so this exposes it for the plugin's
* own controllers.
*/
public function renderPluginView(string $viewName, array $data = []): string
{
return $this->renderView($viewName, $data);
}
public function injectModuleIcon(): void
{
echo $this->renderView('module_icon');
}
/**
* Renders the "Send via WhatsApp" button into a sale document view.
*
* Only `saleId` is passed by the hook, so the recipient's phone number is
* resolved here; nothing is rendered when the customer has no number.
*/
private function injectSaleDocumentButton(string $type, array $data): void
{
$saleId = (int) ($data['saleId'] ?? 0);
if ($saleId === 0) {
return;
}
$phone = $this->connector()->normalizePhone(
(new SaleDocument())->customerPhone($saleId),
);
if ($phone === '') {
return;
}
echo $this->renderView('sale_document_button', ['saleId' => $saleId, 'documentType' => $type]);
}
private function apiUrlAllowed(string $url): bool
{
$parts = parse_url($url);
return ($parts['scheme'] ?? '') === 'https'
&& in_array(strtolower($parts['host'] ?? ''), self::ALLOWED_API_HOSTS, true);
}
/**
* @return string|null Null when the value could not be encrypted.
*/
private function encryptSetting(string $value): ?string
{
try {
return base64_encode(Services::encrypter()->encrypt($value));
} catch (Throwable $e) {
$this->log('critical', 'Could not encrypt setting; refusing to store it as plaintext: ' . $e->getMessage());
return null;
}
}
/**
* Tolerates values written before encryption was available (mirrors
* MailchimpPlugin::decryptApiKey()).
*/
private function decryptSetting(string $value): string
{
if ($value === '') {
return '';
}
try {
$decoded = base64_decode($value, true);
if ($decoded !== false) {
return Services::encrypter()->decrypt($decoded);
}
} catch (Throwable) {
// Legacy plaintext or an old binary-encrypted value — fall through.
}
return $value;
}
}