From 782dcba7123f8ede99f24aad70e627b7f257796b Mon Sep 17 00:00:00 2001 From: slaveeks Date: Sat, 22 Nov 2025 15:19:38 +0300 Subject: [PATCH 1/8] feat(noti): added loop sender worker --- package.json | 2 + workers/loop/package.json | 15 ++ workers/loop/src/deliverer.ts | 47 +++++ workers/loop/src/index.ts | 24 +++ workers/loop/src/provider.ts | 57 +++++ workers/loop/src/templates/event.ts | 57 +++++ workers/loop/src/templates/index.ts | 7 + workers/loop/src/templates/several-events.ts | 19 ++ workers/loop/tests/__mocks__/event-notify.ts | 43 ++++ .../tests/__mocks__/several-events-notify.ts | 63 ++++++ workers/loop/tests/provider.test.ts | 196 ++++++++++++++++++ workers/loop/types/template.d.ts | 13 ++ workers/loop/yarn.lock | 48 +++++ workers/notifier/tests/worker.test.ts | 15 ++ workers/notifier/types/channel.ts | 1 + 15 files changed, 607 insertions(+) create mode 100644 workers/loop/package.json create mode 100644 workers/loop/src/deliverer.ts create mode 100644 workers/loop/src/index.ts create mode 100644 workers/loop/src/provider.ts create mode 100644 workers/loop/src/templates/event.ts create mode 100644 workers/loop/src/templates/index.ts create mode 100644 workers/loop/src/templates/several-events.ts create mode 100644 workers/loop/tests/__mocks__/event-notify.ts create mode 100644 workers/loop/tests/__mocks__/several-events-notify.ts create mode 100644 workers/loop/tests/provider.test.ts create mode 100644 workers/loop/types/template.d.ts create mode 100644 workers/loop/yarn.lock diff --git a/package.json b/package.json index 9f094dc2c..69cfe1e81 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:javascript": "jest workers/javascript", "test:release": "jest workers/release", "test:slack": "jest workers/slack", + "test:loop": "jest workers/loop", "test:limiter": "jest workers/limiter --runInBand", "test:grouper": "jest workers/grouper", "test:diff": "jest ./workers/grouper/tests/diff.test.ts", @@ -37,6 +38,7 @@ "run-sentry": "yarn worker hawk-worker-sentry", "run-js": "yarn worker hawk-worker-javascript", "run-slack": "yarn worker hawk-worker-slack", + "run-loop": "yarn worker hawk-worker-loop", "run-grouper": "yarn worker hawk-worker-grouper", "run-archiver": "yarn worker hawk-worker-archiver", "run-accountant": "yarn worker hawk-worker-accountant", diff --git a/workers/loop/package.json b/workers/loop/package.json new file mode 100644 index 000000000..eccaaec7f --- /dev/null +++ b/workers/loop/package.json @@ -0,0 +1,15 @@ +{ + "name": "hawk-worker-loop", + "version": "1.0.0", + "description": "", + "main": "src/index.ts", + "license": "MIT", + "workerType": "sender/loop", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@slack/webhook": "^5.0.3", + "json-templater": "^1.2.0" + } +} diff --git a/workers/loop/src/deliverer.ts b/workers/loop/src/deliverer.ts new file mode 100644 index 000000000..6645dec82 --- /dev/null +++ b/workers/loop/src/deliverer.ts @@ -0,0 +1,47 @@ +import { IncomingWebhook } from '@slack/webhook'; +import { createLogger, format, Logger, transports } from 'winston'; + +/** + * Deliverer is the man who will send messages to external service + * Separated from the provider to allow testing 'send' method + * Loop is Slack-like platform, so we use Slack API to send messages. + * + */ +export default class LoopDeliverer { + /** + * Logger module + * (default level='info') + */ + private logger: Logger = createLogger({ + level: process.env.LOG_LEVEL || 'info', + transports: [ + new transports.Console({ + format: format.combine( + format.timestamp(), + format.colorize(), + format.simple(), + format.printf((msg) => `${msg.timestamp} - ${msg.level}: ${msg.message}`) + ), + }), + ], + }); + + /** + * Sends message to the Loop through the Incoming Webhook app + * https://developers.loop.ru/integrate/webhooks/incoming/ + * + * @param endpoint - where to send + * @param message - what to send + */ + public async deliver(endpoint: string, message: string): Promise { + try { + const webhook = new IncomingWebhook(endpoint, { + username: 'Hawk', + }); + + await webhook.send(message); + } catch (e) { + this.logger.log('error', 'Can\'t deliver Incoming Webhook. Loop returns an error: ', e); + } + } +} diff --git a/workers/loop/src/index.ts b/workers/loop/src/index.ts new file mode 100644 index 000000000..174a5ac37 --- /dev/null +++ b/workers/loop/src/index.ts @@ -0,0 +1,24 @@ +import * as pkg from './../package.json'; +import LoopProvider from './provider'; +import SenderWorker from 'hawk-worker-sender/src'; +import { ChannelType } from 'hawk-worker-notifier/types/channel'; + +/** + * Worker to send email notifications + */ +export default class LoopSenderWorker extends SenderWorker { + /** + * Worker type + */ + public readonly type: string = pkg.workerType; + + /** + * Email channel type + */ + protected channelType = ChannelType.Loop; + + /** + * Email provider + */ + protected provider = new LoopProvider(); +} diff --git a/workers/loop/src/provider.ts b/workers/loop/src/provider.ts new file mode 100644 index 000000000..a4269d508 --- /dev/null +++ b/workers/loop/src/provider.ts @@ -0,0 +1,57 @@ +import NotificationsProvider from 'hawk-worker-sender/src/provider'; +import { Notification, EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; +import templates from './templates'; +import { LoopTemplate } from '../types/template'; +import LoopDeliverer from './deliverer'; + +/** + * This class provides a 'send' method that will renders and sends a notification + */ +export default class LoopProvider extends NotificationsProvider { + /** + * Class with the 'deliver' method for sending messages to the Loop + */ + private readonly deliverer: LoopDeliverer; + + /** + * Constructor allows to separate dependencies that can't be tested, + * so in tests they will be mocked. + */ + constructor() { + super(); + + this.deliverer = new LoopDeliverer(); + } + + /** + * Send loop message to recipient + * + * @param to - recipient endpoint + * @param notification - notification with payload and type + */ + public async send(to: string, notification: Notification): Promise { + let template: LoopTemplate; + + switch (notification.type) { + case 'event': template = templates.EventTpl; break; + case 'several-events':template = templates.SeveralEventsTpl; break; + /** + * @todo add assignee notification for telegram provider + */ + } + + const message = await this.render(template, notification.payload as EventsTemplateVariables); + + await this.deliverer.deliver(to, message); + } + + /** + * Render loop message template + * + * @param template - template to render + * @param variables - variables for template + */ + private async render(template: LoopTemplate, variables: EventsTemplateVariables): Promise { + return template(variables); + } +} diff --git a/workers/loop/src/templates/event.ts b/workers/loop/src/templates/event.ts new file mode 100644 index 000000000..bd1f9ced0 --- /dev/null +++ b/workers/loop/src/templates/event.ts @@ -0,0 +1,57 @@ +import { GroupedEventDBScheme } from '@hawk.so/types'; +import type { EventsTemplateVariables, TemplateEventData } from 'hawk-worker-sender/types/template-variables'; +import { toMaxLen } from '../../../slack/src/templates/utils'; + +/** + * Renders backtrace overview + * + * @param event - event to render + */ +function renderBacktrace(event: GroupedEventDBScheme): string { + let code = ''; + + const firstNotEmptyFrame = event.payload.backtrace.find(frame => !!frame.sourceCode); + + if (!firstNotEmptyFrame) { + return code; + } + + code = firstNotEmptyFrame.sourceCode.map(({ line, content }) => { + let colDelimiter = ': '; + + if (line === firstNotEmptyFrame.line) { + colDelimiter = ' ->'; + } + + const MAX_SOURCE_CODE_LINE_LENGTH = 65; + + return `${line}${colDelimiter} ${toMaxLen(content, MAX_SOURCE_CODE_LINE_LENGTH)}`; + }).join('\n'); + + return code; +} + +/** + * Return tpl with data substitutions + * + * @param tplData - event template data + */ +export default function render(tplData: EventsTemplateVariables): string { + const eventInfo = tplData.events[0] as TemplateEventData; + const event = eventInfo.event; + const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/'; + let location = 'Неизвестное место'; + + if (event.payload.backtrace && event.payload.backtrace.length > 0) { + location = event.payload.backtrace[0].file; + } + + return ''.concat( + `**${event.payload.title}**`, + '\n', + `*${location}*\n`, + '```\n' + renderBacktrace(event) + '\n```', + '\n', + `[Посмотреть подробности](${eventURL}) `, `| *${tplData.project.name}*`, ` | ${eventInfo.newCount} новых (${eventInfo.event.totalCount} всего)` + ); +} diff --git a/workers/loop/src/templates/index.ts b/workers/loop/src/templates/index.ts new file mode 100644 index 000000000..6e23324c1 --- /dev/null +++ b/workers/loop/src/templates/index.ts @@ -0,0 +1,7 @@ +import EventTpl from './event'; +import SeveralEventsTpl from './several-events'; + +export default { + EventTpl, + SeveralEventsTpl, +}; diff --git a/workers/loop/src/templates/several-events.ts b/workers/loop/src/templates/several-events.ts new file mode 100644 index 000000000..b711a3dd0 --- /dev/null +++ b/workers/loop/src/templates/several-events.ts @@ -0,0 +1,19 @@ +import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; + +/** + * Return tpl with data substitutions + * + * @param tplData - event template data + */ +export default function render(tplData: EventsTemplateVariables): string { + const projectUrl = tplData.host + '/project/' + tplData.project._id; + let message = tplData.events.length + ' новых событий\n\n'; + + tplData.events.forEach(({ event, newCount }) => { + message += `(${newCount}) ${event.payload.title} \n`; + }); + + message += `\n[Посмотреть все события](${projectUrl}) | *${tplData.project.name}*`; + + return message; +} diff --git a/workers/loop/tests/__mocks__/event-notify.ts b/workers/loop/tests/__mocks__/event-notify.ts new file mode 100644 index 000000000..77a53b54a --- /dev/null +++ b/workers/loop/tests/__mocks__/event-notify.ts @@ -0,0 +1,43 @@ +import { EventNotification } from 'hawk-worker-sender/types/template-variables'; +import { ObjectId } from 'mongodb'; + +/** + * Example of new-events notify template variables + */ +export default { + type: 'event', + payload: { + events: [ + { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + }, + daysRepeated: 1, + newCount: 1, + }, + ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + }, +} as EventNotification; diff --git a/workers/loop/tests/__mocks__/several-events-notify.ts b/workers/loop/tests/__mocks__/several-events-notify.ts new file mode 100644 index 000000000..24ed30e87 --- /dev/null +++ b/workers/loop/tests/__mocks__/several-events-notify.ts @@ -0,0 +1,63 @@ +import { SeveralEventsNotification } from 'hawk-worker-sender/types/template-variables'; +import { GroupedEventDBScheme } from '@hawk.so/types'; +import { ObjectId } from 'mongodb'; + +/** + * Example of several-events notify template variables + */ +export default { + type: 'several-events', + payload: { + events: [ + { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + } as GroupedEventDBScheme, + daysRepeated: 1, + newCount: 1, + }, + { + event: { + totalCount: 5, + payload: { + title: 'New event 2', + timestamp: Date.now(), + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + }, + daysRepeated: 100, + newCount: 1, + }, + ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + }, +} as SeveralEventsNotification; diff --git a/workers/loop/tests/provider.test.ts b/workers/loop/tests/provider.test.ts new file mode 100644 index 000000000..faaf3807f --- /dev/null +++ b/workers/loop/tests/provider.test.ts @@ -0,0 +1,196 @@ +import { EventNotification, SeveralEventsNotification } from 'hawk-worker-sender/types/template-variables'; +import { DecodedGroupedEvent, ProjectDBScheme } from '@hawk.so/types'; +import LoopProvider from '../src/provider'; +import templates from '../src/templates'; +import EventNotifyMock from './__mocks__/event-notify'; +import SeveralEventsNotifyMock from './__mocks__/several-events-notify'; +import { ObjectId } from 'mongodb'; + +/** + * The sample of the Loop Incoming Webhook endpoint + */ +const loopEndpointSample = 'https://hooks.loop.com/services/XXXXXXXXX/XXXXXXXXXX/XXXXXXXXXXX'; + +/** + * Mock the 'deliver' method of LoopDeliverer + */ +const deliver = jest.fn(); + +/** + * Loop Deliverer mock + */ +jest.mock('./../src/deliverer.ts', () => { + return jest.fn().mockImplementation(() => { + /** + * Now we can track calls to 'deliver' + */ + return { + deliver: deliver, + }; + }); +}); + +/** + * Clear all records of mock calls between tests + */ +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('LoopProvider', () => { + /** + * Check that the 'send' method works without errors + */ + it('The "send" method should render and deliver message', async () => { + const provider = new LoopProvider(); + + await provider.send(loopEndpointSample, EventNotifyMock); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith(loopEndpointSample, expect.anything()); + }); + + /** + * Logic for select the template depended on events count + */ + describe('Select correct template', () => { + /** + * If there is a single event in payload, use the 'new-event' template + */ + it('Select the new-event template if there is a single event in notify payload', async () => { + const provider = new LoopProvider(); + const EventTpl = jest.spyOn(templates, 'EventTpl'); + const SeveralEventsTpl = jest.spyOn(templates, 'SeveralEventsTpl'); + + await provider.send(loopEndpointSample, EventNotifyMock); + + expect(EventTpl).toHaveBeenCalledTimes(1); + expect(SeveralEventsTpl).toHaveBeenCalledTimes(0); + }); + + /** + * If there are several events in payload, use the 'several-events' template + */ + it('Select the several-events template if there are several events in notify payload', async () => { + const provider = new LoopProvider(); + const EventTpl = jest.spyOn(templates, 'EventTpl'); + const SeveralEventsTpl = jest.spyOn(templates, 'SeveralEventsTpl'); + + await provider.send(loopEndpointSample, SeveralEventsNotifyMock); + + expect(EventTpl).toHaveBeenCalledTimes(0); + expect(SeveralEventsTpl).toHaveBeenCalledTimes(1); + }); + }); + + /** + * Check templates rendering + */ + describe('templates', () => { + /** + * Check that rendering of a single event message works without errors + */ + it('should successfully render a new-event template', async () => { + const vars: EventNotification = { + type: 'event', + payload: { + events: [{ + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [{ + file: 'file', + line: 1, + sourceCode: [{ + line: 1, + content: 'code', + }], + }], + }, + } as DecodedGroupedEvent, + daysRepeated: 1, + newCount: 1, + }], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + } as ProjectDBScheme, + }, + }; + + const provider = new LoopProvider(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const render = (): string => provider.render(templates.EventTpl, vars.payload); + + expect(render).not.toThrowError(); + + const message = await render(); + + expect(message).toBeDefined(); + }); + + /** + * Check that rendering of a several events message works without errors + */ + it('should successfully render a several-events template', async () => { + const vars: SeveralEventsNotification = { + type: 'several-events', + payload: { + events: [{ + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [{ + file: 'file', + line: 1, + sourceCode: [{ + line: 1, + content: 'code', + }], + }], + }, + } as DecodedGroupedEvent, + daysRepeated: 1, + newCount: 1, + }], + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + } as ProjectDBScheme, + period: 60, + }, + }; + + const provider = new LoopProvider(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const render = (): string => provider.render(templates.SeveralEventsTpl, vars.payload); + + expect(render).not.toThrowError(); + + const message = await render(); + + expect(message).toBeDefined(); + }); + }); +}); diff --git a/workers/loop/types/template.d.ts b/workers/loop/types/template.d.ts new file mode 100644 index 000000000..8a5623786 --- /dev/null +++ b/workers/loop/types/template.d.ts @@ -0,0 +1,13 @@ +import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; + +/** + * Loop templates should implement this interface + */ +export interface LoopTemplate { + /** + * Rendering method that accepts tpl args and return rendered string + * + * @param tplData - template variables + */ + (tplData: EventsTemplateVariables): string; +} diff --git a/workers/loop/yarn.lock b/workers/loop/yarn.lock new file mode 100644 index 000000000..1f72f556a --- /dev/null +++ b/workers/loop/yarn.lock @@ -0,0 +1,48 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@slack/types@^1.2.1": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@slack/types/-/types-1.5.0.tgz#5c2cb0f718689266ff295aad33301d489272c842" + integrity sha512-oCYgatJYxHf9wE3tKXzOLeeTsF0ghX1TIcguNfVmO2V6NDe+cHAzZRglEOmJLdRINDS5gscAgSkeZpDhpKBeUA== + +"@slack/webhook@^5.0.3": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@slack/webhook/-/webhook-5.0.3.tgz#2205cba9a8d49d2ae84ca93f11ab4a1dba2f963b" + integrity sha512-51vnejJ2zABNumPVukOLyerpHQT39/Lt0TYFtOEz/N2X77bPofOgfPj2atB3etaM07mxWHLT9IRJ4Zuqx38DkQ== + dependencies: + "@slack/types" "^1.2.1" + "@types/node" ">=8.9.0" + axios "^0.19.0" + +"@types/node@>=8.9.0": + version "13.11.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-13.11.0.tgz#390ea202539c61c8fa6ba4428b57e05bc36dc47b" + integrity sha512-uM4mnmsIIPK/yeO+42F2RQhGUIs39K2RFmugcJANppXe6J1nvH87PvzPZYpza7Xhhs8Yn9yIAVdLZ84z61+0xQ== + +axios@^0.19.0: + version "0.19.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" + integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA== + dependencies: + follow-redirects "1.5.10" + +debug@=3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== + dependencies: + ms "2.0.0" + +follow-redirects@1.5.10: + version "1.5.10" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" + integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ== + dependencies: + debug "=3.1.0" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= diff --git a/workers/notifier/tests/worker.test.ts b/workers/notifier/tests/worker.test.ts index b757d31e8..882c4dda4 100644 --- a/workers/notifier/tests/worker.test.ts +++ b/workers/notifier/tests/worker.test.ts @@ -42,6 +42,11 @@ const rule = { endpoint: 'emailEndpoint', minPeriod: 0.5, }, + loop: { + isEnabled: true, + endpoint: 'loopEndpoint', + minPeriod: 0.5, + }, }, } as any; @@ -70,6 +75,11 @@ const alternativeRule = { endpoint: 'emailEndpoint', minPeriod: 0.5, }, + loop: { + isEnabled: true, + endpoint: 'loopEndpoint', + minPeriod: 0.5, + }, }, }; @@ -361,6 +371,11 @@ describe('NotifierWorker', () => { endpoint: 'emailEndpoint', minPeriod: 0.5, }, + loop: { + isEnabled: true, + endpoint: 'loopEndpoint', + minPeriod: 0.5, + }, }; await worker.handle(message); diff --git a/workers/notifier/types/channel.ts b/workers/notifier/types/channel.ts index c6b1bc70c..3a195c3d7 100644 --- a/workers/notifier/types/channel.ts +++ b/workers/notifier/types/channel.ts @@ -5,6 +5,7 @@ export enum ChannelType { Email = 'email', Telegram = 'telegram', Slack = 'slack', + Loop = 'loop', } /** From f28cbc5f6e7f0e7fe8736daf479f251edcfe4b37 Mon Sep 17 00:00:00 2001 From: slaveeks Date: Sat, 22 Nov 2025 15:22:12 +0300 Subject: [PATCH 2/8] fix tests --- workers/notifier/tests/worker.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workers/notifier/tests/worker.test.ts b/workers/notifier/tests/worker.test.ts index 882c4dda4..1f128ac16 100644 --- a/workers/notifier/tests/worker.test.ts +++ b/workers/notifier/tests/worker.test.ts @@ -380,7 +380,7 @@ describe('NotifierWorker', () => { await worker.handle(message); - expect(worker.sendToSenderWorker).toBeCalledTimes(2); + expect(worker.sendToSenderWorker).toBeCalledTimes(3); }); it('should compute event count for period for each fitted rule', async () => { From 25f2f2eaaf057e42f1c6f3cb17762a884b892833 Mon Sep 17 00:00:00 2001 From: slaveeks Date: Sat, 22 Nov 2025 18:25:16 +0300 Subject: [PATCH 3/8] added numbers declension --- lib/utils/decl.ts | 11 ++ workers/loop/src/templates/several-events.ts | 6 +- workers/loop/tests/provider.test.ts | 130 ++++++++++++++----- 3 files changed, 110 insertions(+), 37 deletions(-) create mode 100644 lib/utils/decl.ts diff --git a/lib/utils/decl.ts b/lib/utils/decl.ts new file mode 100644 index 000000000..b600f0b59 --- /dev/null +++ b/lib/utils/decl.ts @@ -0,0 +1,11 @@ +/** + * Decl of number + * + * @param value - value to decl + * @param titles - titles to decl: ['новое событие', 'новых события', 'новых событий'] + * @returns decl of number + */ +export function declOfNum(value: number, titles: string[]): string { + const cases = [2, 0, 1, 1, 1, 2]; + return titles[ (value%100>4 && value%100<20)? 2 : cases[(value%10<5)?value%10:5] ]; +} \ No newline at end of file diff --git a/workers/loop/src/templates/several-events.ts b/workers/loop/src/templates/several-events.ts index b711a3dd0..bc7923060 100644 --- a/workers/loop/src/templates/several-events.ts +++ b/workers/loop/src/templates/several-events.ts @@ -1,4 +1,5 @@ import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; +import { declOfNum } from '../../../../lib/utils/decl'; /** * Return tpl with data substitutions @@ -7,7 +8,10 @@ import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template- */ export default function render(tplData: EventsTemplateVariables): string { const projectUrl = tplData.host + '/project/' + tplData.project._id; - let message = tplData.events.length + ' новых событий\n\n'; + let message = tplData.events.length + ' ' + declOfNum( + tplData.events.length, + ['новое событие', 'новых события', 'новых событий'] + ) + '\n\n'; tplData.events.forEach(({ event, newCount }) => { message += `(${newCount}) ${event.payload.title} \n`; diff --git a/workers/loop/tests/provider.test.ts b/workers/loop/tests/provider.test.ts index faaf3807f..4b5a62cfb 100644 --- a/workers/loop/tests/provider.test.ts +++ b/workers/loop/tests/provider.test.ts @@ -1,7 +1,8 @@ -import { EventNotification, SeveralEventsNotification } from 'hawk-worker-sender/types/template-variables'; +import { EventNotification, SeveralEventsNotification, EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; import { DecodedGroupedEvent, ProjectDBScheme } from '@hawk.so/types'; import LoopProvider from '../src/provider'; import templates from '../src/templates'; +import SeveralEventsTpl from '../src/templates/several-events'; import EventNotifyMock from './__mocks__/event-notify'; import SeveralEventsNotifyMock from './__mocks__/several-events-notify'; import { ObjectId } from 'mongodb'; @@ -144,41 +145,7 @@ describe('LoopProvider', () => { * Check that rendering of a several events message works without errors */ it('should successfully render a several-events template', async () => { - const vars: SeveralEventsNotification = { - type: 'several-events', - payload: { - events: [{ - event: { - totalCount: 10, - timestamp: Date.now(), - payload: { - title: 'New event', - backtrace: [{ - file: 'file', - line: 1, - sourceCode: [{ - line: 1, - content: 'code', - }], - }], - }, - } as DecodedGroupedEvent, - daysRepeated: 1, - newCount: 1, - }], - host: process.env.GARAGE_URL, - hostOfStatic: process.env.API_STATIC_URL, - project: { - _id: new ObjectId('5d206f7f9aaf7c0071d64596'), - token: 'project-token', - name: 'Project', - workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), - uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), - notifications: [], - } as ProjectDBScheme, - period: 60, - }, - }; + const vars: SeveralEventsNotification = SeveralEventsNotifyMock; const provider = new LoopProvider(); @@ -191,6 +158,97 @@ describe('LoopProvider', () => { const message = await render(); expect(message).toBeDefined(); + + // Header contains number of events and declension + expect(message).toContain(`${vars.payload.events.length} `); + + // Each event should be listed with "(newCount) title" + vars.payload.events.forEach(({ event, newCount }) => { + expect(message).toContain(`(${newCount}) ${event.payload.title}`); + }); + + // Footer should contain link to the project and project name + const projectUrl = vars.payload.host + '/project/' + vars.payload.project._id; + + expect(message).toContain(`[Посмотреть все события](${projectUrl})`); + expect(message).toContain(`*${vars.payload.project.name}*`); + }); + + /** + * Check declensions in several-events template header + */ + describe('several-events declensions', () => { + const baseEvent = { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [{ + file: 'file', + line: 1, + sourceCode: [{ + line: 1, + content: 'code', + }], + }], + }, + } as DecodedGroupedEvent, + daysRepeated: 1, + newCount: 1, + }; + + const baseProject: ProjectDBScheme = { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + } as ProjectDBScheme; + + const makePayload = (count: number): EventsTemplateVariables => ({ + events: Array.from({ length: count }, () => baseEvent), + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: baseProject, + period: 60, + }); + + it('uses correct declension for 1 event', () => { + const payload = makePayload(1); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('1 новое событие'); + }); + + it('uses correct declension for 2 events', () => { + const payload = makePayload(2); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('2 новых события'); + }); + + it('uses correct declension for 5 events', () => { + const payload = makePayload(5); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('5 новых событий'); + }); + + it('uses correct declension for 10 events', () => { + const payload = makePayload(10); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('10 новых событий'); + }); + + it('uses correct declension for 21 events', () => { + const payload = makePayload(21); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('21 новое событие'); + }); }); }); }); From 290c1a0dac6e3b85dbfdfe2f35440ab38e658421 Mon Sep 17 00:00:00 2001 From: slaveeks Date: Sat, 22 Nov 2025 18:32:20 +0300 Subject: [PATCH 4/8] fix lint --- lib/utils/decl.ts | 20 +++++++++++++++++--- workers/loop/tests/provider.test.ts | 28 ++++++++++++++-------------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/lib/utils/decl.ts b/lib/utils/decl.ts index b600f0b59..4d0fcab54 100644 --- a/lib/utils/decl.ts +++ b/lib/utils/decl.ts @@ -5,7 +5,21 @@ * @param titles - titles to decl: ['новое событие', 'новых события', 'новых событий'] * @returns decl of number */ -export function declOfNum(value: number, titles: string[]): string { - const cases = [2, 0, 1, 1, 1, 2]; - return titles[ (value%100>4 && value%100<20)? 2 : cases[(value%10<5)?value%10:5] ]; +export function declOfNum(value: number, titles: string[]): string { + const decimalBase = 10; + const hundredBase = 100; + const minExclusiveTeens = 4; + const maxExclusiveTeens = 20; + const manyFormIndex = 2; + const maxCaseIndex = 5; + const declCases = [manyFormIndex, 0, 1, 1, 1, manyFormIndex]; + + const valueModHundred = value % hundredBase; + const valueModTen = value % decimalBase; + const isTeens = valueModHundred > minExclusiveTeens && valueModHundred < maxExclusiveTeens; + const caseIndex = isTeens + ? manyFormIndex + : declCases[valueModTen < maxCaseIndex ? valueModTen : maxCaseIndex]; + + return titles[caseIndex]; } \ No newline at end of file diff --git a/workers/loop/tests/provider.test.ts b/workers/loop/tests/provider.test.ts index 4b5a62cfb..8f3f8107d 100644 --- a/workers/loop/tests/provider.test.ts +++ b/workers/loop/tests/provider.test.ts @@ -61,12 +61,12 @@ describe('LoopProvider', () => { it('Select the new-event template if there is a single event in notify payload', async () => { const provider = new LoopProvider(); const EventTpl = jest.spyOn(templates, 'EventTpl'); - const SeveralEventsTpl = jest.spyOn(templates, 'SeveralEventsTpl'); + const SeveralEventsTplSpy = jest.spyOn(templates, 'SeveralEventsTpl'); await provider.send(loopEndpointSample, EventNotifyMock); expect(EventTpl).toHaveBeenCalledTimes(1); - expect(SeveralEventsTpl).toHaveBeenCalledTimes(0); + expect(SeveralEventsTplSpy).toHaveBeenCalledTimes(0); }); /** @@ -75,12 +75,12 @@ describe('LoopProvider', () => { it('Select the several-events template if there are several events in notify payload', async () => { const provider = new LoopProvider(); const EventTpl = jest.spyOn(templates, 'EventTpl'); - const SeveralEventsTpl = jest.spyOn(templates, 'SeveralEventsTpl'); + const SeveralEventsTplSpy = jest.spyOn(templates, 'SeveralEventsTpl'); await provider.send(loopEndpointSample, SeveralEventsNotifyMock); expect(EventTpl).toHaveBeenCalledTimes(0); - expect(SeveralEventsTpl).toHaveBeenCalledTimes(1); + expect(SeveralEventsTplSpy).toHaveBeenCalledTimes(1); }); }); @@ -95,25 +95,25 @@ describe('LoopProvider', () => { const vars: EventNotification = { type: 'event', payload: { - events: [{ + events: [ { event: { totalCount: 10, timestamp: Date.now(), payload: { title: 'New event', - backtrace: [{ + backtrace: [ { file: 'file', line: 1, - sourceCode: [{ + sourceCode: [ { line: 1, content: 'code', - }], - }], + } ], + } ], }, } as DecodedGroupedEvent, daysRepeated: 1, newCount: 1, - }], + } ], period: 60, host: process.env.GARAGE_URL, hostOfStatic: process.env.API_STATIC_URL, @@ -184,14 +184,14 @@ describe('LoopProvider', () => { timestamp: Date.now(), payload: { title: 'New event', - backtrace: [{ + backtrace: [ { file: 'file', line: 1, - sourceCode: [{ + sourceCode: [ { line: 1, content: 'code', - }], - }], + } ], + } ], }, } as DecodedGroupedEvent, daysRepeated: 1, From 4122560d69eafb06dc5cd16449db4c61884a64c2 Mon Sep 17 00:00:00 2001 From: slaveeks Date: Sat, 22 Nov 2025 18:38:33 +0300 Subject: [PATCH 5/8] added examples for decl --- lib/utils/decl.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/utils/decl.ts b/lib/utils/decl.ts index 4d0fcab54..7a4b93cc9 100644 --- a/lib/utils/decl.ts +++ b/lib/utils/decl.ts @@ -3,6 +3,10 @@ * * @param value - value to decl * @param titles - titles to decl: ['новое событие', 'новых события', 'новых событий'] + * @example declOfNum(1, ['новое событие', 'новых события', 'новых событий']) -> 'новое событие' + * @example declOfNum(2, ['новое событие', 'новых события', 'новых событий']) -> 'новых события' + * @example declOfNum(10, ['новое событие', 'новых события', 'новых событий']) -> 'новых событий' + * @example declOfNum(21, ['новое событие', 'новых события', 'новых событий']) -> 'новое событие' * @returns decl of number */ export function declOfNum(value: number, titles: string[]): string { From 8e2a1c72572aaa4db29f636f5f53f8d9e63d4e7f Mon Sep 17 00:00:00 2001 From: Vyacheslav Chernyshev <81693471+slaveeks@users.noreply.github.com> Date: Sat, 22 Nov 2025 19:47:10 +0300 Subject: [PATCH 6/8] Update workers/loop/src/provider.ts Co-authored-by: e11sy <130844513+e11sy@users.noreply.github.com> --- workers/loop/src/provider.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/workers/loop/src/provider.ts b/workers/loop/src/provider.ts index a4269d508..5a9af5bb9 100644 --- a/workers/loop/src/provider.ts +++ b/workers/loop/src/provider.ts @@ -35,9 +35,6 @@ export default class LoopProvider extends NotificationsProvider { switch (notification.type) { case 'event': template = templates.EventTpl; break; case 'several-events':template = templates.SeveralEventsTpl; break; - /** - * @todo add assignee notification for telegram provider - */ } const message = await this.render(template, notification.payload as EventsTemplateVariables); From 09814408a596897fcd1159d9247ce2a2055cb349 Mon Sep 17 00:00:00 2001 From: Vyacheslav Chernyshev <81693471+slaveeks@users.noreply.github.com> Date: Sat, 22 Nov 2025 19:47:19 +0300 Subject: [PATCH 7/8] Update workers/loop/src/provider.ts Co-authored-by: e11sy <130844513+e11sy@users.noreply.github.com> --- workers/loop/src/provider.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/workers/loop/src/provider.ts b/workers/loop/src/provider.ts index 5a9af5bb9..d1143c6de 100644 --- a/workers/loop/src/provider.ts +++ b/workers/loop/src/provider.ts @@ -37,18 +37,8 @@ export default class LoopProvider extends NotificationsProvider { case 'several-events':template = templates.SeveralEventsTpl; break; } - const message = await this.render(template, notification.payload as EventsTemplateVariables); + const message = template(notification.payload as EventsTemplateVariables); await this.deliverer.deliver(to, message); } - - /** - * Render loop message template - * - * @param template - template to render - * @param variables - variables for template - */ - private async render(template: LoopTemplate, variables: EventsTemplateVariables): Promise { - return template(variables); - } } From 9eb3cb933ba01f44741fe79354c18b394249a864 Mon Sep 17 00:00:00 2001 From: slaveeks Date: Sat, 22 Nov 2025 20:03:44 +0300 Subject: [PATCH 8/8] fix tests --- workers/loop/tests/provider.test.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/workers/loop/tests/provider.test.ts b/workers/loop/tests/provider.test.ts index 8f3f8107d..b8185928e 100644 --- a/workers/loop/tests/provider.test.ts +++ b/workers/loop/tests/provider.test.ts @@ -128,15 +128,11 @@ describe('LoopProvider', () => { }, }; - const provider = new LoopProvider(); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const render = (): string => provider.render(templates.EventTpl, vars.payload); + const render = (): string => templates.EventTpl(vars.payload); expect(render).not.toThrowError(); - const message = await render(); + const message = render(); expect(message).toBeDefined(); }); @@ -144,18 +140,14 @@ describe('LoopProvider', () => { /** * Check that rendering of a several events message works without errors */ - it('should successfully render a several-events template', async () => { + it('should successfully render a several-events template', () => { const vars: SeveralEventsNotification = SeveralEventsNotifyMock; - const provider = new LoopProvider(); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const render = (): string => provider.render(templates.SeveralEventsTpl, vars.payload); + const render = (): string => templates.SeveralEventsTpl(vars.payload); expect(render).not.toThrowError(); - const message = await render(); + const message = render(); expect(message).toBeDefined();