Merge branch 'dev'

This commit is contained in:
Pavel Gnedov 2022-10-03 21:35:04 +07:00
commit 93e4ea0778
15 changed files with 7765 additions and 2642 deletions

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"editor.tabSize": 2
}

View file

@ -1,7 +1,10 @@
{ {
"couchDb": { "couchDb": {
"dbs": { "dbs": {
"changes": "" "changes": "",
} "userMetaInfo": "",
} }
},
"telegramBotToken": "",
"personalMessageTemplate": ""
} }

9941
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -29,9 +29,11 @@
"@nestjs/serve-static": "^2.2.2", "@nestjs/serve-static": "^2.2.2",
"@nestjs/websockets": "^8.4.4", "@nestjs/websockets": "^8.4.4",
"axios": "^0.27.2", "axios": "^0.27.2",
"cache-manager": "^4.1.0",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"imap-simple": "^5.1.0", "imap-simple": "^5.1.0",
"nano": "^10.0.0", "nano": "^10.0.0",
"node-telegram-bot-api": "^0.59.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rss-parser": "^3.12.0", "rss-parser": "^3.12.0",
@ -42,9 +44,11 @@
"@nestjs/cli": "^8.0.0", "@nestjs/cli": "^8.0.0",
"@nestjs/schematics": "^8.0.0", "@nestjs/schematics": "^8.0.0",
"@nestjs/testing": "^8.0.0", "@nestjs/testing": "^8.0.0",
"@types/cache-manager": "^4.0.1",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/jest": "27.4.0", "@types/jest": "27.4.0",
"@types/node": "^16.0.0", "@types/node": "^16.0.0",
"@types/node-telegram-bot-api": "^0.57.1",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0", "@typescript-eslint/parser": "^5.0.0",

View file

