mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-09-13 13:57:34 -04:00
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
184 lines
6.4 KiB
PHP
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 &/' 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,
|
|
]);
|
|
}
|
|
}
|