feat: add html editor for telegram messages

This commit is contained in:
2025-12-15 19:38:57 +03:00
parent 32ccb1ef43
commit 97df5b4c0a
11 changed files with 263 additions and 58 deletions

View File

@@ -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",

View File

@@ -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",

View 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>

View File

@@ -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: {

View File

@@ -96,6 +96,7 @@ class ControllerExtensionModuleTgshop extends Controller
public function index(): void
{
$this->runMaintenanceTasks();
$this->injectAssets();
$this->injectVueJs();
$this->showConfigPage();
}
@@ -112,7 +113,7 @@ class ControllerExtensionModuleTgshop extends Controller
$data['themes'] = self::$themes;
$data['telecart_module_version'] = module_version();
$data['shop_base_url'] = HTTPS_CATALOG;
$data['action'] = $this->url->link(
'extension/module/tgshop',
'user_token=' . $this->session->data['user_token'],
@@ -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');
}
}

View File

@@ -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 }}

View File

@@ -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', [

View File

@@ -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;

View File

@@ -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" => "",
],

View File

@@ -33,6 +33,6 @@ class StartCommand extends TelegramCommand
],
];
$this->telegram->sendMessage($chatId, $message, $buttons, ChatAction::TYPING, 'html');
$this->telegram->sendMessage($chatId, $message, $buttons);
}
}

View File

@@ -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;