Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/github-actions-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
- name: Setup nodejs
uses: actions/setup-node@v2
with:
node-version: '12'
node-version: '16'
cache: 'yarn'
- name: Install dependencies
run: yarn install
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:12-alpine
FROM node:16-alpine

RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/helpers/create-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const createTestAppContext = ({
telegramApiRoot,
telegramPollingInterval: 1,
mongoUri: 'mongonotused',
database: 'memory',
withPromo: false,
logLevel: LogLevel.WARNING,
l10nFilesPath: '',
Expand Down
20 changes: 20 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export function getConfig(): Config {
telegramAdminNickName: process.env.ADMIN_NICK,
telegramPollingInterval: 30,
mongoUri: ensureEnv('MONGO'),
database: getDatabaseType(),
memoryDatabaseDumpPath: process.env.MEMORY_DB_DUMP_PATH,
withPromo: process.env.PROMO_DISABLED !== '1',
l10nFilesPath: process.env.L10N_PATH || `${__dirname}/../l10n`,
logLevel: logLevelNameToLevel(process.env.LOG_LEVEL),
Expand All @@ -34,6 +36,10 @@ export function getConfig(): Config {
throw firstError;
}

if (config.workersCount !== 1 && config.database === 'memory') {
throw new Error('in-memory database supported only for single worker');
}

return config;

function ensureEnv(name: string): string {
Expand All @@ -51,3 +57,17 @@ export function getConfig(): Config {
return value;
}
}

const getDatabaseType = (): Config['database'] => {
const type = process.env.DATABASE;

switch (type) {
case 'mongo':
case 'memory':
return type;
case undefined:
return 'mongo';
default:
throw new Error(`unknown db type: ${type}, supported: mongo, memory`);
}
};
32 changes: 31 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {createFsPoTranslationsLoader} from './i18n/translations-loader-fs-po';
import {getCommands} from './commands/all-commands';
import {ExplicitPartial} from './types/utility';
import {toDoValidateResponse} from './types/hacks/to-do-validate';
import {MemoryDatabase} from './database/memory/database';

export type ContextOptions = Partial<{
isWorker: boolean;
Expand All @@ -36,11 +37,16 @@ const defaultCreateTranslations = ({appContext}: {appContext: AppContext}) =>
logger: appContext.logger.fork('l10n'),
});

const defaultCreateDatabase = ({appContext}: {appContext: AppContext}) =>
new MongoDatabase({appContext});
const createMemoryDatabase = ({appContext}: {appContext: AppContext}) =>
new MemoryDatabase({appContext});

export function createContext({
isWorker = true,
instanceId,
config: customConfig,
createDatabase = ({appContext}) => new MongoDatabase({appContext}),
createDatabase: createDatabaseFn = defaultCreateDatabase,
createTranslations = defaultCreateTranslations,
getCurrentDate = () => new Date(),
getLogger = ({name, config}) => new Logger(name, {logLevel: config.logLevel}),
Expand Down Expand Up @@ -90,6 +96,24 @@ export function createContext({
// and undefined fields will be not used on initialization
appContext = initialAppContext as AppContext;

const createDatabase = (() => {
if (createDatabaseFn !== defaultCreateDatabase) {
return createDatabaseFn;
}

switch (config.database) {
case 'mongo':
return defaultCreateDatabase;
case 'memory':
return createMemoryDatabase;
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const shouldBeNever: never = config.database;
throw new Error('impossible');
}
}
})();

appContext.database = createDatabase({appContext});
appContext.translations = createTranslations({appContext});

Expand All @@ -106,6 +130,8 @@ export function createContext({
};

appContext.stop = async () => {
logger.info('stop', {state: 'start'});

await shutdownHandlers.reduceRight((acc, handler) => {
return acc.finally(() =>
handler().catch((error) => {
Expand All @@ -115,8 +141,12 @@ export function createContext({
);
}, Promise.resolve());

await appContext.database.stop?.();

await bot.stop();
await reportedShutdown();

logger.info('stop', {state: 'finish'});
};

appContext.run = (fun) => {
Expand Down
73 changes: 73 additions & 0 deletions src/database/memory/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {sleep} from '@root/util/async/sleep';
import {assertNonNullish} from '@root/util/assert/assert-non-nullish';
import {getOrCreateMap, getOrCreateSet} from '@root/util/map/get-or-create';
import {MessageId} from '@root/models/message';
import {readFile, writeFile} from 'fs/promises';

/**
* In-memory mock of MongoDatabase, used in tests.
Expand All @@ -39,8 +40,80 @@ export class MemoryDatabase implements Database {
// behavior like real db connector
// If MemoryDatabase will be used for real, move those sleeps to a flag
await sleep(1);

await this.restoreDump();
};

async stop() {
await this.makeDump();
}

private async restoreDump() {
const {memoryDatabaseDumpPath} = this.appContext.config;

if (!memoryDatabaseDumpPath) {
return;
}

const content = await (async () => {
try {
return await readFile(memoryDatabaseDumpPath, 'utf-8');
} catch (error) {
if (error.code === 'ENOENT') {
return null;
}

throw error;
}
})();

if (!content) {
return;
}

// WARN: there should be some validation, describe data in `zod`, please
const dump = JSON.parse(content);

[
[this.chats, dump.chats],
[this.cappedKickedUsers, dump.cappedKickedUsers],
[this.entryMessages, dump.entryMessages],
[this.messagesToDelete, dump.messagesToDelete],
[this.cappedMessages, dump.cappedMessages],
].forEach(([map, dump]) => {
dump.forEach(([key, value]) => {
map.set(key, value);
});
});

dump.verifiedUsers.forEach((id) => {
this.verifiedUsers.add(id);
});
}

private async makeDump() {
const {
isWorker,
config: {memoryDatabaseDumpPath},
} = this.appContext;

// Only workers have some state here
if (!isWorker || !memoryDatabaseDumpPath) {
return;
}

const dump = {
chats: Array.from(this.chats.entries()),
cappedKickedUsers: Array.from(this.cappedKickedUsers.entries()),
entryMessages: Array.from(this.entryMessages.entries()),
verifiedUsers: Array.from(this.verifiedUsers),
messagesToDelete: Array.from(this.messagesToDelete.entries()),
cappedMessages: Array.from(this.cappedMessages.entries()),
};

await writeFile(memoryDatabaseDumpPath, JSON.stringify(dump), 'utf-8');
}

getChatById = async (chatId: ChatId): Promise<Chat | null> => {
await sleep(1);

Expand Down
18 changes: 18 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ import {createContext} from './context';

const appContext = createContext();

// in-memory database can work only with single worker and intended for dev-purposes only,
// and also SIGINT handling works bad under `concurrently`, looks like we should resend
// this signal from master to workers and vise-versa (or maybe investigate
// `concurrently` options to send SIGNINT for all child processes
if (appContext.config.database === 'memory') {
process.on('SIGINT', () => {
appContext.stop().then(
() => {
process.exit(0);
},
(error) => {
appContext.logger.error('sigint', undefined, {error});
process.exit(1);
},
);
});
}

appContext.run(() => {
if (isMaster) {
appContext.isWorker = false;
Expand Down
9 changes: 9 additions & 0 deletions src/middlewares/checkIfFromReplier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ export const checkIfFromReplierMiddleware: BotMiddlewareFn = async (
ctx.callbackQuery.message.reply_to_message
) {
const message = ctx.callbackQuery.message;
// Anonymous admin
if (
message.reply_to_message &&
message.reply_to_message.from &&
message.reply_to_message.from.username &&
message.reply_to_message.from.username === 'GroupAnonymousBot'
) {
return BotMiddlewareNextStrategy.next;
}

assertNonNullish(message.reply_to_message?.from);

Expand Down
4 changes: 4 additions & 0 deletions src/middlewares/checkLock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export const checkLockMiddleware: BotMiddlewareFn = async (ctx) => {
if (ctx.from.id === ctx.appContext.config.telegramAdminId) {
return BotMiddlewareNextStrategy.next;
}
// If from the group anonymous bot, then continue
if (ctx.from?.username === 'GroupAnonymousBot') {
return BotMiddlewareNextStrategy.next;
}
// If from admin, then continue
if (ctx.isAdministrator) {
return BotMiddlewareNextStrategy.next;
Expand Down
2 changes: 2 additions & 0 deletions src/types/app-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type Config = {
telegramApiRoot: string;
telegramPollingInterval: number;
mongoUri: string;
database: 'mongo' | 'memory';
memoryDatabaseDumpPath?: string;
isNeedUpdateAutocomplete: boolean;
/** @deprecated */
withPromo: boolean;
Expand Down
5 changes: 3 additions & 2 deletions src/types/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ export type SetChatPropertyOptions = Values<ChatProperties>;
type FilteredKeys<T, U> = {[P in keyof T]: T[P] extends U ? P : never}[keyof T];
export type BooleanChatPropertyKey = NonNullable<FilteredKeys<Chat, boolean>>;

export interface Database {
export type Database = {
init: () => Promise<void>;
stop?: () => Promise<void>;

// TODO: change to `Chat | undefined`
getChatById: (chatId: ChatId) => Promise<Chat | null>;
Expand Down Expand Up @@ -64,4 +65,4 @@ export interface Database {
findCappedMessages: (
query: Partial<CappedMessage>,
) => Promise<CappedMessage[]>;
}
};