Добавлен парсер персональных сообщений по упоминанию в комментариях

This commit is contained in:
Pavel Gnedov 2022-07-23 20:41:00 +07:00
parent 0d7b684244
commit 8c77117703
14 changed files with 172 additions and 65 deletions

View file

@ -1,8 +1,6 @@
import { DynamicModule, Logger, Module, OnModuleInit } from '@nestjs/common'; import { DynamicModule, Logger, Module, OnModuleInit } from '@nestjs/common';
import { EventEmitterService } from './event-emitter.service'; import { EventEmitterService } from './event-emitter.service';
import { RedmineEventsGateway } from './events/redmine-events.gateway'; import { RedmineEventsGateway } from './events/redmine-events.gateway';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import MainConfig from './configs/main-config'; import MainConfig from './configs/main-config';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { RedmineDataLoader } from './redmine-data-loader/redmine-data-loader'; import { RedmineDataLoader } from './redmine-data-loader/redmine-data-loader';
@ -41,10 +39,6 @@ export class EventEmitterModule implements OnModuleInit {
UsersService, UsersService,
IssuesService, IssuesService,
TimestampEnhancer, TimestampEnhancer,
{
provide: 'ENHANCERS',
useValue: params?.enhancers || null,
},
EnhancerService, EnhancerService,
], ],
exports: [ exports: [
@ -75,23 +69,25 @@ export class EventEmitterModule implements OnModuleInit {
onModuleInit() { onModuleInit() {
const queue = this.redmineEventsGateway.getIssuesChangesQueue(); const queue = this.redmineEventsGateway.getIssuesChangesQueue();
const subj = queue.queue; const subj = queue.queue;
subj.subscribe(async (issues: any) => { subj.subscribe(async (issues: RedmineTypes.Issue[]) => {
this.logger.debug(`Changed issues = ${JSON.stringify(issues)}`); this.logger.debug(
`Changed issues - ` +
issues.map((i) => `#${i.id} (${i.subject})`).join(', '),
);
for (let i = 0; i < issues.length; i++) { for (let i = 0; i < issues.length; i++) {
const issue: RedmineTypes.Issue = issues[i]; const issue: RedmineTypes.Issue = issues[i];
try { try {
this.logger.debug( this.logger.debug(`Save issue #${issue.id} (${issue.subject})`);
`Save issue #${issue.id} - ${JSON.stringify(issue)}`,
);
const response = await this.redmineIssuesCacheWriterService.saveIssue( const response = await this.redmineIssuesCacheWriterService.saveIssue(
issue, issue,
); );
this.logger.debug( this.logger.debug(
`Save issue #${issue.id} response = ${JSON.stringify(response)}`, // eslint-disable-next-line prettier/prettier
`Save ${(response.prev) ? 'exists' : 'new'} issue #${issue.id} - count of changes = ${response.journalsDiff.length}`,
); );
} catch (ex) { } catch (ex) {
this.logger.error(`Saving issue error - ${ex}`, null, { this.logger.error(`Saving issue error - ${ex}`, null, {

View file

@ -18,6 +18,7 @@ import { WebhookConfigItemModel } from '../models/webhook-config-item-model';
import { RssListener } from '../rsslistener/rsslistener'; import { RssListener } from '../rsslistener/rsslistener';
import { EventsListener } from './events-listener'; import { EventsListener } from './events-listener';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { RedmineTypes } from '../models/redmine-types';
type IssuesChangesQueueParams = { type IssuesChangesQueueParams = {
updateInterval: number; updateInterval: number;
@ -48,14 +49,14 @@ export class RedmineEventsGateway {
issuesChanges( issuesChanges(
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@MessageBody() data: any, @MessageBody() data: any,
): Observable<WsResponse<RedmineIssueData[]>> { ): Observable<WsResponse<RedmineTypes.Issue[]>> {
return this.issuesChangesObservable; return this.issuesChangesObservable;
} }
private issuesChangesQueue: Queue<number, RedmineIssueData>; private issuesChangesQueue: Queue<number, RedmineTypes.Issue>;
getIssuesChangesQueue(): Queue<number, RedmineIssueData> { getIssuesChangesQueue(): Queue<number, RedmineTypes.Issue> {
if (!this.issuesChangesQueue) { if (!this.issuesChangesQueue) {
this.issuesChangesQueue = new Queue<number, RedmineIssueData>( this.issuesChangesQueue = new Queue<number, RedmineTypes.Issue>(
this.issuesChangesQueueParams.updateInterval, this.issuesChangesQueueParams.updateInterval,
this.issuesChangesQueueParams.itemsLimit, this.issuesChangesQueueParams.itemsLimit,
async (issueNumbers) => { async (issueNumbers) => {

View file

@ -17,13 +17,11 @@ export class RedmineIssuesCacheWriterService {
async saveIssue(issue: RedmineTypes.Issue): Promise<SaveResponse> { async saveIssue(issue: RedmineTypes.Issue): Promise<SaveResponse> {
this.logger.debug( this.logger.debug(
`Saving issue ${issue?.id || '-'} - ${ `Saving issue ${issue?.id || '-'} (${issue?.subject || '-'})`,
issue?.subject || '-'
}, issue data = ${JSON.stringify(issue)}`,
); );
const id = Number(issue['id']); const id = Number(issue['id']);
let prevIssue: (nano.DocumentGetResponse & RedmineTypes.Issue) | null; let prevIssue: (nano.DocumentGetResponse & RedmineTypes.Issue) | null;
const issueDb = await Issues.getDatasource(); const issueDb = await this.issues.getDatasource();
if (!issueDb) { if (!issueDb) {
throw `CouchDb datasource must defined`; throw `CouchDb datasource must defined`;
} }
@ -46,7 +44,7 @@ export class RedmineIssuesCacheWriterService {
journalsDiff: this.getJournalsDiff(prevIssue, newIssue), journalsDiff: this.getJournalsDiff(prevIssue, newIssue),
}; };
this.logger.debug( this.logger.debug(
`Saving issue success ${issue?.id || '-'} - ${issue?.subject || '-'}`, `Saving issue success #${issue?.id || '-'} - ${issue?.subject || '-'}`,
); );
this.subject.next(res); this.subject.next(res);
return res; return res;

View file

@ -1,31 +1,19 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { RedmineTypes } from '../models/redmine-types'; import { RedmineTypes } from '../models/redmine-types';
import { IssueEnhancerInterface } from './issue-enhancer-interface'; import { IssueEnhancerInterface } from './issue-enhancer-interface';
@Injectable() @Injectable()
export class EnhancerService { export class EnhancerService {
private logger = new Logger(EnhancerService.name); private logger = new Logger(EnhancerService.name);
private initilialized = false; private enhancers: IssueEnhancerInterface[] = [];
constructor( addEnhancer(enh: IssueEnhancerInterface[]): void {
private moduleRef: ModuleRef, this.enhancers.push(...enh);
@Inject('ENHANCERS')
private enhancers: IssueEnhancerInterface[],
) {}
private init(): void {
if (!this.initilialized) {
this.logger.log(`Initialize EnhancerService start`);
this.enhancers.forEach((e) => e.init(this.moduleRef));
this.logger.log(`Initialize EnhancerService finished`);
}
} }
async enhanceIssue( async enhanceIssue(
issue: RedmineTypes.Issue & Record<string, any>, issue: RedmineTypes.Issue & Record<string, any>,
): Promise<RedmineTypes.Issue & Record<string, any>> { ): Promise<RedmineTypes.Issue & Record<string, any>> {
this.init();
for (let i = 0; i < this.enhancers.length; i++) { for (let i = 0; i < this.enhancers.length; i++) {
const enhancer = this.enhancers[i]; const enhancer = this.enhancers[i];
// eslint-disable-next-line prettier/prettier // eslint-disable-next-line prettier/prettier

View file

@ -1,9 +1,7 @@
import { ModuleRef } from '@nestjs/core';
import { RedmineTypes } from '../models/redmine-types'; import { RedmineTypes } from '../models/redmine-types';
export interface IssueEnhancerInterface { export interface IssueEnhancerInterface {
name: string; name: string;
init(moduleRef: ModuleRef);
enhance( enhance(
issue: RedmineTypes.Issue, issue: RedmineTypes.Issue,
): Promise<RedmineTypes.Issue & Record<string, any>>; ): Promise<RedmineTypes.Issue & Record<string, any>>;

View file

@ -6,10 +6,6 @@ import { IssueEnhancerInterface } from './issue-enhancer-interface';
export class TimestampEnhancer implements IssueEnhancerInterface { export class TimestampEnhancer implements IssueEnhancerInterface {
name = 'timestamp'; name = 'timestamp';
init() {
return;
}
async enhance( async enhance(
issue: RedmineTypes.Issue, issue: RedmineTypes.Issue,
): Promise<RedmineTypes.Issue & Record<string, any>> { ): Promise<RedmineTypes.Issue & Record<string, any>> {

View file

@ -1,7 +1,5 @@
import { IssueEnhancerInterface } from '../issue-enhancers/issue-enhancer-interface';
import { MainConfigModel } from './main-config-model'; import { MainConfigModel } from './main-config-model';
export type ModuleParams = { export type ModuleParams = {
config?: MainConfigModel; config?: MainConfigModel;
enhancers?: IssueEnhancerInterface[];
}; };

View file

@ -46,6 +46,32 @@ export class UsersService {
); );
} }
async findUserByName(
firstname: string,
lastname: string,
): Promise<RedmineTypes.User | null> {
const userFromMemoryCache = this.memoryCache.find((item) => {
return item.firstname === firstname && item.lastname === lastname;
});
if (userFromMemoryCache) {
return RedmineTypes.CreateUser(userFromMemoryCache);
}
const usersDb = await this.users.getDatasource();
const res = await usersDb.find({
selector: {
firstname: firstname,
lastname: lastname,
},
limit: 1,
});
if (!res && !res.docs && !res.docs[0]) {
return null;
}
const userFromDb = res.docs[0];
this.memoryCache.set(userFromDb.id, userFromDb);
return RedmineTypes.CreateUser(userFromDb);
}
private async getUserFromRedmine( private async getUserFromRedmine(
userId: number, userId: number,
): Promise<RedmineTypes.User | null> { ): Promise<RedmineTypes.User | null> {

View file

@ -39,6 +39,22 @@ export class MemoryCache<K, T> {
} }
} }
find(fn: (item: T, key: K) => boolean): (T & Timestamped) | null {
for (const key in this.memoryCache) {
if (Object.prototype.hasOwnProperty.call(this.memoryCache, key)) {
const item = this.memoryCache[key];
if (TimestampIsTimeouted(item, this.timeout)) {
delete this.memoryCache[key];
continue;
}
if (fn(item, key as any)) {
return item;
}
}
}
return null;
}
private startAutoclean() { private startAutoclean() {
setTimeout(() => { setTimeout(() => {
this.cleanTimeouted(); this.cleanTimeouted();

View file

@ -1,38 +1,58 @@
import { EventEmitterModule } from '@app/event-emitter'; import { EventEmitterModule } from '@app/event-emitter';
import { RedmineIssuesCacheWriterService } from '@app/event-emitter/issue-cache-writer/redmine-issues-cache-writer.service';
import { EnhancerService } from '@app/event-emitter/issue-enhancers/enhancer.service';
import { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps-enhancer'; import { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps-enhancer';
import { MainController } from '@app/event-emitter/main/main.controller'; import { MainController } from '@app/event-emitter/main/main.controller';
import { UsersService } from '@app/event-emitter/users/users.service'; import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import configuration from './configs/app'; import configuration from './configs/app';
import { CurrentUserEnhancer } from './issue-enhancers/current-user-enhancer'; import { CurrentUserEnhancer } from './issue-enhancers/current-user-enhancer';
import { CustomFieldsEnhancer } from './issue-enhancers/custom-fields-enhancer'; import { CustomFieldsEnhancer } from './issue-enhancers/custom-fields-enhancer';
import { PersonalNotificationsService } from './notifications/personal-notifications.service';
@Module({ @Module({
imports: [ imports: [
EventEmitterModule.register({ EventEmitterModule.register({
config: configuration().redmineIssueEventEmitterConfig, config: configuration().redmineIssueEventEmitterConfig,
enhancers: [
new TimestampEnhancer(),
new CustomFieldsEnhancer(),
new CurrentUserEnhancer(),
],
}), }),
ConfigModule.forRoot({ load: [configuration] }), ConfigModule.forRoot({ load: [configuration] }),
], ],
controllers: [AppController, MainController], controllers: [AppController, MainController],
providers: [ providers: [
AppService, AppService,
UsersService,
CustomFieldsEnhancer, CustomFieldsEnhancer,
CurrentUserEnhancer, CurrentUserEnhancer,
PersonalNotificationsService,
], ],
}) })
export class AppModule { export class AppModule implements OnModuleInit {
private logger = new Logger(AppModule.name);
constructor( constructor(
private personalNotificationsService: PersonalNotificationsService,
private redmineIssuesCacheWriterService: RedmineIssuesCacheWriterService,
private enhancerService: EnhancerService,
private timestampEnhancer: TimestampEnhancer, private timestampEnhancer: TimestampEnhancer,
private customFieldsEnhancer: CustomFieldsEnhancer, private customFieldsEnhancer: CustomFieldsEnhancer,
private currentUserEnhancer: CurrentUserEnhancer,
) {} ) {}
onModuleInit() {
this.enhancerService.addEnhancer([
this.timestampEnhancer,
this.customFieldsEnhancer,
this.currentUserEnhancer,
]);
this.personalNotificationsService.$messages.subscribe((message) => {
// eslint-disable-next-line prettier/prettier
this.logger.log(`Get personal message ${JSON.stringify(message.message)} for recipients ${JSON.stringify(message.recipients)}`);
});
this.redmineIssuesCacheWriterService.subject.subscribe(
async (saveResult) => {
await this.personalNotificationsService.analize(saveResult);
},
);
}
} }

View file

@ -20,10 +20,6 @@ export class CurrentUserEnhancer implements IssueEnhancerInterface {
Rejected: 'dev', Rejected: 'dev',
}; };
init() {
return;
}
async enhance( async enhance(
issue: RedmineTypes.Issue, issue: RedmineTypes.Issue,
): Promise<RedmineTypes.Issue & Record<string, any>> { ): Promise<RedmineTypes.Issue & Record<string, any>> {

View file

@ -2,17 +2,11 @@ import { IssueEnhancerInterface } from '@app/event-emitter/issue-enhancers/issue
import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import { UsersService } from '@app/event-emitter/users/users.service'; import { UsersService } from '@app/event-emitter/users/users.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
@Injectable() @Injectable()
export class CustomFieldsEnhancer implements IssueEnhancerInterface { export class CustomFieldsEnhancer implements IssueEnhancerInterface {
name = 'custom-fields'; name = 'custom-fields';
private usersService: UsersService; constructor(private usersService: UsersService) {}
init(moduleRef: ModuleRef): void {
this.usersService = moduleRef.get(UsersService);
}
async enhance( async enhance(
issue: RedmineTypes.Issue, issue: RedmineTypes.Issue,

View file

@ -0,0 +1,5 @@
export class PersonalParsedMessage {
sender: number;
message: string;
recipients: number[];
}

View file

@ -0,0 +1,75 @@
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import { SaveResponse } from '@app/event-emitter/models/save-response';
import { UsersService } from '@app/event-emitter/users/users.service';
import { Injectable, Logger } from '@nestjs/common';
import { Subject } from 'rxjs';
import { PersonalParsedMessage } from 'src/models/personal-parsed-message.model';
@Injectable()
export class PersonalNotificationsService {
private userNameRe = /@([\wА-Яа-яЁё]+) ([\wА-Яа-яЁё]+)@/g;
private logger = new Logger(PersonalNotificationsService.name);
$messages = new Subject<PersonalParsedMessage>();
constructor(private usersService: UsersService) {}
async analize(data: SaveResponse): Promise<PersonalParsedMessage[]> {
this.logger.debug(
`Analize personal messages for issue ` +
`#${data.current.id} (${data.current.subject}) start`,
);
const pMessages = data.journalsDiff.map(async (journal) => {
const message = await this.getMessage(journal);
if (message) {
this.logger.log(
`Found personal message in issue #${data.current.id} ` +
`(${data.current.subject}) - ` +
`message = ${JSON.stringify(message.message)} ` +
`from sender ${message.sender} ` +
`for recipients ${JSON.stringify(message.recipients)}`,
);
this.$messages.next(message);
}
return message;
});
const res = (await Promise.all(pMessages)).filter((m) => Boolean(m));
this.logger.debug(
`Analize personal messages for issue ` +
`#${data.current.id} (${data.current.subject}) finished`,
);
return res;
}
private async getMessage(
journal: RedmineTypes.Journal,
): Promise<PersonalParsedMessage | null> {
if (!journal.notes) {
return null;
}
const notes = journal.notes;
const results = notes.matchAll(this.userNameRe);
const recipients: number[] = [];
let result = results.next();
while (!result.done) {
if (result.value && result.value[1] && result.value[2]) {
const firstname = result.value[1];
const lastname = result.value[2];
const user = await this.usersService.findUserByName(
firstname,
lastname,
);
if (user) recipients.push(user.id);
}
result = results.next();
}
if (recipients.length > 0) {
return {
message: notes,
sender: journal.user.id,
recipients: recipients,
};
}
return null;
}
}