feat: add html editor for telegram messages
This commit is contained in:
79
frontend/admin/package-lock.json
generated
79
frontend/admin/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "admin",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@formkit/drag-and-drop": "^0.5.3",
|
||||
@@ -572,6 +573,51 @@
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
|
||||
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
@@ -1547,6 +1593,17 @@
|
||||
"integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
|
||||
"integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
@@ -1556,6 +1613,28 @@
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.12.tgz",
|
||||
"integrity": "sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@formkit/drag-and-drop": "^0.5.3",
|
||||
|
||||
133
frontend/admin/src/components/RichTextEditor.vue
Normal file
133
frontend/admin/src/components/RichTextEditor.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="tw:space-y-2">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 240,
|
||||
},
|
||||
});
|
||||
|
||||
const model = defineModel({
|
||||
type: String,
|
||||
default: "",
|
||||
});
|
||||
|
||||
const textareaRef = ref(null);
|
||||
const summernoteInstance = ref(null);
|
||||
|
||||
const getJQuery = () => window.$ || window.jQuery;
|
||||
|
||||
const normalizeTelegramHtml = (html = "") => {
|
||||
const withoutEmptyParagraphs = html.replace(/<p><br><\/p>/gi, "<br>");
|
||||
const withoutParagraphs = withoutEmptyParagraphs
|
||||
.replace(/<p>/gi, "")
|
||||
.replace(/<\/p>/gi, "<br>");
|
||||
|
||||
return withoutParagraphs.replace(/(?:<br>\s*)+$/i, "").trim();
|
||||
};
|
||||
|
||||
const makeSpoilerButton = ($) => (context) => {
|
||||
const ui = $.summernote.ui;
|
||||
return ui
|
||||
.button({
|
||||
contents: '<i class="fa fa-eye-slash"></i>',
|
||||
tooltip: "Спойлер (Telegram)",
|
||||
click() {
|
||||
const selectedText = context.invoke("editor.getSelectedText") || "";
|
||||
const content = selectedText || "spoiler";
|
||||
context.invoke(
|
||||
"editor.pasteHTML",
|
||||
`<span class="tg-spoiler">${content}</span>`
|
||||
);
|
||||
},
|
||||
})
|
||||
.render();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const $ = getJQuery();
|
||||
if (!$ || !textareaRef.value) {
|
||||
console.warn("[RichTextEditor] jQuery или textarea недоступны");
|
||||
return;
|
||||
}
|
||||
|
||||
const $el = $(textareaRef.value);
|
||||
|
||||
$el.summernote({
|
||||
height: props.height,
|
||||
placeholder: props.placeholder,
|
||||
shortcuts: false,
|
||||
dialogsInBody: true,
|
||||
disableResizeEditor: true,
|
||||
buttons: {
|
||||
spoiler: makeSpoilerButton($),
|
||||
},
|
||||
toolbar: [
|
||||
["font", ["bold", "underline", "italic", "strikethrough", "clear"]],
|
||||
["para", ["ul", "ol", "paragraph"]],
|
||||
["insert", ["link", "spoiler"]],
|
||||
["view", ["fullscreen", "codeview", "help"]],
|
||||
],
|
||||
callbacks: {
|
||||
onChange(contents) {
|
||||
const normalized = normalizeTelegramHtml(contents ?? "");
|
||||
if (normalized !== contents) {
|
||||
$el.summernote("code", normalized);
|
||||
return;
|
||||
}
|
||||
model.value = normalized;
|
||||
},
|
||||
onKeydown(e) {
|
||||
if (e.keyCode === 13) {
|
||||
e.preventDefault();
|
||||
$el.summernote("pasteHTML", "<br>");
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (model.value) {
|
||||
$el.summernote("code", normalizeTelegramHtml(model.value));
|
||||
}
|
||||
|
||||
summernoteInstance.value = $el;
|
||||
});
|
||||
|
||||
watch(
|
||||
model,
|
||||
(value) => {
|
||||
const instance = summernoteInstance.value;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeTelegramHtml(value || "");
|
||||
const current = normalizeTelegramHtml(instance.summernote("code"));
|
||||
if (current !== normalized) {
|
||||
instance.summernote("code", normalized);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (summernoteInstance.value) {
|
||||
summernoteInstance.value.summernote("destroy");
|
||||
summernoteInstance.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<textarea
|
||||
<Codemirror
|
||||
v-model="model"
|
||||
:rows="rows"
|
||||
:placeholder="placeholder"
|
||||
class="form-control"
|
||||
></textarea>
|
||||
:extensions="extensions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -37,7 +36,12 @@
|
||||
|
||||
<div class="collapse" :id="collapseId" style="margin-top: 15px">
|
||||
<div class="well">
|
||||
<p>Вы можете использовать переменные:</p>
|
||||
<p>
|
||||
Для формирования сообщения используется HTML разметка.
|
||||
Telegram поддерживает только часть HTML тегов, которые описаны в их
|
||||
<a href="https://core.telegram.org/bots/api#html-style" target="_blank">документации <i class="fa fa-external-link"></i></a>.
|
||||
</p>
|
||||
<p>Дополнительно к этому TeleCart добавляет переменные, которые вы можете использовать, чтобы сделать сообщения динамическими.</p>
|
||||
<ul>
|
||||
<li><code>{store_name}</code> — название магазина</li>
|
||||
<li><code>{order_id}</code> — номер заказа</li>
|
||||
@@ -50,19 +54,7 @@
|
||||
<li><code>{ip}</code> — IP покупателя</li>
|
||||
<li><code>{created_at}</code> — дата и время создания заказа</li>
|
||||
</ul>
|
||||
<p>
|
||||
Форматирование: поддерживается
|
||||
<a href="https://core.telegram.org/bots/api#markdownv2-style" target="_blank">
|
||||
*MarkdownV2*
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>.
|
||||
</p>
|
||||
<p>Символы, которые нужно экранировать в тексте:</p>
|
||||
<pre>_ * [ ] ( ) ~ ` > # + - = | { } . !</pre>
|
||||
<p>
|
||||
Каждый из них нужно экранировать обратным слэшем \, если он не используется для форматирования.
|
||||
Например вместо <code>Заказ #123</code> нужно писать <code>Заказ \#123</code>.
|
||||
</p>
|
||||
<p></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,11 +70,15 @@ import {useSettingsStore} from "@/stores/settings.js";
|
||||
import {ref, toRaw, useId} from "vue";
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
import {apiPost} from "@/utils/http.js";
|
||||
import {Codemirror} from "vue-codemirror";
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
|
||||
const model = defineModel();
|
||||
const settings = useSettingsStore();
|
||||
const isSending = ref(false);
|
||||
const collapseId = useId();
|
||||
const extensions = [html(), oneDark];
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
|
||||
@@ -96,6 +96,7 @@ class ControllerExtensionModuleTgshop extends Controller
|
||||
public function index(): void
|
||||
{
|
||||
$this->runMaintenanceTasks();
|
||||
$this->injectAssets();
|
||||
$this->injectVueJs();
|
||||
$this->showConfigPage();
|
||||
}
|
||||
@@ -272,4 +273,10 @@ class ControllerExtensionModuleTgshop extends Controller
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
private function injectAssets(): void
|
||||
{
|
||||
$this->document->addScript('view/javascript/summernote/summernote.js');
|
||||
$this->document->addStyle('view/javascript/summernote/summernote.css');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,4 +31,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript" src="view/javascript/summernote/summernote.js"></script>
|
||||
<link href="view/javascript/summernote/summernote.css" rel="stylesheet" />
|
||||
{{ footer }}
|
||||
|
||||
@@ -69,9 +69,6 @@ class SendMessageHandler
|
||||
$this->telegramService->sendMessage(
|
||||
$telegramUserId,
|
||||
$message,
|
||||
[],
|
||||
\Openguru\OpenCartFramework\Telegram\Enums\ChatAction::TYPING,
|
||||
'' // Обычный текст без форматирования
|
||||
);
|
||||
|
||||
$this->logger->info('Message sent to Telegram user', [
|
||||
|
||||
@@ -7,6 +7,7 @@ use Exception;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\Telegram\Enums\ChatAction;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -15,33 +15,34 @@ return [
|
||||
'telegram' => [
|
||||
"bot_token" => "",
|
||||
"chat_id" => null,
|
||||
"owner_notification_template" => <<<TEXT
|
||||
*Новый заказ \#{order_id}* в магазине *{store_name}*
|
||||
"owner_notification_template" => <<<HTML
|
||||
📦 <b>Новый заказ №{order_id}</b>
|
||||
Магазин: <b>{store_name}</b>
|
||||
|
||||
*Покупатель:* {customer}
|
||||
*Email:* {email}
|
||||
*Телефон:* {phone}
|
||||
*IP:* {ip}
|
||||
<b>Покупатель</b>
|
||||
Имя: {customer}
|
||||
Email: {email}
|
||||
Телефон: {phone}
|
||||
IP: {ip}
|
||||
|
||||
*Адрес доставки:*
|
||||
{address}
|
||||
|
||||
*Комментарий:*
|
||||
<b>Комментарий к заказу</b>
|
||||
{comment}
|
||||
|
||||
*Сумма заказа:* {total}
|
||||
*Дата оформления:* {created_at}
|
||||
TEXT,
|
||||
"customer_notification_template" => <<<TEXT
|
||||
Спасибо за Ваш заказ в магазине *{store_name}*
|
||||
<b>Сумма заказа:</b> {total}
|
||||
<b>Дата оформления:</b> {created_at}
|
||||
HTML,
|
||||
"customer_notification_template" => <<<HTML
|
||||
✅ <b>Заказ оформлен</b>
|
||||
|
||||
*Номер заказа* \#{order_id}
|
||||
*Сумма заказа:* {total}
|
||||
*Дата оформления:* {created_at}
|
||||
Спасибо за ваш заказ в магазине <b>{store_name}</b>.
|
||||
|
||||
Мы свяжемся с вами при необходимости\.
|
||||
Хорошего дня\!
|
||||
TEXT,
|
||||
<b>Номер заказа:</b> №{order_id}
|
||||
<b>Сумма заказа:</b> {total}р.
|
||||
<b>Дата оформления:</b> {created_at}
|
||||
|
||||
Информация о заказе сохранена.
|
||||
При необходимости с вами свяжутся представители магазина.
|
||||
HTML,
|
||||
"mini_app_url" => "",
|
||||
],
|
||||
|
||||
|
||||
@@ -33,6 +33,6 @@ class StartCommand extends TelegramCommand
|
||||
],
|
||||
];
|
||||
|
||||
$this->telegram->sendMessage($chatId, $message, $buttons, ChatAction::TYPING, 'html');
|
||||
$this->telegram->sendMessage($chatId, $message, $buttons);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,21 +27,9 @@ class TelegramService
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
public function escapeTelegramMarkdownV2(string $text): string
|
||||
{
|
||||
$specials = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
|
||||
foreach ($specials as $char) {
|
||||
$text = str_replace($char, '\\' . $char, $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function prepareMessage(string $template, array $variables = []): string
|
||||
{
|
||||
$values = array_map([$this, 'escapeTelegramMarkdownV2'], array_values($variables));
|
||||
|
||||
return str_replace(array_keys($variables), $values, $template);
|
||||
return str_replace(array_keys($variables), array_values($variables), $template);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +42,7 @@ class TelegramService
|
||||
string $text,
|
||||
array $replyMarkup = [],
|
||||
string $chatAction = ChatAction::TYPING,
|
||||
string $parseMode = 'MarkdownV2'
|
||||
string $parseMode = 'html'
|
||||
): void {
|
||||
if (! $this->botToken) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user