first commit

This commit is contained in:
Niels Peen 2025-08-19 16:03:10 -05:00
parent cbafd7173e
commit 5af4cf83f8
15 changed files with 111 additions and 339 deletions

View file

@ -1,124 +0,0 @@
<?php

namespace Modules\CustomApp\Http\Controllers;

use App\Mailbox;
use App\Conversation;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;

class CustomAppController extends Controller
{
public function mailboxSettings($id)
{
$mailbox = Mailbox::findOrFail($id);

return view('customapp::mailbox_settings', [
'settings' => [
'customapp.callback_url' => \Option::get('customapp.callback_url')[(string)$id] ?? '',
'customapp.secret_key' => \Option::get('customapp.secret_key')[(string)$id] ?? '',
'customapp.signature_header' => \Option::get('customapp.signature_header')[(string)$id] ?? 'X-FREESCOUT-SIGNATURE',
'customapp.title' => \Option::get('customapp.title')[(string)$id] ?? '',
],
'mailbox' => $mailbox
]);
}

public function mailboxSettingsSave($id, Request $request)
{
$settings = $request->settings ?: [];

$urls = \Option::get('customapp.url') ?: [];
$secrets = \Option::get('customapp.secret') ?: [];

$urls[(string)$id] = $settings['customapp.callback_url'] ?? '';
$secrets[(string)$id] = $settings['customapp.secret_key'] ?? '';
$signatureHeaders[(string)$id] = $settings['customapp.signature_header'] ?? 'X-FREESCOUT-SIGNATURE';
$titles[(string)$id] = $settings['customapp.title'] ?? '';

\Option::set('customapp.callback_url', $urls);
\Option::set('customapp.secret_key', $secrets);
\Option::set('customapp.signature_header', $signatureHeaders);
\Option::set('customapp.title', $titles);

\Session::flash('flash_success_floating', __('Settings updated'));

return redirect()->route('mailboxes.customapp', ['id' => $id]);
}

public function generateSignature(string $data, string $secret): string
{
return base64_encode(hash_hmac('sha1', $data, $secret, true));
}

public function content(Request $request)
{
if(!auth()->check()) {
return response()->json(['status' => 'error', 'msg' => 'Unauthorized']);
}

$referrer = $request->headers->get('referer');

if ($referrer) {
$referrer = explode('?', $referrer)[0];
}
if (!is_array($referrerParts = explode('/', $referrer)) || !isset($referrerParts[4])) {
return response()->json(['status' => 'error', 'msg' => 'Invalid referrer']);
}

$conversationId = $referrerParts[4] ?? null;

if(!$conversation = Conversation::find($conversationId)) {
return response()->json(['status' => 'error', 'msg' => 'Conversation not found']);
}

if(!$mailbox = Mailbox::find($conversation->mailbox_id)) {
return response()->json(['status' => 'error', 'msg' => 'Mailbox not found']);
}

if(!$customer = $conversation->customer) {
return response()->json(['status' => 'error', 'msg' => 'Customer not found']);
}

$callbackUrl = \Option::get('customapp.callback_url')[(string)$mailbox->id] ?? '';
$secretKey = \Option::get('customapp.secret_key')[(string)$mailbox->id] ?? '';
$signatureHeader = \Option::get('customapp.signature_header')[(string)$mailbox->id] ?? 'X-FREESCOUT-SIGNATURE';
$title = \Option::get('customapp.title')[(string)$mailbox->id] ?? 'Custom App';

if (!$callbackUrl) {
return response()->json(['status' => 'error', 'msg' => 'Callback URL is not set']);
}

$payload = [
'customer' => [
'id' => $customer->id,
'email' => $customer->getMainEmail(),
'emails' => $customer->emails->pluck('email')->toArray(),
]
];

$content = json_encode($payload);
$signature = $this->generateSignature($content, $secretKey);

try {
$client = new \GuzzleHttp\Client();
$result = $client->post($callbackUrl, [
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'text/html',
$signatureHeader => $signature,
],
'body' => $content,
]);
$response = json_decode($result->getBody()->getContents(), true)['html'];
} catch (\Exception $e) {
$response = 'Callback error: ' . $e->getMessage();
}

return response($response, 200, [
'Content-Type' => 'text/html',
]);
}
}

