mirror of
https://github.com/muerwre/vk-tg-bot.git
synced 2025-04-24 14:36:41 +07:00
added calendar service
This commit is contained in:
parent
0b663fb96f
commit
6e34090f8f
31 changed files with 1359 additions and 200 deletions
1
.env
1
.env
|
@ -1 +0,0 @@
|
|||
EXPOSE=7003
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,3 +2,4 @@ config.yml
|
|||
node_modules
|
||||
dist
|
||||
.idea
|
||||
/key.local.json
|
|
@ -22,6 +22,9 @@ templates:
|
|||
wall_post_new: templates/post_new.md
|
||||
group_join: templates/group_join.md
|
||||
group_leave: templates/group_leave.md
|
||||
calendar:
|
||||
keyFile: # key.json
|
||||
timezone:
|
||||
# groups:
|
||||
# - id: 0
|
||||
# name: 'Group name'
|
||||
|
@ -29,6 +32,9 @@ templates:
|
|||
# secretKey: 'groupSecretKey'
|
||||
# apiKey: 'callbackApiKey'
|
||||
# post_types: ['post','copy','reply','postpone','suggest']
|
||||
# calendar:
|
||||
# id: abcd@group.calendar.google.com
|
||||
# enabled: true
|
||||
# templates: # group's custom templates (optional)
|
||||
# message_new: templates/custom/message_new.md
|
||||
# channels:
|
||||
|
|
13
key.json
Normal file
13
key.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"type": "service_account",
|
||||
"project_id": "",
|
||||
"private_key_id": "",
|
||||
"private_key": "",
|
||||
"client_email": "",
|
||||
"client_id": "",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
|
@ -12,7 +12,10 @@
|
|||
"dependencies": {
|
||||
"axios": "^0.21.1",
|
||||
"cross-fetch": "^3.1.4",
|
||||
"date-fns": "^3.2.0",
|
||||
"express": "^4.17.1",
|
||||
"google-auth-library": "^9.4.1",
|
||||
"googleapis": "^130.0.0",
|
||||
"handlebars": "^4.7.7",
|
||||
"http": "^0.0.1-security",
|
||||
"js-yaml": "^4.0.0",
|
||||
|
@ -33,8 +36,8 @@
|
|||
"telegraf": "^4.3.0",
|
||||
"to-vfile": "^6.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typeorm": "^0.2.32",
|
||||
"typescript": "^4.2.3",
|
||||
"typeorm": "^0.3.19",
|
||||
"typescript": "^4.8.2",
|
||||
"unist-util-filter": "^3.0.0",
|
||||
"url": "^0.11.0",
|
||||
"vk-io": "^4.8.3",
|
||||
|
@ -46,6 +49,7 @@
|
|||
"@types/axios": "^0.14.0",
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/handlebars": "^4.1.0",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^14.14.37",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@types/yargs": "^16.0.1",
|
||||
|
|
|
@ -23,4 +23,7 @@ export const defaultConfig: Config = {
|
|||
group_join: "templates/group_join.md",
|
||||
group_leave: "templates/group_leave.md",
|
||||
},
|
||||
calendar: {
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -6,6 +6,10 @@ import { validateConfig } from "./validate";
|
|||
import { getCmdArg } from "../utils/args";
|
||||
import { defaultConfig } from "./default";
|
||||
import { merge } from "lodash";
|
||||
import {
|
||||
CalendarKeyFile,
|
||||
calendarKeyValidator,
|
||||
} from "../service/calendar/config";
|
||||
|
||||
const configPath = getCmdArg("config");
|
||||
const data = fs.readFileSync(
|
||||
|
@ -21,6 +25,18 @@ const config =
|
|||
export default function prepareConfig() {
|
||||
validateConfig(config);
|
||||
|
||||
if (config.calendar?.keyFile) {
|
||||
try {
|
||||
const key = JSON.parse(
|
||||
fs.readFileSync(config.calendar?.keyFile).toString()
|
||||
) as CalendarKeyFile;
|
||||
calendarKeyValidator.validateSync(key);
|
||||
config.calendarKey = key;
|
||||
} catch (error) {
|
||||
console.warn("tried to parse calendar key, got error", error);
|
||||
}
|
||||
}
|
||||
|
||||
config.telegram.templates = {
|
||||
help: config.templates.help,
|
||||
help_admin: config.templates.help_admin,
|
||||
|
|
|
@ -3,6 +3,7 @@ import { VkConfig, VkEvent } from "../service/vk/types";
|
|||
import { HttpConfig } from "../api/http/types";
|
||||
import { LoggerConfig } from "../service/logger/types";
|
||||
import { PostgresConfig } from "../service/db/postgres/types";
|
||||
import { CalendarConfig, CalendarKeyFile } from "src/service/calendar/config";
|
||||
|
||||
export type TemplateConfig = Record<VkEvent, string> &
|
||||
Partial<Record<"help" | "help_admin", string>>;
|
||||
|
@ -14,4 +15,6 @@ export interface Config extends Record<string, any> {
|
|||
logger: LoggerConfig;
|
||||
templates: TemplateConfig;
|
||||
postgres: PostgresConfig;
|
||||
calendar?: Partial<CalendarConfig>;
|
||||
calendarKey?: CalendarKeyFile;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { vkConfigSchema } from "../service/vk/validation";
|
|||
import { telegramConfigSchema } from "../service/telegram/validation";
|
||||
import { loggerConfigSchema } from "../service/logger/config";
|
||||
import { dbConfigValidatior } from "../service/db/postgres/validation";
|
||||
import { calendarConfigValidator } from "../service/calendar/config";
|
||||
|
||||
export const templateConfigSchema = object().required().shape({
|
||||
message_new: string().required(),
|
||||
|
@ -29,6 +30,7 @@ const configSchema = object<Config>().required().shape({
|
|||
logger: loggerConfigSchema,
|
||||
templates: templateConfigSchema,
|
||||
postgres: dbConfigValidatior,
|
||||
calendar: calendarConfigValidator.optional(),
|
||||
});
|
||||
|
||||
export const validateConfig = (config: Config) =>
|
||||
|
|
23
src/index.ts
23
src/index.ts
|
@ -7,6 +7,7 @@ import { HttpApi } from "./api/http";
|
|||
import { PostgresDB } from "./service/db/postgres";
|
||||
import { PgTransport } from "./service/db/postgres/loggerTransport";
|
||||
import { roll } from "./commands/roll";
|
||||
import { setupCalendar } from "./service/calendar/setup";
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
|
@ -18,7 +19,27 @@ async function main() {
|
|||
logger.add(new PgTransport(db, { level: "warn" }));
|
||||
|
||||
const telegram = new TelegramService(config.telegram);
|
||||
const vkService = new VkService(config.vk, telegram, config.templates, db);
|
||||
const calendar = await setupCalendar(
|
||||
logger.warn,
|
||||
config.calendar,
|
||||
config.calendarKey
|
||||
);
|
||||
|
||||
if (calendar) {
|
||||
logger.info(
|
||||
`calendar service started for ${config.calendarKey?.project_id}`
|
||||
);
|
||||
} else {
|
||||
logger.warn("calendar service not started");
|
||||
}
|
||||
|
||||
const vkService = new VkService(
|
||||
config.vk,
|
||||
telegram,
|
||||
config.templates,
|
||||
db,
|
||||
calendar
|
||||
);
|
||||
|
||||
const telegramApi = new TelegramApi(telegram, db, config);
|
||||
telegramApi.listen();
|
||||
|
|
29
src/service/calendar/config.ts
Normal file
29
src/service/calendar/config.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { Asserts, boolean, object, string } from "yup";
|
||||
|
||||
export const calendarGroupConfigValidator = object({
|
||||
id: string().optional().email(),
|
||||
enabled: boolean().default(false),
|
||||
});
|
||||
|
||||
export const calendarConfigValidator = object({
|
||||
keyFile: string().optional(),
|
||||
timezone: string().required().default(""),
|
||||
});
|
||||
|
||||
export const calendarKeyValidator = object({
|
||||
type: string().required(),
|
||||
project_id: string().required(),
|
||||
private_key_id: string().required(),
|
||||
private_key: string().required(),
|
||||
client_email: string().required(),
|
||||
client_id: string().required(),
|
||||
auth_uri: string().required(),
|
||||
token_uri: string().required(),
|
||||
auth_provider_x509_cert_url: string().required(),
|
||||
client_x509_cert_url: string().required(),
|
||||
universe_domain: string().required(),
|
||||
});
|
||||
|
||||
export type CalendarConfig = Asserts<typeof calendarConfigValidator>;
|
||||
export type CalendarGroupConfig = Asserts<typeof calendarGroupConfigValidator>;
|
||||
export type CalendarKeyFile = Asserts<typeof calendarKeyValidator>;
|
74
src/service/calendar/index.ts
Normal file
74
src/service/calendar/index.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { calendar_v3, google } from "googleapis";
|
||||
import { KeyFile } from "./types";
|
||||
import { JWT } from "google-auth-library";
|
||||
|
||||
export class CalendarService {
|
||||
private auth!: JWT;
|
||||
private calendar!: calendar_v3.Calendar;
|
||||
|
||||
constructor(
|
||||
key: KeyFile,
|
||||
private timeZone: string,
|
||||
private log: (...vals: any) => void
|
||||
) {
|
||||
this.auth = new google.auth.JWT(
|
||||
key.client_email,
|
||||
undefined,
|
||||
key.private_key,
|
||||
[
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
]
|
||||
);
|
||||
|
||||
this.calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: this.auth,
|
||||
});
|
||||
}
|
||||
|
||||
public async authenticate() {
|
||||
await this.auth.authorize();
|
||||
}
|
||||
|
||||
protected async isEventExist(calendarId: string, eventID: string) {
|
||||
const event = await this.calendar.events.list({
|
||||
calendarId: calendarId,
|
||||
iCalUID: eventID,
|
||||
showDeleted: true,
|
||||
});
|
||||
|
||||
return Boolean(event.data.items?.length);
|
||||
}
|
||||
|
||||
public async createEvent(
|
||||
calendarId: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
summary: string,
|
||||
description: string,
|
||||
eventId: string
|
||||
) {
|
||||
if (await this.isEventExist(calendarId, eventId)) {
|
||||
this.log("event already exist, quitting");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.calendar.events.insert({
|
||||
calendarId,
|
||||
requestBody: {
|
||||
summary,
|
||||
description,
|
||||
start: {
|
||||
dateTime: start.toISOString(),
|
||||
timeZone: this.timeZone,
|
||||
},
|
||||
end: {
|
||||
dateTime: end.toISOString(),
|
||||
timeZone: this.timeZone,
|
||||
},
|
||||
iCalUID: eventId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
27
src/service/calendar/setup.ts
Normal file
27
src/service/calendar/setup.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { CalendarConfig, CalendarKeyFile } from "./config";
|
||||
import { CalendarService } from "../calendar";
|
||||
|
||||
export const setupCalendar = async (
|
||||
logger: (...vals: any) => void,
|
||||
config?: Partial<CalendarConfig>,
|
||||
keyConfig?: CalendarKeyFile
|
||||
) => {
|
||||
if (!keyConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const service = new CalendarService(
|
||||
keyConfig,
|
||||
config?.timezone ?? "",
|
||||
logger
|
||||
);
|
||||
|
||||
await service.authenticate();
|
||||
|
||||
return service;
|
||||
} catch (error) {
|
||||
logger("can't init calendar", error);
|
||||
return null;
|
||||
}
|
||||
};
|
13
src/service/calendar/types.ts
Normal file
13
src/service/calendar/types.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
export interface KeyFile {
|
||||
type: string;
|
||||
project_id: string;
|
||||
private_key_id: string;
|
||||
private_key: string;
|
||||
client_email: string;
|
||||
client_id: string;
|
||||
auth_uri: string;
|
||||
token_uri: string;
|
||||
auth_provider_x509_cert_url: string;
|
||||
client_x509_cert_url: string;
|
||||
universe_domain: string;
|
||||
}
|
|
@ -9,14 +9,14 @@ export interface Storage {
|
|||
tgMessageId: number,
|
||||
groupId: number,
|
||||
channel: string
|
||||
): Promise<Event | undefined>;
|
||||
getEventById(eventId: number): Promise<Event | undefined>;
|
||||
): Promise<Event | null>;
|
||||
getEventById(eventId: number): Promise<Event | null>;
|
||||
getEventByVKEventId(
|
||||
type: VkEvent,
|
||||
eventId: number,
|
||||
groupId: number,
|
||||
channel: string
|
||||
): Promise<Event | undefined>;
|
||||
): Promise<Event | null>;
|
||||
createEvent(
|
||||
type: VkEvent,
|
||||
eventId: number,
|
||||
|
@ -24,7 +24,7 @@ export interface Storage {
|
|||
channel: string,
|
||||
tgMessageId: number,
|
||||
text: Record<any, any>
|
||||
): Promise<Event | undefined>;
|
||||
): Promise<Event | null>;
|
||||
createOrUpdateLike(
|
||||
messageId: number,
|
||||
channel: string,
|
||||
|
@ -36,12 +36,12 @@ export interface Storage {
|
|||
channel: string,
|
||||
messageId: number,
|
||||
author: number
|
||||
): Promise<Like | undefined>;
|
||||
): Promise<Like | null>;
|
||||
createPost(
|
||||
eventId: number,
|
||||
text: string,
|
||||
vkPostId: number
|
||||
): Promise<Post | undefined>;
|
||||
findPostByEvent(eventId: number): Promise<Post | undefined>;
|
||||
): Promise<Post | null>;
|
||||
findPostByEvent(eventId: number): Promise<Post | null>;
|
||||
healthcheck(): Promise<void>;
|
||||
}
|
||||
|
|
|
@ -55,10 +55,12 @@ export class PostgresDB implements Storage {
|
|||
channel: string
|
||||
) => {
|
||||
return await this.events.findOne({
|
||||
type,
|
||||
tgMessageId,
|
||||
vkGroupId,
|
||||
channel,
|
||||
where: {
|
||||
type,
|
||||
tgMessageId,
|
||||
vkGroupId,
|
||||
channel,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -69,15 +71,19 @@ export class PostgresDB implements Storage {
|
|||
channel: string
|
||||
) => {
|
||||
return await this.events.findOne({
|
||||
type,
|
||||
vkEventId,
|
||||
vkGroupId,
|
||||
channel,
|
||||
where: {
|
||||
type,
|
||||
vkEventId,
|
||||
vkGroupId,
|
||||
channel,
|
||||
},
|
||||
});
|
||||
};
|
||||
getEventById = async (id: number) => {
|
||||
return await this.events.findOne({
|
||||
id,
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -109,14 +115,18 @@ export class PostgresDB implements Storage {
|
|||
|
||||
getLikeBy = async (channel, messageId, author) => {
|
||||
return this.likes.findOne({
|
||||
channel,
|
||||
messageId,
|
||||
author,
|
||||
where: {
|
||||
channel,
|
||||
messageId,
|
||||
author,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
createOrUpdateLike = async (messageId, channel, author, text) => {
|
||||
const like = await this.likes.findOne({ channel, author, messageId });
|
||||
const like = await this.likes.findOne({
|
||||
where: { channel, author, messageId },
|
||||
});
|
||||
|
||||
if (like) {
|
||||
return await this.likes.save({ ...like, text });
|
||||
|
@ -131,7 +141,7 @@ export class PostgresDB implements Storage {
|
|||
};
|
||||
|
||||
findPostByEvent = async (eventId: number) => {
|
||||
return this.posts.findOne({ eventId });
|
||||
return this.posts.findOne({ where: { eventId } });
|
||||
};
|
||||
|
||||
createPost = async (eventId: number, text: string, vkPostId: number) => {
|
||||
|
|
76
src/service/vk/handlers/PostNewCalendarHandler.ts
Normal file
76
src/service/vk/handlers/PostNewCalendarHandler.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { addMinutes, isBefore, secondsToMilliseconds } from "date-fns";
|
||||
import { NextMiddleware } from "middleware-io";
|
||||
import { WallPostContext } from "vk-io";
|
||||
import { getDateFromText } from "../../../utils/date/getDateFromText";
|
||||
import logger from "../../logger";
|
||||
import { VkEventHandler } from "./VkEventHandler";
|
||||
import { getSummaryFromText } from "../../../utils/text/getSummaryFromText";
|
||||
import { maybeTrim } from "../../../utils/text/maybeTrim";
|
||||
|
||||
|
||||
export class PostNewCalendarHandler extends VkEventHandler {
|
||||
constructor(...props: ConstructorParameters<typeof VkEventHandler>) {
|
||||
super(...props);
|
||||
}
|
||||
|
||||
public execute = async (context: WallPostContext, next: NextMiddleware) => {
|
||||
try {
|
||||
const id = context?.wall?.id;
|
||||
const postType = context?.wall?.postType;
|
||||
const text = context.wall.text;
|
||||
const createdAt = context.wall.createdAt && new Date(secondsToMilliseconds(context.wall.createdAt));
|
||||
const eventId = context.wall.id.toString();
|
||||
|
||||
if (context.isRepost || !id || !this.group.calendar?.id || !this.group.calendar.enabled || !this.calendar || !text || !createdAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isValidPostType(postType)) {
|
||||
logger.info(
|
||||
`skipping wall_post_new for ${this.group.name
|
||||
}#${id} since it have type '${postType}', which is not in [${this.channel.post_types.join(
|
||||
","
|
||||
)}]`
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = getSummaryFromText(text);
|
||||
|
||||
const start = getDateFromText(text, createdAt);
|
||||
if (!start) {
|
||||
logger.warn(`can't extract date from summary: ${summary}`)
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBefore(start, createdAt)) {
|
||||
logger.warn(`extracted date was from the past: ${start?.toISOString()}, summary was: ${summary}`)
|
||||
return;
|
||||
}
|
||||
|
||||
const end = addMinutes(start, 15);
|
||||
const description = [this.generateVkPostUrl(context.wall.id), maybeTrim(text, 512)].join('\n\n');
|
||||
|
||||
this.calendar?.createEvent(this.group.calendar?.id, start, end, summary, description, eventId)
|
||||
} catch (error) {
|
||||
logger.warn('error occurred in calendar handler', error);
|
||||
} finally {
|
||||
await next();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if event of type we can handle
|
||||
*/
|
||||
private isValidPostType(type?: string) {
|
||||
return !!type && type === "post";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates urls for postId
|
||||
*/
|
||||
generateVkPostUrl = (postId?: number) =>
|
||||
`https://vk.com/wall-${this.group.id}_${postId}`;
|
||||
|
||||
}
|
|
@ -49,8 +49,7 @@ const PHOTO_CAPTION_LIMIT = 1000;
|
|||
const POST_TEXT_LIMIT = 4096;
|
||||
|
||||
export class PostNewHandler extends VkEventHandler<Fields, Values> {
|
||||
constructor(...props: any) {
|
||||
// @ts-ignore
|
||||
constructor(...props: ConstructorParameters<typeof VkEventHandler<Fields, Values>>) {
|
||||
super(...props);
|
||||
this.onInit();
|
||||
}
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
import { NextMiddleware } from "middleware-io";
|
||||
import { ConfigGroup, GroupChannel, GroupInstance, VkEvent } from "../types";
|
||||
import {
|
||||
Calendar,
|
||||
ConfigGroup,
|
||||
GroupChannel,
|
||||
GroupInstance,
|
||||
VkEvent,
|
||||
} from "../types";
|
||||
import { VkService } from "../index";
|
||||
import { TelegramService } from "../../telegram";
|
||||
import { Template } from "../../template";
|
||||
import { Storage } from "../../db";
|
||||
import { Event } from "../../db/postgres/entities/Event";
|
||||
import logger from "../../logger";
|
||||
import safeJson from "safe-json-stringify";
|
||||
|
||||
|
@ -20,7 +25,8 @@ export class VkEventHandler<
|
|||
protected vk: VkService,
|
||||
protected telegram: TelegramService,
|
||||
protected template: Template<F, V>,
|
||||
protected db: Storage
|
||||
protected db: Storage,
|
||||
protected calendar: Calendar | null
|
||||
) {}
|
||||
|
||||
public execute: (
|
||||
|
@ -57,7 +63,7 @@ export class VkEventHandler<
|
|||
/**
|
||||
* Checks for duplicates
|
||||
*/
|
||||
getEventById = async (id?: number): Promise<Event | undefined> => {
|
||||
getEventById = async (id?: number) => {
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -68,7 +74,7 @@ export class VkEventHandler<
|
|||
/**
|
||||
* Checks for duplicates
|
||||
*/
|
||||
getEventByVkEventId = async (id?: number): Promise<Event | undefined> => {
|
||||
getEventByVkEventId = async (id?: number) => {
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -84,9 +90,7 @@ export class VkEventHandler<
|
|||
/**
|
||||
* Checks for duplicates
|
||||
*/
|
||||
getEventByTgMessageId = async (
|
||||
tgMessageId?: number
|
||||
): Promise<Event | undefined> => {
|
||||
getEventByTgMessageId = async (tgMessageId?: number) => {
|
||||
if (!tgMessageId) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
@ -1,13 +1,19 @@
|
|||
import { ConfigGroup, GroupChannel, GroupInstance, VkEvent } from "../types";
|
||||
import {
|
||||
Calendar,
|
||||
ConfigGroup,
|
||||
GroupChannel,
|
||||
GroupInstance,
|
||||
VkEvent,
|
||||
} from "../types";
|
||||
import { VkEventHandler } from "./VkEventHandler";
|
||||
import { MessageNewHandler } from "./MessageNewHandler";
|
||||
import { StubHandler } from "./StubHandler";
|
||||
import { VkService } from "../index";
|
||||
import { TelegramService } from "../../telegram";
|
||||
import { Template } from "../../template";
|
||||
import { PostNewHandler } from "./PostNewHandler";
|
||||
import { Storage } from "../../db";
|
||||
import { JoinLeaveHandler } from "./JoinLeaveHandler";
|
||||
import { PostNewCalendarHandler } from "./PostNewCalendarHandler";
|
||||
|
||||
interface Handler {
|
||||
new (
|
||||
|
@ -18,13 +24,14 @@ interface Handler {
|
|||
vk: VkService,
|
||||
telegram: TelegramService,
|
||||
template: Template<any, any>,
|
||||
db: Storage
|
||||
db: Storage,
|
||||
calendar: Calendar | null
|
||||
): VkEventHandler;
|
||||
}
|
||||
|
||||
export const vkEventToHandler: Record<VkEvent, Handler> = {
|
||||
[VkEvent.GroupJoin]: JoinLeaveHandler,
|
||||
[VkEvent.GroupLeave]: JoinLeaveHandler,
|
||||
[VkEvent.MessageNew]: MessageNewHandler,
|
||||
[VkEvent.WallPostNew]: PostNewHandler,
|
||||
export const vkEventToHandler: Record<VkEvent, Handler[]> = {
|
||||
[VkEvent.GroupJoin]: [JoinLeaveHandler],
|
||||
[VkEvent.GroupLeave]: [JoinLeaveHandler],
|
||||
[VkEvent.MessageNew]: [MessageNewHandler],
|
||||
[VkEvent.WallPostNew]: [PostNewHandler, PostNewCalendarHandler],
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@ import { ConfigGroup, GroupInstance, VkConfig, VkEvent } from "./types";
|
|||
import { API, Updates, Upload } from "vk-io";
|
||||
import logger from "../logger";
|
||||
import { Request, Response } from "express";
|
||||
import { flatten, has, keys, prop } from "ramda";
|
||||
import { flatten, has, keys } from "ramda";
|
||||
import { NextFunction } from "connect";
|
||||
import { VkEventHandler } from "./handlers/VkEventHandler";
|
||||
import { vkEventToHandler } from "./handlers";
|
||||
|
@ -10,6 +10,7 @@ import { TelegramService } from "../telegram";
|
|||
import { Template } from "../template";
|
||||
import { TemplateConfig } from "../../config/types";
|
||||
import { PostgresDB } from "../db/postgres";
|
||||
import { CalendarService } from "../calendar";
|
||||
|
||||
/**
|
||||
* Service to handle VK to Telegram interactions
|
||||
|
@ -26,7 +27,8 @@ export class VkService {
|
|||
private config: VkConfig,
|
||||
private telegram: TelegramService,
|
||||
private templates: TemplateConfig,
|
||||
private db: PostgresDB
|
||||
private db: PostgresDB,
|
||||
private calendar: CalendarService | null
|
||||
) {
|
||||
if (!config.groups.length) {
|
||||
throw new Error("No vk groups to handle. Specify them in config");
|
||||
|
@ -120,9 +122,11 @@ export class VkService {
|
|||
const handlers = this.setupHandlers(group, instance);
|
||||
|
||||
handlers.forEach((channel) => {
|
||||
keys(channel).forEach((event) => {
|
||||
keys(channel).forEach((event: VkEvent) => {
|
||||
logger.info(` - ${group.name} listens for ${String(event)}`);
|
||||
updates.on(event as any, channel[event].execute);
|
||||
channel[event].forEach((handler) => {
|
||||
updates.on(event as any, handler.execute);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -135,27 +139,31 @@ export class VkService {
|
|||
private setupHandlers(
|
||||
group: ConfigGroup,
|
||||
instance: GroupInstance
|
||||
): Record<VkEvent, VkEventHandler>[] {
|
||||
): Record<VkEvent, VkEventHandler[]>[] {
|
||||
return flatten(
|
||||
group.channels.map((chan) =>
|
||||
chan.events.reduce((acc, event) => {
|
||||
const template = new Template(
|
||||
prop(event, chan?.templates) ||
|
||||
prop(event, group?.templates) ||
|
||||
prop(event, this.templates)
|
||||
chan?.templates?.[event] ??
|
||||
group?.templates?.[event] ??
|
||||
this.templates?.[event]
|
||||
);
|
||||
|
||||
const handler = new vkEventToHandler[event](
|
||||
event,
|
||||
group,
|
||||
chan,
|
||||
instance,
|
||||
this,
|
||||
this.telegram,
|
||||
template,
|
||||
this.db
|
||||
const handlers = vkEventToHandler[event]?.map(
|
||||
(handler) =>
|
||||
new handler(
|
||||
event,
|
||||
group,
|
||||
chan,
|
||||
instance,
|
||||
this,
|
||||
this.telegram,
|
||||
template,
|
||||
this.db,
|
||||
this.calendar
|
||||
)
|
||||
);
|
||||
return { ...acc, [event]: handler };
|
||||
return { ...acc, [event]: handlers };
|
||||
}, {} as Record<VkEvent, VkEventHandler>[])
|
||||
)
|
||||
);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { API, Upload, Updates } from "vk-io";
|
||||
import { WallPostType } from "vk-io/lib/api/schemas/objects";
|
||||
import { TemplateConfig } from "../../config/types";
|
||||
import { CalendarGroupConfig } from "../calendar/config";
|
||||
|
||||
export interface VkConfig extends Record<string, any> {
|
||||
groups: ConfigGroup[];
|
||||
|
@ -15,6 +16,7 @@ export interface ConfigGroup {
|
|||
apiKey: string;
|
||||
channels: GroupChannel[];
|
||||
templates: Partial<TemplateConfig>;
|
||||
calendar?: Partial<CalendarGroupConfig>;
|
||||
}
|
||||
|
||||
export interface GroupChannel {
|
||||
|
@ -37,3 +39,14 @@ export interface GroupInstance {
|
|||
upload: Upload;
|
||||
updates: Updates;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
createEvent: (
|
||||
calendarId: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
summary: string,
|
||||
description: string,
|
||||
eventId: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import * as yup from "yup";
|
||||
import { VkConfig, VkEvent } from "./types";
|
||||
import { templateOptionalSchema } from "../../config/validate";
|
||||
import { calendarGroupConfigValidator } from "../calendar/config";
|
||||
|
||||
const vkChannelEventSchema = yup.string().oneOf(Object.values(VkEvent));
|
||||
|
||||
|
@ -34,6 +35,7 @@ export const vkConfigSchema = yup
|
|||
apiKey: yup.string().required(),
|
||||
channels: yup.array().of(vkChannelSchema),
|
||||
templates: templateOptionalSchema,
|
||||
calendar: calendarGroupConfigValidator.optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
|
16
src/utils/date/__tests__/getDayMonthFromText.test.ts
Normal file
16
src/utils/date/__tests__/getDayMonthFromText.test.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { getDayMonthFromText } from "../getDayMonthFromText";
|
||||
|
||||
describe("getDayMonthFromText", () => {
|
||||
const cases = [
|
||||
{
|
||||
text:
|
||||
"Попытка номер два.\n\nСегодня. Старт в 19:32 от ГПНТБ. 80км. Асфальт. Темп бодрый. Две остановки на поесть и передохнуть. Маршрут тот же. Автоматическая отмена поката при граде размером более 0.5см.\n\nhttps://map.vault48.org/04092023_razmyatsya_po_asfaltu",
|
||||
created: new Date("2023-09-06T04:24:53.000Z"),
|
||||
expected: [6, 8],
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)("$text", ({ text, created, expected }) => {
|
||||
expect(getDayMonthFromText(text, created)).toEqual(expected);
|
||||
});
|
||||
});
|
42
src/utils/date/getDateFromText.ts
Normal file
42
src/utils/date/getDateFromText.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { getDayMonthFromText } from "./getDayMonthFromText";
|
||||
import { getTimeFromString } from "./getTimeFromString";
|
||||
|
||||
export const getDateFromText = (
|
||||
val: string,
|
||||
createdAt: Date
|
||||
): Date | undefined => {
|
||||
const text = val.toLowerCase();
|
||||
|
||||
const time = getTimeFromString(text);
|
||||
if (!time) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dayMonth = getDayMonthFromText(text, createdAt);
|
||||
if (!dayMonth) {
|
||||
return;
|
||||
}
|
||||
|
||||
const date = new Date(
|
||||
createdAt.getFullYear(),
|
||||
dayMonth[1],
|
||||
dayMonth[0],
|
||||
time[0],
|
||||
time[1]
|
||||
);
|
||||
|
||||
// TODO: handle jan and feb posts from december or november
|
||||
// if createdAt is december and date is in January or February
|
||||
// like this:
|
||||
// if (isBefore(date, createdAt) && differenceInMonths(addYears(date, 1), createdAt) < 3) {
|
||||
// return addYears(date, 1)
|
||||
// }
|
||||
// if (isBefore(date, createdAt)) {
|
||||
// const diff = differenceInMonths(date, createdAt);
|
||||
// return diff > 9 && dayMonth[1] >= 10
|
||||
// ? addYears(date, 1)
|
||||
// : undefined;
|
||||
// }
|
||||
|
||||
return date;
|
||||
};
|
85
src/utils/date/getDayMonthFromText.ts
Normal file
85
src/utils/date/getDayMonthFromText.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { addDays } from "date-fns";
|
||||
|
||||
const months = [
|
||||
"янв",
|
||||
"фев",
|
||||
"мар",
|
||||
"апр",
|
||||
"мая",
|
||||
"июн",
|
||||
"июл",
|
||||
"авг",
|
||||
"сент",
|
||||
"октя",
|
||||
"нояб",
|
||||
];
|
||||
|
||||
type DayMonth = [number, number];
|
||||
|
||||
/** Searches for strings like 1 января */
|
||||
const byText = (val: string): DayMonth | undefined => {
|
||||
const text = val.toLowerCase();
|
||||
const match = text.match(
|
||||
/(\d{1,2})\s?(янв|фев|мар|апр|мая|июн|июл|авг|сент|октя|нояб).*/
|
||||
);
|
||||
|
||||
if (!match?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const day = parseInt(match[1]);
|
||||
const month = months.indexOf(match[2]);
|
||||
|
||||
if (
|
||||
!Number.isFinite(day) ||
|
||||
!Number.isFinite(month) ||
|
||||
day < 0 ||
|
||||
day > 31 ||
|
||||
month < 0 ||
|
||||
month >= months.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return [day, month];
|
||||
};
|
||||
|
||||
const byNumber = (val: string): DayMonth | undefined => {
|
||||
const text = val.toLowerCase();
|
||||
|
||||
const match = text.match(/\b([0-2]?[0-9]|31)\.([1]?[0-2]|0?[0-9])\b/);
|
||||
if (!match?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const day = parseInt(match[1]);
|
||||
const month = parseInt(match[2]);
|
||||
if (
|
||||
!Number.isFinite(day) ||
|
||||
!Number.isFinite(month) ||
|
||||
day < 1 ||
|
||||
day > 31 ||
|
||||
month < 1 ||
|
||||
month > 12
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return [day, month];
|
||||
};
|
||||
|
||||
const byToday = (val: string, refDate: Date): DayMonth | undefined => {
|
||||
const text = val.toLowerCase();
|
||||
if (text.match(/сегодня/)) {
|
||||
return [refDate.getDate(), refDate.getMonth()];
|
||||
}
|
||||
|
||||
if (text.match(/завтра/)) {
|
||||
const tomorrow = addDays(refDate, 1);
|
||||
return [tomorrow.getDate(), tomorrow.getMonth()];
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
export const getDayMonthFromText = (val: string, refDate: Date) =>
|
||||
byText(val) || byNumber(val) || byToday(val, refDate);
|
18
src/utils/date/getTimeFromString.ts
Normal file
18
src/utils/date/getTimeFromString.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
export const getTimeFromString = (
|
||||
val: string
|
||||
): [number, number] | undefined => {
|
||||
const matches = val.match(/\b([01]?[0-9]|2[0-3])[:\-]([0-5][0-9])\b/);
|
||||
|
||||
if (!matches?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = parseInt(matches[1]);
|
||||
const minutes = parseInt(matches[2]);
|
||||
|
||||
if (hours < 0 || hours > 24 || minutes < 0 || minutes > 60) {
|
||||
return;
|
||||
}
|
||||
|
||||
return [hours, minutes];
|
||||
};
|
8
src/utils/text/getSummaryFromText.ts
Normal file
8
src/utils/text/getSummaryFromText.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { maybeTrim } from './maybeTrim';
|
||||
|
||||
/** Makes summary from first 3 strings of text */
|
||||
export const getSummaryFromText = (text: string) => {
|
||||
const match = text.match(/(.*\n?){0,3}/)
|
||||
|
||||
return match?.[0] ? maybeTrim(match[0].replace(/\n/gm, ''), 96) : '';
|
||||
}
|
1
src/utils/text/maybeTrim.ts
Normal file
1
src/utils/text/maybeTrim.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const maybeTrim = (text: string, maxChars: number) => text.length > maxChars ? `${text.slice(0, maxChars)}...` : text;
|
|
@ -13,10 +13,8 @@
|
|||
"baseUrl": ".",
|
||||
"paths": {},
|
||||
"lib": ["esnext"],
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"./**/*",
|
||||
"./custom.d.ts"
|
||||
]
|
||||
"include": ["./**/*", "./custom.d.ts"]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue