Бот на генераторах — когда нет времени и ресурсов
Представьте, что бизнесу срочно понадобился небольшой бот, например, для сбора данных или генерации картинок или постов. В голову сразу приходит стандартная идея — пишем бота на бэкенде, дальше со стороны клиента просто делаем запрос на отправку сообщения и получаем ответ от бота, который показываем нашему пользователю. Но не тут‑то было — бизнес говорит, что денег и времени на бэкенд нет, а бот нужен был ещё вчера.
Что же делать? Логичное решение — максимально упростить бота и написать его на стороне фронтенда с отправкой результата всех собранных данных на бэкенд и получением конечного ответа от него одним запросом. Думаю многие баловались или просто могут представить, что это делается несложно, но в основе с написанием множества различных конструкций, условий и т. п. Я же предлагаю посмотреть, каким образом можно сделать это с помощью простого советского JavaScript‑генератора.
Как возникла идея?
Думаю, у многих в компаниях есть что‑то типа опросников или порталов для сбора различных корпоративных идей, которые в будущем отправляются наверх, рассматриваются, и часть из них может быть реализована (или нам просто так говорят).
Как‑то раз наша команда решила убить двух зайцев и сделать что‑то, чтобы помочь сотрудникам генерировать такие идеи и заодно пропиарить наших ML‑щиков. Для ML задача была ясна — нужно сделать нейронку, генерирующую идеи.
Со стороны клиента с постановкой задачи было немного труднее, так как нужно было решить, как людям будет интереснее всего всем этим пользоваться. Было опробовано множество различных вариантов, но в итоге остановились на имитации переписки с ботом, который собирает данные о тематике идеи, какие‑то дополнительные данные и выдаёт конечную идею.
В тот момент как раз-таки начали появляться дополнительные параметры, чтобы запрос был достаточно гибким, но он всё равно оставался один. И во время написания приложения я пришёл к тому, что можно сделать это всё на генераторах, так как с их помощью можно ходить по шагам, при этом выполняя всё в бесконечном цикле, и ничего не сломается.
Немного фактов
Думаю, многие, даже если не использовали генераторы, всё равно читали про них, так как информации в интернете достаточно, на том же Хабре, вбив в поиске «Генераторы Javascript» можно получить множество крутых и сложных статей, поэтому останавливаться на этом не будем.
Расскажу только о паре интересных фактов о том, где вы уже используете генераторы или итераторы (самый простой способ их создания — это генераторы), при этом возможно даже не догадываетесь об этом:
Async/await является синтаксическим сахаром для работы с промисами, но под капотом он использует генераторы. Когда вы пишете async‑функцию, компилятор преобразует её в генератор, который возвращает промис.
Цикл for…of использует итераторы. Когда вы используете for…of для перебора массива или объекта, JavaScript создаёт итератор.
Spread-оператор (
...
) также использует итераторы. Когда вы используете spread-оператор для разворачивания массива или объекта, JavaScript создает итератор.
Техническая реализация
Перейдём к самой реализации и коду. Код можно посмотреть на Codesandbox. Сразу оговорка — код накидан на коленке и не является идеальным, так как чтобы показать концепцию, хватит и этого.
Что будем реализовывать?
Сделаем простое приложение в виде чата, в котором пользователь будет получать картинку или гифку с котом. Для получения кота необходимо либо выбрать один из доступных тэгов, по которым сформируется кот, либо просто запросить рандомного кота из доступных.
В качестве API возьмём бесплатную апишку https://cataas.com.
Как это выглядит
Первым делом была сверстана оболочка и пара компонентов, которые мы будем использовать в визуализации (кнопки, сообщения и т. п.). Статья не о вёрстке, так что, думаю, тут останавливаться не стоит.
Дальше начинаем писать сам функционал, и вот тут уже будем рассматривать всё подробно.
В нашем приложении стоит выделить две главные сущности, которые используются и являются ключевыми — это сообщения и экшены.
С сообщениями всё просто. У нас будет обычный массив сообщений, в который мы будем добавлять их по мере необходимости. При этом стоит помнить, что сообщения должны различаться по входящим и исходящим.
Сообщения будут состоять из текста или картинки типа (incoming или outgoing) и будут содержать экшены, которые могут быть выполнены после вывода сообщения.
Экшены — это сами события, которые нам необходимо выполнить для получения следующего сообщения. По их количеству будут генерироваться кнопки, которые будут вызывать экшены. Экшен может быть любой функцией, которая будет что‑либо делать.
type MessageType = "incoming" | "outgoing";
type MessageProps = {
text?: string | JSX.Element;
src?: string;
type?: MessageType;
className?: string;
};
type Message = MessageProps & { actions?: Action[] };
const [messages, setMessages] = useState([]);
const [actions, setActions] = useState([]);
const setNewMessage = async (message: Message) => {
setMessages((prev) => [...prev, message]);
message.actions && setActions(message.actions);
};
const setImage = (src: string) => {
setNewMessage({
src,
type: "incoming",
});
};
Так как мы уже проговорили, что сообщения все лежат на стороне клиента, мы должны их захардкодить. Для этого я решил создать объект defaultMessages, из которого, в зависимости от шага, на котором мы присутствуем, будет браться сообщение.
export enum Step {
Hello = 'hello',
GetCat = 'getCat',
CatsByTag = 'catsByTag',
ChangeCats = 'changeCats',
}
const defaultMessages: Record = {
[Step.Hello]: {
text: "Привет!",
type: "incoming",
},
[Step.GetCat]: {
text: "Давай получим себе кота)",
type: "incoming",
actions: [
{
label: "Давай!",
onClick: (text: string) => actionCb(Actions.Go, text),
},
],
},
[Step.ChangeCats]: {
text: "Какого ты хочешь?",
type: "incoming",
actions: [
{
label: "Рандомного",
onClick: (text: string) => actionCb(Actions.RandomCats, text),
},
{
label: "Выбрать по тэгу",
onClick: (text: string) => actionCb(Actions.CatsByTag, text),
},
],
},
[Step.CatsByTag]: {
text: "Выбери кота",
type: "incoming",
},
};
В коде видно, что для экшенов используется функция actionCb. Эта функция будет возвращать нам сам экшен и необходима нам, чтобы разделить логику.
export enum Actions {
RandomCats = 'randomCats',
CatsByTag = 'catsByTag',
ChangeTag = 'changeTag',
Go = 'go',
}
async function actionCb(action: Actions, text: string, responseText?: string) {
setIsSleep(true);
setNewMessage({ text, type: "outgoing" });
switch (action) {
case Actions.CatsByTag: {
setActions(
Object.entries(catTags).map(([tag, cat]) => ({
label: cat,
onClick: () => actionCb(Actions.ChangeTag, cat, tag),
}))
);
break;
}
case Actions.Go: {
await sleep(1000);
generator.next();
break;
}
case Actions.RandomCats: {
const src = await getRandomCat();
setImage(src);
break;
}
case Actions.ChangeTag: {
if (responseText === "back") {
generator.next();
break;
}
const src = await getCatByTag(responseText ?? text);
setImage(src);
break;
}
}
setIsSleep(false);
}
Можно заметить, что в экшенах начали появляться запросы и использоваться методы генератора. Давайте рассмотрим каждый экшен поближе, чтобы разобраться, что он делает.
export const catTags = {
cute: "милый",
play: "игривый",
sleepy: "сонный",
fat: "толстый",
back: "назад",
};
/........................../
case Actions.CatsByTag: {
setActions(
Object.entries(catTags).map(([tag, cat]) => ({
label: cat,
onClick: () => actionCb(Actions.ChangeTag, cat, tag),
}))
);
break;
}
Экшен CatsByTag будет выполнен после нажатия кнопки «Выбрать по тэгу». Он необходим нам, чтобы сгенерировать последующие кнопки с экшенами для каждого из предзаданных тэгов.
case Actions.ChangeTag: {
if (responseText === "back") {
generator.next();
break;
}
const src = await getCatByTag(responseText ?? text);
setImage(src);
break;
}
При генерации кнопок с тэгами к ним привязывается метод из экшена ChangeTag. Здесь по нажатию на тэг будет отправляться запрос для получения кота с нужным нам тэгом, а также добавляться сообщение с полученной нами картинкой.
Можно заметить, что тут есть конструкция, которая просто переводит генератор на следующий шаг. Эта часть нужна нам специально для того, если мы хотим прекратить получать кота по тэгу и вернуться назад к выбору метода получения кота.
case Actions.RandomCats: {
const src = await getRandomCat();
setImage(src);
break;
}
RandomCats нужен нам для альтернативного метода получения кота, а именно — получения рандомного кота без параметров. Экшен достаточно простой, и в нём мы делаем запрос за картинкой и выводим её в сообщении.
case Actions.Go: {
await sleep(1000);
generator.next();
break;
}
Go — экшен для нажатия на кнопку «Давай». По сути он просто выполняет задержку и после неё перекидывает нас на следующий шаг нашей итерации сообщений.
Стоит отметить, что здесь появляется метод sleep, который нужен для имитации задержки написания сообщения и показа в этот момент трёх точек. Можно было обойтись без него, но он придаёт интерактивности нашему боту, как будто он более живой, чем есть на самом деле.
const sleep = async (ms: number) => {
setIsSleep(true);
return new Promise((r) =>
setTimeout(() => {
r(true);
setIsSleep(false);
}, ms)
);
};
Ну и наконец перейдём к нашему созданию генератора, ради чего мы здесь все и собрались.
async function getDefaultMessage(step: Step) {
return defaultMessages[step];
}
function* scriptGenerator() {
yield getDefaultMessage(Step.Hello).then((res) => setNewMessage(res));
yield getDefaultMessage(Step.GetCat).then((res) => setNewMessage(res));
while (true) {
yield getDefaultMessage(Step.ChangeCats).then((res) => setNewMessage(res));
}
}
Как видим, по итогу кода здесь очень мало, но в этом и есть его плюс. Первыми двумя вызовами мы выведем сообщения «Привет!» и «Давай получим себе кота)». А дальше мы просто будем гонять наше приложение в цикле, во время которого будет выбираться кот, которого пользователь хочет получить.
Осталось только запустить наш вечный двигатель генератор при загрузке приложения и всё.
const generator = scriptGenerator();
useEffect(() => {
generator.next();
sleep(1000).then(() => generator.next());
}, []);
Плюсы и минусы решения
Как по мне, можно выделить следующие минусы:
Необходимо создавать объект для сообщений и для экшенов. Сейчас это не критично, но если мы захотим сделать большую вариативность — это всё разрастётся. Хотя это, скорее, минус в принципе ботов на клиенте.
Нужно вникать в работу генераторов и правильно расставлять шаги. Много кто не работал с генераторами, и даже такое небольшое решение потребует углубиться и хотя бы немного их изучить
Из плюсов, которые вижу я:
Всё само крутится в бесконечном цикле.
Не надо заморачиваться, чтобы делать ещё больше функций и конструкций, которые будут возвращать нас на шаг назад, вперёд или просто бесконечно выбирать кота по тэгам, как пришлось бы делать способом без генераторов. Как следствие — кода меньше, и не так сложно его понять.
Не претендую на полноту количества плюсов и минусов и буду рад увидеть ваши дополнения на этот счёт в комментариях.
Область применения и выводы
Итак, теперь вы тоже знаете, как написать бота на клиенте ещё одним способом. Можно накинуть варианты, когда это может пригодиться, тут уже зависит от полёта вашей фантазии.
Из того, что приходит мне в голову:
Любой бот для сбора данных, например, нам нужно в игровой форме собрать данные для регистрации клиента или бронирования какого‑нибудь тура на сапах.
Расширить функционал бота для проставления лайков и дизлайков и тогда можно будет реализовать мини‑версию «Тиндера», как в фильме «Социальная сеть». Вам будет приходить сообщение с двумя фотками пары/девушки/парня, и вы будете ставить лайк на более понравившуюся вам. В следующем сообщении будет выводиться фотка, которая понравилась вам, и следующая. И так до бесконечности.
Решение наподобие предыдущего с выбором наилучшего варианта, но с более полезной нагрузкой. У вас есть нейронка, которая обучена на каких‑либо данных, и вы хотите её дообучить уже на реальных пользователях, например, генерация случайных фактов о чём‑либо. Вам будет присылаться факт, и вы будете голосовать — реальный ли он (или интересный), или нет. Либо также будет выводиться два факта, и вы будете выбирать наиболее интересный.
Думаю, можно придумать ещё много разных вариантов, где реализовать такого простенького бота, и если у вас есть такие идеи — welcome в комменты :-)