View file

@ -1,8 +0,0 @@
<?php

Route::group(['middleware' => 'web', 'prefix' => \Helper::getSubdirectory(), 'namespace' => 'Modules\CustomApp\Http\Controllers'], function () {
Route::get('/customapp/content', ['uses' => 'CustomAppController@content'])->name('customapp.content');

Route::get('/mailbox/customapp/{id}', ['uses' => 'CustomAppController@mailboxSettings', 'middleware' => ['auth', 'roles'], 'roles' => ['admin']])->name('mailboxes.customapp');
Route::post('/mailbox/customapp/{id}', ['uses' => 'CustomAppController@mailboxSettingsSave', 'middleware' => ['auth', 'roles'], 'roles' => ['admin']])->name('mailboxes.customapp.save');
});

View file

@ -1,66 +0,0 @@
<?php

namespace Modules\CustomApp\Providers;

use Illuminate\Support\ServiceProvider;

class CustomAppServiceProvider extends ServiceProvider
{
private const MODULE_NAME = 'customapp';

public function boot()
{
$this->loadViewsFrom(__DIR__ . '/../Resources/views', self::MODULE_NAME);
$this->hooks();
}

public function registerViews()
{
$viewPath = resource_path('views/modules/customapp');

$sourcePath = __DIR__ . '/../Resources/views';

$this->publishes([
$sourcePath => $viewPath
], 'views');

$this->loadViewsFrom(array_merge(array_map(function ($path) {
return $path . '/modules/customapp';
}, \Config::get('view.paths')), [$sourcePath]), 'customapp');
}

/**
* Module hooks.
*/
public function hooks()
{
\Eventy::addFilter('javascripts', function ($javascripts) {
$javascripts[] = \Module::getPublicPath('customapp') . '/js/customapp.js';
return $javascripts;
});

\Eventy::addAction('mailboxes.settings.menu', function ($mailbox) {
if (auth()->user()->isAdmin()) {
echo \View::make('customapp::partials/settings_menu', ['mailbox' => $mailbox])->render();
}
}, 34);

// Settings view.
\Eventy::addFilter('settings.view', function ($view, $section) {
if ($section != 'customapp') {
return $view;
} else {
return 'customapp::settings';
}
}, 20, 2);

\Eventy::addAction('conversation.after_prev_convs', function ($customer, $conversation, $mailbox) {
$url = \Option::get('customapp.callback_url')[(string)$mailbox->id] ?? '';
$title = \Option::get('customapp.title')[(string)$mailbox->id] ?? 'Custom App';

if ($url != '') {
echo \View::make(self::MODULE_NAME . '::partials/sidebar', ['title' => $title])->render();
}
}, -1, 3);
}
}

View file

@ -0,0 +1,47 @@
<?php

namespace Modules\RedisDriver\Providers;

use Illuminate\Support\ServiceProvider;
use Modules\RedisDriver\Services\RedisOverrideService;

