select predefined messages
Deploy db-migrations / db-migrations (push) Successful in 49s
Deploy service message-delivery-method-web-push / service message-delivery-method-web-push (push) Successful in 1m9s
Deploy app frontend / app frontend (push) Successful in 1m53s
Deploy service tg-bot / service tg-bot (push) Successful in 1m36s

This commit is contained in:
2024-12-17 02:41:14 +02:00
parent c1edb1937f
commit 0e37ebfe5f
15 changed files with 630 additions and 70 deletions
+5 -5
View File
@@ -63,16 +63,16 @@
"@grammyjs/types": "^3.17.0",
"@hereconnect/types": "workspace:^",
"@types/bun": "^1.1.14",
"@types/node": "^22.10.1",
"@types/node": "^22.10.2",
"@types/pouchdb-adapter-http": "^6.1.6",
"@types/pouchdb-core": "^7.0.15",
"@types/pouchdb-find": "^7.3.3",
"@types/pouchdb-upsert": "^2.2.9",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"eslint": "^9.16.0",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"tsx": "^4.19.2",
"type-fest": "^4.30.1",
"type-fest": "^4.30.2",
"typescript": "~5.7.2"
}
}
+2
View File
@@ -52,6 +52,7 @@ interface CreateQRCodeOptions {
placement: QRCodeDocument["placement"];
actions: QRCodeDocument["actions"];
messageDeliveryMethod?: QRCodeDocument["messageDeliveryMethod"];
predefinedMessages?: QRCodeDocument["predefinedMessages"];
browser_uuid?: string;
telegram_id?: string;
}
@@ -74,6 +75,7 @@ export async function createQRCode(options: CreateQRCodeOptions) {
name: options.name,
placement: options.placement,
actions: options.actions,
predefinedMessages: options.predefinedMessages,
uri,
url,
messageDeliveryMethod: options.messageDeliveryMethod || "telegram",
@@ -73,6 +73,7 @@ export async function qrCodeActions(ctx: MyContext) {
break;
default:
console.warn(`Игнорируем callback_data в qrCodeActions: ${data}`);
await ctx.answerCallbackQuery({ text: "Неизвестное действие." });
}
}
+3 -3
View File
@@ -5,10 +5,10 @@ import { qrCodeActions } from "./actions/qrCodeActions";
/**
* Обработка callback_query для управления QR-кодами
*/
export async function callbackQueryHandler(ctx: MyContext) {
export async function callbackQueryHandler(ctx: MyContext, next: () => void) {
const data = ctx.callbackQuery?.data;
if (!data) return; // Игнорируем пустые callback queries
if (!data) return next(); // Игнорируем пустые callback queries
if (
data.startsWith("manage:info:") ||
@@ -22,6 +22,6 @@ export async function callbackQueryHandler(ctx: MyContext) {
await manageMenuActions(ctx);
} else {
// Ответ на неизвестные callback queries
await ctx.answerCallbackQuery({ text: "Неизвестное действие." });
next();
}
}
+3 -4
View File
@@ -2,6 +2,8 @@ import { MyContext } from "../../types/myContext";
import { ensureUserExists } from "../../api/users";
export async function startCommand(ctx: MyContext) {
ctx.session = {};
const userId = ctx.from?.id;
if (userId === undefined) {
@@ -15,9 +17,6 @@ export async function startCommand(ctx: MyContext) {
await ensureUserExists(userId);
await ctx.reply(
"Привет! Я бот сервиса «НаСвязи». Я помогу вам создавать и настраивать QR-коды.",
);
await ctx.reply(
"Используйте команду /new чтобы создать новый QR-код или /manage для управления уже созданными кодами.",
"Привет! Я бот сервиса «НаСвязи». Я помогу вам создавать и настраивать QR-коды.\n\nИспользуйте команду /new чтобы создать новый QR-код или /manage для управления уже созданными кодами.",
);
}
@@ -10,6 +10,8 @@ import {
import { createQRCode } from "../../api/qrCodes";
import { generateQRCodeImage } from "../../services/qrGenerator";
import { selectMultipleActions } from "./selectMultipleActions";
import { defaultMessages } from "@hereconnect/constants";
import { selectPredefinedMessages } from "./selectPredefinedMessages";
export async function newQRCodeConversation(
conversation: Conversation<MyContext>,
@@ -30,7 +32,7 @@ export async function newQRCodeConversation(
return;
}
// 1. Выбор типа объекта (placement)
// Выбор типа объекта (placement)
await ctx.reply("Выберите объект, для которого будет создан QR-код:", {
reply_markup: {
inline_keyboard: buildPlacementKeyboard(qrCodePlacements),
@@ -52,7 +54,24 @@ export async function newQRCodeConversation(
// Подтверждаем выбор
await placementChoice.answerCallbackQuery({ text: "Объект выбран!" });
// 2. Ввод названия QR-кода
// Выбор действий
const availableActions = [
{ action: "sendMessage", label: "Отправить сообщение" },
{ action: "viewContactInfo", label: "Посмотреть контактные данные" },
] as const;
const chosenActions = await selectMultipleActions(
conversation,
ctx,
availableActions,
);
if (chosenActions.length === 0) {
await ctx.reply("Вы не выбрали ни одного действия. Попробуйте снова /new.");
return;
}
// Ввод названия QR-кода
let exampleName: string;
switch (typedPlacement) {
@@ -76,6 +95,15 @@ export async function newQRCodeConversation(
break;
}
let predefinedMessages: QRCodeDocument["predefinedMessages"] = undefined;
if (chosenActions.includes("sendMessage")) {
predefinedMessages = await selectPredefinedMessages(
conversation,
ctx,
typedPlacement,
);
}
// Отправляем сообщение пользователю с примером
await ctx.reply(
`Введите название для вашего QR-кода (например: «${exampleName}»):`,
@@ -83,42 +111,23 @@ export async function newQRCodeConversation(
const nameMsg = await conversation.waitFor("message:text");
const name = nameMsg.message.text.trim();
if (name.length < 2) {
await ctx.reply(
"Название должно содержать минимум 2 символа. Попробуйте снова /new.",
);
return;
}
// 3. Выбор действий
const availableActions = [
{ action: "sendMessage", label: "📩 Отправить сообщение" },
{ action: "viewContactInfo", label: "📞 Посмотреть контактные данные" },
] as const;
// Уведомление о создании QR-кода
await ctx.reply("Создаём QR-код…");
const chosenActions = await selectMultipleActions(
conversation,
ctx,
availableActions,
);
if (chosenActions.length === 0) {
await ctx.reply("Вы не выбрали ни одного действия. Попробуйте снова /new.");
return;
}
// 4. Создание документа в БД
// Создание документа в БД
const { url } = await createQRCode({
userUuid,
name,
placement: typedPlacement,
actions: chosenActions,
predefinedMessages,
});
// 5. Генерация QR-кода
// Генерация QR-кода
const pngBuffer = await generateQRCodeImage(url, { format: "png" });
// 6. Отправляем пользователю QR-код
// Отправляем пользователю QR-код
await ctx.reply("Ваш QR-код готов! Вот он:");
await ctx.replyWithPhoto(
@@ -31,7 +31,7 @@ export async function selectMultipleActions<T extends readonly Action[]>(
const updateKeyboard = () => {
return availableActions.map(({ action, label }) => [
{
text: `${selectedActions.has(action) ? "️ " : ""}${label}`,
text: `${selectedActions.has(action) ? "️ " : ""}${label}`,
callback_data: `action:${action}`,
},
]);
@@ -76,10 +76,9 @@ export async function selectMultipleActions<T extends readonly Action[]>(
const action = choice.replace("action:", "");
if (action === "done") {
await actionChoice.answerCallbackQuery({ text: "Выбор завершён!" });
// Уведомление о создании QR-кода
await ctx.reply("Создаём QR-код…");
await actionChoice.answerCallbackQuery({
text: "Выбор действий завершён!",
});
break;
}
@@ -0,0 +1,325 @@
import { MyContext } from "../../types/myContext";
import { Conversation } from "@grammyjs/conversations";
import { defaultMessages } from "@hereconnect/constants";
import { InlineKeyboardMarkup } from "@grammyjs/types";
// Тип разговора
type MyConversation = Conversation<MyContext>;
export async function selectPredefinedMessages(
conversation: MyConversation,
ctx: MyContext,
placement: keyof typeof defaultMessages,
): Promise<string[]> {
const messages: string[] = [...defaultMessages[placement]];
let mode: "default" | "edit" | "sort" = "default";
let selectedIndex: number | null = null;
let sortOrder: number[] = [];
const updateKeyboard = (): InlineKeyboardMarkup => ({
inline_keyboard: messages
.map((message, index) => {
const prefix =
mode === "edit" && selectedIndex === index
? "📝 "
: mode === "sort" && sortOrder.includes(index)
? `${sortOrder.indexOf(index) + 1}. `
: "";
return [
{
text: `${prefix}${message}`,
callback_data: `select:${index}`,
},
];
})
.concat(
mode === "sort"
? [
[{ text: "✅ Завершить сортировку", callback_data: "done_sort" }],
[
{
text: "❌ Отменить сортировку",
callback_data: "cancel_sort",
},
],
]
: mode === "edit" && selectedIndex !== null
? [
[
{
text: `❌ Удалить «${messages[selectedIndex]}»`,
callback_data: `delete:${selectedIndex}`,
},
],
[
{
text: "❌ Отменить редактирование",
callback_data: "cancel_edit",
},
],
]
: messages.length > 0
? [
[{ text: "🔀 Поменять порядок", callback_data: "sort" }],
[{ text: "✅ Готово", callback_data: "done" }],
]
: [[{ text: "✅ Готово", callback_data: "done" }]],
),
});
// Функция для отправки или редактирования сообщения
const sendOrEditMessage = async (
text: string,
options: {
reply_markup: InlineKeyboardMarkup;
parse_mode?: "Markdown" | "MarkdownV2" | "HTML";
},
forceNew = false,
) => {
try {
if (forceNew && ctx.session.messageId) {
try {
await ctx.api.deleteMessage(ctx.chat!.id, ctx.session.messageId);
ctx.session.messageId = undefined;
console.log("deleteMessage", ctx.session.messageId);
} catch (deleteErr) {
console.warn(
"Ошибка удаления старого сообщения:",
ctx.session.messageId,
deleteErr,
);
ctx.session.messageId = undefined;
}
}
if (ctx.session.messageId) {
await ctx.api.editMessageText(
ctx.chat!.id,
ctx.session.messageId,
text,
options,
);
} else {
const replyMessage = await ctx.reply(text, options);
console.log("replyMessage.message_id", replyMessage.message_id);
ctx.session.messageId = replyMessage.message_id;
}
} catch (err) {
console.error(
"Ошибка обновления сообщения, создаем новое:",
ctx.session.messageId,
err,
);
if (ctx.session.messageId) {
try {
await ctx.api.deleteMessage(ctx.chat!.id, ctx.session.messageId);
console.log("deleteMessage", ctx.session.messageId);
} catch (deleteErr) {
console.warn(
"Ошибка удаления старого сообщения:",
ctx.session.messageId,
deleteErr,
);
}
}
const newMessage = await ctx.reply(text, options);
console.log("newMessage.message_id", newMessage.message_id);
ctx.session.messageId = newMessage.message_id;
}
};
// Начальное сообщение
await sendOrEditMessage(
`**Настройка предустановленных сообщений**
Эти сообщения будут предлагаться пользователю при сканировании QR‑кода. Он сможет выбрать одно из них или написать своё.
Напишите сообщение для добавления, нажмите на сообщение для редактирования или выберите "🔀 Поменять порядок" для сортировки.`,
{ parse_mode: "Markdown", reply_markup: updateKeyboard() },
);
while (true) {
const actionChoice = await conversation.waitFor([
"callback_query:data",
"message:text",
]);
const choice = actionChoice.update.callback_query?.data;
if (!choice && !actionChoice.message) {
if (choice) {
// await actionChoice.answerCallbackQuery({
// text: "Произошла ошибка. Попробуйте снова.",
// });
continue;
}
ctx.session.messageId = undefined;
break;
}
const messageText = actionChoice.message?.text.trim();
if (!choice) {
if (messageText) {
if (mode === "edit" && selectedIndex !== null) {
messages[selectedIndex] = messageText;
mode = "default";
selectedIndex = null;
// await ctx.reply(`Сообщение обновлено: ${messageText}`);
await sendOrEditMessage(
"Сообщение обновлено.",
{
parse_mode: "Markdown",
reply_markup: updateKeyboard(),
},
true,
);
} else {
messages.push(messageText);
// await ctx.reply(`Сообщение добавлено: ${messageText}`);
await sendOrEditMessage(
"Сообщение добавлено.",
{
parse_mode: "Markdown",
reply_markup: updateKeyboard(),
},
true,
);
}
} else {
await ctx.reply(`Введите текст сообщения.`);
}
continue;
}
if (choice === "done") {
// await actionChoice.answerCallbackQuery({ text: "Настройка завершена!" });
await ctx.api.deleteMessage(ctx.chat!.id, ctx.session.messageId!);
ctx.session.messageId = undefined;
break;
}
if (choice === "sort") {
mode = "sort";
sortOrder.length = 0;
// await actionChoice.answerCallbackQuery({ text: "Режим сортировки." });
await sendOrEditMessage("Нажмите на сообщения по порядку.", {
parse_mode: "Markdown",
reply_markup: updateKeyboard(),
});
continue;
}
if (mode === "sort" && choice.startsWith("select:")) {
const index = parseInt(choice.replace("select:", ""), 10);
if (sortOrder.includes(index)) {
sortOrder.splice(sortOrder.indexOf(index), 1);
} else {
sortOrder.push(index);
}
const sortedMessages: string[] = [];
sortOrder.forEach((sortIndex, messagesIndex) => {
sortedMessages[messagesIndex] = messages[sortIndex];
});
messages.forEach((message, index) => {
if (!sortOrder.includes(index)) {
sortedMessages.push(message);
}
});
// Обновляем порядок сообщений
for (let i = 0; i < sortedMessages.length; i++) {
messages[i] = sortedMessages[i];
}
for (let i = 0; i < sortOrder.length; i++) {
sortOrder[i] = i;
}
// await actionChoice.answerCallbackQuery({ text: "Сообщение перемещено." });
await sendOrEditMessage(
"Нажмите на сообщения по порядку, начиная с первого.",
{ parse_mode: "Markdown", reply_markup: updateKeyboard() },
);
continue;
}
if (choice === "cancel_sort") {
mode = "default";
sortOrder.length = 0;
// await actionChoice.answerCallbackQuery({ text: "Сортировка отменена." });
await sendOrEditMessage(
"Сортировка отменена. Возврат к стандартному режиму.",
{ parse_mode: "Markdown", reply_markup: updateKeyboard() },
);
continue;
}
if (choice === "done_sort") {
mode = "default";
sortOrder.length = 0;
// await actionChoice.answerCallbackQuery({ text: "Сортировка завершена." });
await sendOrEditMessage("Сортировка завершена.", {
parse_mode: "Markdown",
reply_markup: updateKeyboard(),
});
continue;
}
if (choice.startsWith("select:")) {
const index = parseInt(choice.replace("select:", ""), 10);
if (selectedIndex === index) {
// await actionChoice.answerCallbackQuery({
// text: "Редактирование отменено.",
// });
mode = "default";
selectedIndex = null;
await sendOrEditMessage("Редактирование отменено.", {
parse_mode: "Markdown",
reply_markup: updateKeyboard(),
});
continue;
}
mode = "edit";
selectedIndex = index;
// await actionChoice.answerCallbackQuery({
// text: `Редактирование сообщения: "${messages[index]}"`,
// });
await sendOrEditMessage(
`Редактирование сообщения: "${messages[index]}"`,
{ parse_mode: "Markdown", reply_markup: updateKeyboard() },
);
continue;
}
if (choice === "cancel_edit") {
mode = "default";
selectedIndex = null;
// await actionChoice.answerCallbackQuery({
// text: "Редактирование отменено.",
// });
await sendOrEditMessage("Редактирование отменено.", {
parse_mode: "Markdown",
reply_markup: updateKeyboard(),
});
continue;
}
if (choice.startsWith("delete:")) {
const index = parseInt(choice.replace("delete:", ""), 10);
const deletedMessage = messages.splice(index, 1)[0];
mode = "default";
selectedIndex = null;
// await actionChoice.answerCallbackQuery({
// text: `Сообщение "${deletedMessage}" удалено.`,
// });
await sendOrEditMessage(`Сообщение "${deletedMessage}" удалено.`, {
parse_mode: "Markdown",
reply_markup: updateKeyboard(),
});
}
}
return messages;
}
+11 -5
View File
@@ -45,19 +45,25 @@ export const createBot = ({
// Подключаем conversations для многошаговых сценариев
bot.use(conversations());
bot.use(createConversation(newQRCodeConversation));
// Регистрация команд
bot.command("start", startCommand);
bot.command("help", helpCommand);
bot.command("new", newCommand);
bot.command("manage", manageCommand);
// Обработка callback_query
bot.on("callback_query:data", callbackQueryHandler);
bot.command("cancel", async (ctx) => {
await ctx.conversation.exit();
await ctx.reply("Leaving.");
});
// Middleware для обработки ответов на сообщения
bot.use(forwardQrCodeMessageMiddleware);
// /new
bot.use(createConversation(newQRCodeConversation));
bot.command("new", newCommand);
// Обработка callback_query
bot.on("callback_query:data", callbackQueryHandler);
return bot;
};
@@ -32,10 +32,10 @@ export const forwardQrCodeMessageMiddleware = async (
ctx.msg.text.match(qrCodeMessageRegexp);
if (!m?.groups) {
console.log("m.groups is empty", m, m?.groups);
await ctx.reply(
`не возможно обработать это сообщение.\nВыберете в телеграм ответить у любого сообщения QR-кода чтобы я его мог переслать в диалог.`,
);
// console.log("m.groups is empty", m, m?.groups);
// await ctx.reply(
// `не возможно обработать это сообщение.\nВыберете в телеграм ответить у любого сообщения QR-кода чтобы я его мог переслать в диалог.`,
// );
return next();
}
+1
View File
@@ -7,6 +7,7 @@ import type { UserDocument } from "@hereconnect/types";
export interface SessionData {
step?: number;
messageId?: number;
}
// Базовый контекст без conversation