@ -3,9 +3,9 @@ import { RedmineIssuesCacheWriterService } from '@app/event-emitter/issue-cache-
import { EnhancerService } from '@app/event-emitter/issue-enhancers/enhancer.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 { Logger, Module, OnModuleInit } from '@nestjs/common'; import { CacheModule, Logger, Module, OnModuleInit } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { switchMap } from 'rxjs'; import { switchMap, tap } from 'rxjs';
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';
@ -18,6 +18,12 @@ import { StatusChangeNotificationsService } from './notifications/status-change-
import { ChangesCacheWriterService } from './changes-cache-writer/changes-cache-writer.service'; import { ChangesCacheWriterService } from './changes-cache-writer/changes-cache-writer.service';
import { Issues } from '@app/event-emitter/couchdb-datasources/issues'; import { Issues } from '@app/event-emitter/couchdb-datasources/issues';
import { Users } from '@app/event-emitter/couchdb-datasources/users'; import { Users } from '@app/event-emitter/couchdb-datasources/users';
import { TelegramBotService } from './telegram-bot/telegram-bot.service';
import { UserMetaInfoService } from './user-meta-info/user-meta-info.service';
import { UserMetaInfo } from './couchdb-datasources/user-meta-info';
import { PersonalNotificationAdapterService } from './notifications/adapters/personal-notification.adapter/personal-notification.adapter.service';
import { PublicUrlAdapterService } from './notifications/adapters/public-url.adapter.service';
import { StatusChangeAdapterService } from './notifications/adapters/status-change.adapter.service';
@Module({ @Module({
imports: [ imports: [
@ -25,6 +31,9 @@ import { Users } from '@app/event-emitter/couchdb-datasources/users';
config: configuration().redmineIssueEventEmitterConfig, config: configuration().redmineIssueEventEmitterConfig,
}), }),
ConfigModule.forRoot({ load: [configuration] }), ConfigModule.forRoot({ load: [configuration] }),
CacheModule.register({
isGlobal: true,
}),
], ],
controllers: [AppController, MainController], controllers: [AppController, MainController],
providers: [ providers: [
@ -36,6 +45,12 @@ import { Users } from '@app/event-emitter/couchdb-datasources/users';
Changes, Changes,
RedminePublicUrlConverter, RedminePublicUrlConverter,
ChangesCacheWriterService, ChangesCacheWriterService,
TelegramBotService,
UserMetaInfoService,
UserMetaInfo,
PersonalNotificationAdapterService,
PublicUrlAdapterService,
StatusChangeAdapterService,
], ],
}) })
export class AppModule implements OnModuleInit { export class AppModule implements OnModuleInit {
@ -50,12 +65,16 @@ export class AppModule implements OnModuleInit {
private currentUserEnhancer: CurrentUserEnhancer, private currentUserEnhancer: CurrentUserEnhancer,
private statusChangeNotificationsService: StatusChangeNotificationsService, private statusChangeNotificationsService: StatusChangeNotificationsService,
private changesCacheWriterService: ChangesCacheWriterService, private changesCacheWriterService: ChangesCacheWriterService,
private telegramBotService: TelegramBotService,
private personalNotificationAdapterService: PersonalNotificationAdapterService,
private statusChangeAdapterService: StatusChangeAdapterService,
) {} ) {}
onModuleInit() { onModuleInit() {
Issues.getDatasource(); Issues.getDatasource();
Users.getDatasource(); Users.getDatasource();
Changes.getDatasource(); Changes.getDatasource();
UserMetaInfo.getDatasource();
this.enhancerService.addEnhancer([ this.enhancerService.addEnhancer([
this.timestampEnhancer, this.timestampEnhancer,
@ -63,33 +82,37 @@ export class AppModule implements OnModuleInit {
this.currentUserEnhancer, this.currentUserEnhancer,
]); ]);
this.personalNotificationsService.$messages.subscribe((message) => { this.personalNotificationsService.$messages.subscribe((resp) => {
// eslint-disable-next-line prettier/prettier this.logger.log(
this.logger.log(`Get personal message ${JSON.stringify(message.message)} for recipients ${JSON.stringify(message.recipients)}`); `Get personal message ` +
JSON.stringify(resp.personalParsedMessage.message) +
` for recipients ` +
JSON.stringify(resp.personalParsedMessage.recipients),
);
this.personalNotificationAdapterService.send(resp);
}); });
this.statusChangeNotificationsService.$changes.subscribe((change) => { this.statusChangeNotificationsService.$changes.subscribe((change) => {
const messages = change.messages
.map((m) => m.change_message)
.filter((m) => !!m);
const notifications = change.messages
.map((m) => m.notification_message)
.filter((m) => !!m);
this.logger.log( this.logger.log(
`Get status changes messages for ` + `Get status changes messages for ` +
`issue_id = ${change.issue_id}, ` + `issue_id = ${change.issue_id}, ` +
`messages = ${JSON.stringify( `messages = ${JSON.stringify(messages)}, ` +
change.messages.map((m) => m.change_message).filter((m) => !!m), `notifications = ${JSON.stringify(notifications)}`,
)}`,
); );
this.statusChangeAdapterService.send(change);
}); });
this.redmineIssuesCacheWriterService.subject.subscribe(
async (saveResult) => {
await this.personalNotificationsService.analize(saveResult);
},
);
this.redmineIssuesCacheWriterService.subject this.redmineIssuesCacheWriterService.subject
.pipe( .pipe(
switchMap(async (saveResult) => { tap((saveResult) => {
this.logger.debug( this.logger.debug(
`Save result process started, issue_id = ${saveResult.current.id}`, `Save result process started, issue_id = ${saveResult.current.id}`,
); );
return saveResult;
}), }),
) )
.pipe( .pipe(
@ -117,6 +140,10 @@ export class AppModule implements OnModuleInit {
this.logger.debug('Save changes in couchdb successed'); this.logger.debug('Save changes in couchdb successed');
return args; return args;
}), }),
switchMap(async (args) => {
this.logger.debug(``);
return args;
}),
) )
.subscribe(async (args) => { .subscribe(async (args) => {
this.logger.debug( this.logger.debug(

View file

@ -0,0 +1,35 @@
import { CouchDb } from '@app/event-emitter/couchdb-datasources/couchdb';
import { Injectable, Logger } from '@nestjs/common';
import nano from 'nano';
import { UserMetaInfoModel } from 'src/models/user-meta-info.model';
import configuration from '../configs/app';
const config = configuration();
@Injectable()
export class UserMetaInfo {
private static logger = new Logger(UserMetaInfo.name);
private static db = null;
static async getDatasource(): Promise<nano.DocumentScope<UserMetaInfoModel>> {
if (UserMetaInfo.db) {
return UserMetaInfo.db;
}
const n = CouchDb.getCouchDb();
const userMetaInfoDbName =
config.couchDb.dbs.userMetaInfo || 'user_meta_info';
const dbs = await n.db.list();
if (!dbs.includes(userMetaInfoDbName)) {
await n.db.create(userMetaInfoDbName);
}
UserMetaInfo.db = await n.db.use(userMetaInfoDbName);
UserMetaInfo.logger.log(
`Connected to user_meta_info db - ${userMetaInfoDbName}`,
);
return UserMetaInfo.db;
}
async getDatasource(): Promise<nano.DocumentScope<UserMetaInfoModel>> {
return await UserMetaInfo.getDatasource();
}
}

View file

@ -9,6 +9,8 @@ export type AppConfig = {
couchDb: { couchDb: {
dbs: { dbs: {
changes: string; changes: string;
userMetaInfo: string;
}; };
}; };
telegramBotToken: string;
}; };

View file

@ -0,0 +1,7 @@
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import { PersonalParsedMessage } from './personal-parsed-message.model';
export type IssueAndPersonalParsedMessageModel = {
issue: RedmineTypes.Issue;
personalParsedMessage: PersonalParsedMessage;
};

View file

@ -0,0 +1,4 @@
export type UserMetaInfoModel = {
user_id: number;
telegram_chat_id: number;
};

View file

@ -0,0 +1,54 @@
import { UsersService } from '@app/event-emitter/users/users.service';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IssueAndPersonalParsedMessageModel } from 'src/models/issue-and-personal-parsed-message.model';
import { TelegramBotService } from 'src/telegram-bot/telegram-bot.service';
import Handlebars from 'handlebars';
import { PublicUrlAdapterService } from '../public-url.adapter.service';
@Injectable()
export class PersonalNotificationAdapterService {
private personalMessageTemplate: HandlebarsTemplateDelegate;
constructor(
private telegramBotService: TelegramBotService,
private configService: ConfigService,
private usersService: UsersService,
private publicUrlAdapterService: PublicUrlAdapterService,
) {
const template = this.configService.get<string>('personalMessageTemplate');
this.personalMessageTemplate = Handlebars.compile(template);
}
async send(
issueAndMessages: IssueAndPersonalParsedMessageModel,
): Promise<void> {
const promises = issueAndMessages.personalParsedMessage.recipients.map(
async (recipient) => {
const redmineId = recipient;
const issueUrlHtml = this.publicUrlAdapterService.getHtmlHref(
issueAndMessages.issue,
);
const sender = await this.usersService.getUser(
issueAndMessages.personalParsedMessage.sender,
);
const data = {
data: {
issue: issueAndMessages.issue,
messages: issueAndMessages.personalParsedMessage,
},
issue_url: issueUrlHtml,
sender_name: sender.name,
message: issueAndMessages.personalParsedMessage.message,
};
const message = this.personalMessageTemplate(data);
return await this.telegramBotService.sendMessageByRedmineId(
redmineId,
message,
{ parse_mode: 'HTML' },
);
},
);
await Promise.all(promises);
}
}

View file

@ -0,0 +1,21 @@
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class PublicUrlAdapterService {
private publicUrl: string;
constructor(private configService: ConfigService) {
this.publicUrl = this.configService.get<string>('redmineUrlPublic');
}
getUrl(issueId: number): string {
return `${this.publicUrl}/issues/${issueId}`;
}
getHtmlHref(issue: RedmineTypes.Issue): string {
const url = this.getUrl(issue.id);
return `<a href="${url}">${issue.tracker.name} #${issue.id}</a>`;
}
}

View file

@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { Change } from 'src/models/change.model';
import { TelegramBotService } from 'src/telegram-bot/telegram-bot.service';
@Injectable()
export class StatusChangeAdapterService {
constructor(private telegramBotService: TelegramBotService) {}
async send(change: Change): Promise<boolean[]> {
const promises = change.messages.map((m) => {
if (!m || !m.recipient || !m.recipient.id || !m.notification_message) {
return true;
}
return this.telegramBotService.sendMessageByRedmineId(
m.recipient.id,
m.notification_message,
{ parse_mode: 'HTML' },
);
});
return await Promise.all(promises);
}
}

View file

@ -3,6 +3,7 @@ import { SaveResponse } from '@app/event-emitter/models/save-response';
import { UsersService } from '@app/event-emitter/users/users.service'; import { UsersService } from '@app/event-emitter/users/users.service';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { IssueAndPersonalParsedMessageModel } from 'src/models/issue-and-personal-parsed-message.model';
import { PersonalParsedMessage } from 'src/models/personal-parsed-message.model'; import { PersonalParsedMessage } from 'src/models/personal-parsed-message.model';
@Injectable() @Injectable()
@ -10,11 +11,13 @@ export class PersonalNotificationsService {
private userNameRe = /@([\wА-Яа-яЁё]+) ([\wА-Яа-яЁё]+)@/g; private userNameRe = /@([\wА-Яа-яЁё]+) ([\wА-Яа-яЁё]+)@/g;
private logger = new Logger(PersonalNotificationsService.name); private logger = new Logger(PersonalNotificationsService.name);
$messages = new Subject<PersonalParsedMessage>(); $messages = new Subject<IssueAndPersonalParsedMessageModel>();
constructor(private usersService: UsersService) {} constructor(private usersService: UsersService) {}
async analize(data: SaveResponse): Promise<PersonalParsedMessage[]> { async analize(
data: SaveResponse,
): Promise<IssueAndPersonalParsedMessageModel[]> {
this.logger.debug( this.logger.debug(
`Analize personal messages for issue ` + `Analize personal messages for issue ` +
`#${data.current.id} (${data.current.subject}) start`, `#${data.current.id} (${data.current.subject}) start`,
@ -29,11 +32,16 @@ export class PersonalNotificationsService {
`from sender ${message.sender} ` + `from sender ${message.sender} ` +
`for recipients ${JSON.stringify(message.recipients)}`, `for recipients ${JSON.stringify(message.recipients)}`,
); );
this.$messages.next(message); this.$messages.next({
issue: data.current,
personalParsedMessage: message,
});
} }
return message; return message;
}); });
const res = (await Promise.all(pMessages)).filter((m) => Boolean(m)); const res = (await Promise.all(pMessages))
.filter((m) => Boolean(m))
.map((m) => ({ issue: data.current, personalParsedMessage: m }));
this.logger.debug( this.logger.debug(
`Analize personal messages for issue ` + `Analize personal messages for issue ` +
`#${data.current.id} (${data.current.subject}) finished`, `#${data.current.id} (${data.current.subject}) finished`,

View file

@ -0,0 +1,165 @@
import { UsersService } from '@app/event-emitter/users/users.service';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import TelegramBot from 'node-telegram-bot-api';
import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service';
import axios from 'axios';
import { UserMetaInfoModel } from 'src/models/user-meta-info.model';
@Injectable()
export class TelegramBotService {
private logger = new Logger(TelegramBotService.name);
private bot: TelegramBot;
private telegramBotToken: string;
private redmineApiUrlPrefix: string;
private redminePublicUrlPrefix: string;
private registerRe = /\/register (\d+) (.+)/;
constructor(
private userMetaInfoService: UserMetaInfoService,
private usersService: UsersService,
private configService: ConfigService,
) {
this.telegramBotToken = this.configService.get<string>('telegramBotToken');
this.redmineApiUrlPrefix =
this.configService.get<string>('redmineUrlPrefix');
this.redminePublicUrlPrefix =
this.configService.get<string>('redmineUrlPublic');
this.initTelegramBot();
}
private async initTelegramBot(): Promise<void> {
const Telegram = await require('node-telegram-bot-api');
this.bot = new Telegram(this.telegramBotToken, { polling: true });
this.bot.onText(/\/start/, async (msg) => {
await this.showHelpMessage(msg);
});
this.bot.onText(/\/help/, async (msg) => {
await this.showHelpMessage(msg);
});
this.bot.onText(this.registerRe, async (msg) => {
await this.register(msg);
});
this.bot.onText(/\/leave/, async (msg) => {
await this.leave(msg);
});
}
private async showHelpMessage(msg: TelegramBot.Message): Promise<void> {
const userMetaInfo = await this.userMetaInfoService.findByTelegramId(
msg.chat.id,
);
let helpMessage: string;
if (userMetaInfo) {
// eslint-disable-next-line prettier/prettier
helpMessage = [
`/current_issues - мои текущие задачи`,
`/help`
].join('\n');
} else {
// eslint-disable-next-line prettier/prettier
helpMessage = [
`/register <redmine_id> <redmine_token>`,
`/help`
].join('\n');
}
this.logger.debug(
`Sent help message for telegramChatId = ${msg.chat.id}, ` +
`message = ${helpMessage}`,
);
this.bot.sendMessage(msg.chat.id, helpMessage);
}
async sendMessageByRedmineId(
redmineId: number,
msg: string,
options?: TelegramBot.SendMessageOptions,
): Promise<boolean> {
const userMetaInfo = await this.userMetaInfoService.findByRedmineId(
redmineId,
);
if (!userMetaInfo) return false;
const chatId = userMetaInfo.telegram_chat_id;
await this.bot.sendMessage(chatId, msg, options);
this.logger.debug(
`Sent message for redmineUserId = ${redmineId}, ` +
`telegramChatId = ${chatId}, ` +
`message = ${msg}, ` +
`options = ${JSON.stringify(options)}`,
);
return true;
}
async sendMessageByName(
firstname: string,
lastname: string,
msg: string,
options?: TelegramBot.SendMessageOptions,
): Promise<boolean> {
const user = await this.usersService.findUserByName(firstname, lastname);
if (!user) return false;
return await this.sendMessageByRedmineId(user.id, msg, options);
}
private async register(
msg: TelegramBot.Message,
): Promise<{ result: boolean; message: string }> {
const items = (msg.text || '').match(this.registerRe);
const result = (result: boolean, message: string, logData?: any) => {
const logMsg =
`Telegram registration ${result ? 'successed' : 'failed'} ` +
`with message ${message}, ` +
`log data = ${JSON.stringify(logData || null)}`;
this.logger.log(logMsg);
this.bot.sendMessage(msg.chat.id, message);
return { result: result, message: message };
};
if (!items || !items[1] || !items[2]) {
return result(false, 'Не указаны необходимые данные');
}
let redmineId: number, redmineToken: string;
try {
redmineId = Number(items[1]);
redmineToken = items[2];
} catch (ex) {
return result(false, 'Введены неверные значения', {});
}
const url = `${this.redminePublicUrlPrefix}/users/current.json`;
const resp = await axios.get(url, {
headers: { 'X-Redmine-API-Key': redmineToken },
});
if (resp && resp.data && resp.statusText == 'OK') {
const data = resp.data;
if (data.user.id == redmineId) {
const userData = await this.usersService.getUser(redmineId);
const userMetaInfo: UserMetaInfoModel = {
telegram_chat_id: msg.chat.id,
user_id: userData.id,
};
await this.userMetaInfoService.save(userMetaInfo);
return result(true, `Данные для ${userData.name} подтверждены`, {
redmineId: redmineId,
});
}
}
return result(false, 'Не удалось проверить подлинность указанных данных');
}
private async leave(msg: TelegramBot.Message): Promise<void> {
const telegramChatId = msg.chat.id;
const userMetaInfo = await this.userMetaInfoService.findByTelegramId(
telegramChatId,
);
if (userMetaInfo) {
await this.userMetaInfoService.delete(userMetaInfo);
}
}
}

View file

@ -0,0 +1,63 @@
import { CacheTTL, Injectable } from '@nestjs/common';
import { UserMetaInfo } from 'src/couchdb-datasources/user-meta-info';
import { UserMetaInfoModel } from 'src/models/user-meta-info.model';
import nano from 'nano';
@Injectable()
export class UserMetaInfoService {
constructor(private userMetaInfo: UserMetaInfo) {}
@CacheTTL(60)
async findByRedmineId(id: number): Promise<UserMetaInfoModel | null> {
const db = await this.userMetaInfo.getDatasource();
try {
return await db.get(String(id));
} catch (ex) {
return null;
}
}
@CacheTTL(60)
async findByTelegramId(id: number): Promise<UserMetaInfoModel | null> {
const db = await this.userMetaInfo.getDatasource();
try {
const resp = await db.find({
selector: { telegram_chat_id: id },
limit: 1,
});
return resp.docs && resp.docs[0] ? resp.docs[0] : null;
} catch (ex) {
return null;
}
}
async save(data: UserMetaInfoModel): Promise<void> {
const id = String(data.user_id);
const db = await this.userMetaInfo.getDatasource();
let item: (nano.MaybeDocument & UserMetaInfoModel) | null = null;
const newItem: nano.MaybeDocument & UserMetaInfoModel = {
_id: id,
...data,
};
try {
item = await db.get(id);
} catch (ex) {}
if (item) {
newItem._id = item._id;
newItem._rev = item._rev;
}
await db.insert(newItem);
}
async delete(data: UserMetaInfoModel): Promise<void> {
const id = String(data.user_id);
const db = await this.userMetaInfo.getDatasource();
let item: (nano.MaybeDocument & UserMetaInfoModel) | null = null;
try {
item = await db.get(id);
} catch (ex) {}
if (item) {
await db.destroy(item._id, item._rev);
}
}
}