class RedisDriverServiceProvider extends ServiceProvider
{
private const MODULE_NAME = 'redisdriver';

public function register(): void
{
// RedisOverrideService::override();
$this->app->booting(function () {
RedisOverrideService::override();
});
}

public function boot()
{
$this->loadViewsFrom(__DIR__ . '/../Resources/views', self::MODULE_NAME);
$this->hooks();
}

public function registerViews()
{
$viewPath = resource_path('views/modules/redisdriver');

$sourcePath = __DIR__ . '/../Resources/views';

$this->publishes([
$sourcePath => $viewPath
], 'views');

$this->loadViewsFrom(array_merge(array_map(function ($path) {
return $path . '/modules/redisdriver';
}, \Config::get('view.paths')), [$sourcePath]), 'redisdriver');
}

/**
* Module hooks.
*/
public function hooks()
{
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -1,10 +0,0 @@
fetch('/customapp/content')
.then(response => response.text())
.then(data => {
document.getElementById('customapp-content').innerHTML = data;
})
.catch(error => {
console.error('Error loading customapp content:', error);
});


BIN
Public/redisdriver.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -1,8 +1,26 @@
# FreeScout Custom App
# FreeScout Redis Driver

* Similar to Helpscout's Legacy App feature.
* Fetches HTML from an external source to show in the sidebar of your conversations.
Enable Redis caching for FreeScout.

# Limitations
Inspired by #[4650](https://github.com/freescout-help-desk/freescout/issues/4650).

* Allows for only 1 Custom App per Mailbox
## Installation

* Ensure Redis server is installed.
* Ensure **phpredis** (not predis) driver for PHP is installed. (Both PHP-FPM and the PHP cli!)
* Double-check your Redis settings in ```config/database.php```. It must use **phpredis**, not predis.
* Add to your .env:
```
CACHE_DRIVER=redis
CACHE_PREFIX=freescout
QUEUE_DRIVER=redis
# Session drivers in FreeScout appear to be broken or incompatible with PHP 8. Only file and database drivers work.
# SESSION_DRIVER=redis
```
* Activate the Redis Driver module.
* Run php artisan ```freescout:clear-cache```.
## FAQ

* Why phpredis?
Using phpredis requires us to install a driver in PHP only. This removes the maintenance burden that comes with adding predis to FreeScout.

View file

@ -1,26 +0,0 @@
@extends('layouts.app')

@section('title_full', 'Custom App'.' - '.$mailbox->name)

@section('sidebar')
@include('partials/sidebar_menu_toggle')
@include('mailboxes/sidebar_menu')
@endsection

@section('content')

<div class="section-heading">
Custom App
</div>

@include('partials/flash_messages')

<div class="row-container">
<div class="row">
<div class="col-xs-12">
@include('customapp::settings')
</div>
</div>
</div>

@endsection

View file

@ -1 +0,0 @@
<li @if (Route::is('mailboxes.customapp'))class="active" @endif><a href="{{ route('mailboxes.customapp', ['id'=>$mailbox->id]) }}"><i class="glyphicon glyphicon-cloud"></i> Custom App</a></li>

View file

@ -1,20 +0,0 @@
<div class="conv-sidebar-block">
<div class="panel-group accordion accordion-empty">
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href=".collapse-custom-app">{{ $title }}
<b class="caret"></b>
</a>
</h4>
</div>
<div class="collapse-custom-app panel-collapse collapse in">
<div class="panel-body">
<div id="customapp-content">
<p>Loading...</p>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -1,67 +0,0 @@
<form class="form-horizontal margin-top margin-bottom" method="POST" action="">
{{ csrf_field() }}

<div class="form-group">
<label class="col-sm-2 control-label">{{ __('Title') }}</label>

<div class="col-sm-6">
<input type="text" class="form-control input-sized-lg" name="settings[customapp.title]" value="{{ $settings['customapp.title'] }}">

<p class="form-help">
{{ __('The title of the custom app. This is used to display the title of the custom app in the sidebar.') }}
</p>
</div>
</div>

<div class="form-group{{ $errors->has('settings.customapp->url') ? ' has-error' : '' }}">
<label class="col-sm-2 control-label">{{ __('Callback URL') }}</label>

<div class="col-sm-6">
<div class="input-group input-sized-lg">
<input type="text" class="form-control input-sized-lg" name="settings[customapp.callback_url]" value="{{ old('settings') ? old('settings')['customapp.callback_url'] : $settings['customapp.callback_url'] }}">
</div>

@include('partials/field_error', ['field'=>'settings.customapp->url'])

<p class="form-help">
{{ __('Example') }}: https://crm.example.org/api/freescout/callback
</p>
</div>
</div>

<div class="form-group">
<label class="col-sm-2 control-label">{{ __('Secret Key') }}</label>

<div class="col-sm-6">
<input type="text" class="form-control input-sized-lg" name="settings[customapp.secret_key]" value="{{ $settings['customapp.secret_key'] }}">

<p class="form-help">
{{ __('The secret key used to generate a signature header. This can be used to verify the authenticity of the request.') }}
</p>
</div>
</div>

<div class="form-group">
<label for="signature_header" class="col-sm-2 control-label">Signature Header</label>

<div class="col-sm-6">
<select id="signature_header" class="form-control input-sized" name="settings[customapp.signature_header]">
<option value="X-FREESCOUT-SIGNATURE" {{ $settings['customapp.signature_header'] == 'X-FREESCOUT-SIGNATURE' ? 'selected' : '' }}>X-FREESCOUT-SIGNATURE</option>
<option value="X-HELPSCOUT-SIGNATURE" {{ $settings['customapp.signature_header'] == 'X-HELPSCOUT-SIGNATURE' ? 'selected' : '' }}>X-HELPSCOUT-SIGNATURE</option>
</select>

<p class="form-help">
{{ __('Select the signature header to use. This is used to verify the authenticity of the request. Select X-HELPSCOUT-SIGNATURE if you are migrating from HelpScout.') }}
</p>

</div>
</div>

<div class="form-group margin-top margin-bottom">
<div class="col-sm-6 col-sm-offset-2">
<button type="submit" class="btn btn-primary">
{{ __('Save') }}
</button>
</div>
</div>
</form>

View file

@ -0,0 +1,28 @@
<?php

namespace Modules\RedisDriver\Services;

class RedisOverrideService
{
public static function override() {

$models = [
\App\Customer::class,
\App\Email::class,
\App\User::class,
\App\MailboxUser::class,
\App\Conversation::class,
\App\Mailbox::class,
];
foreach ( $models as $model_class ) {
try {
$model_class::retrieved( function ( $instance ) {
$instance->rememberCacheDriver = 'redis';
} );
} catch (\Exception $e) {
\Log::warning( "Failed to patch Rememberable for {$model_class}: " . $e->getMessage() );
}
}
}
}

View file

@ -1,5 +1,5 @@
{
"name": "nielspeen/freescout-custom-app",
"name": "nielspeen/freescout-redis-driver",
"description": "",
"authors": [
{
@ -10,7 +10,7 @@
"extra": {
"laravel": {
"providers": [
"Modules\\CustomApp\\Providers\\CustomAppServiceProvider"
"Modules\\RedisDriver\\Providers\\RedisDriverServiceProvider"
],
"aliases": {
}
@ -18,7 +18,7 @@
},
"autoload": {
"psr-4": {
"Modules\\CustomApp\\": ""
"Modules\\RedisDriver\\": ""
}
}
}

View file

@ -1,26 +1,27 @@
{
"name": "Custom App",
"alias": "customapp",
"description": "Custom App allows you to pull in external data to your Conversations.",
"version": "1.0.2",
"name": "Redis Driver",
"alias": "redisdriver",
"description": "Redis Driver for FreeScout.",
"version": "1.0.0",
"requiredAppVersion": "1.8.190",
"detailsUrl": "https://github.com/nielspeen/freescout-custom-app",
"detailsUrl": "https://github.com/nielspeen/freescout-redis-driver",
"author": "Niels Peen",
"authorUrl": "https://github.com/nielspeen",
"license": "AGPL-3.0",
"keywords": ["custom", "app", "external", "data"],
"active": 1,
"order": 0,
"priority": -10000,
"providers": [
"Modules\\CustomApp\\Providers\\CustomAppServiceProvider"
"Modules\\RedisDriver\\Providers\\RedisDriverServiceProvider"
],
"aliases": {},
"files": [
"start.php"
],
"requires": [],
"img": "/modules/customapp/customapp.png",
"latestVersionUrl": "https://raw.githubusercontent.com/nielspeen/freescout-custom-app/refs/heads/main/module.json",
"latestVersionZipUrl": "https://github.com/nielspeen/freescout-custom-app/archive/refs/heads/main.zip"
"img": "/modules/redisdriver/redisdriver.png",
"latestVersionUrl": "https://raw.githubusercontent.com/nielspeen/freescout-redis-driver/refs/heads/main/module.json",
"latestVersionZipUrl": "https://github.com/nielspeen/freescout-redis-driver/archive/refs/heads/main.zip"
}