diff --git a/package-lock.json b/package-lock.json index d417cdc..5f9e5a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "debug-level": "^3.1.2", "input": "^1.0.1", "mongodb": "^6.3.0", "mongoose": "^8.1.1", @@ -3046,7 +3047,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -3297,12 +3297,28 @@ "tslib": "^2.3.1" } }, + "node_modules/asyncc": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asyncc/-/asyncc-2.0.6.tgz", + "integrity": "sha512-m3nkCP6CKuLubt2vwqoOio8NmOJPjUL6dcaNNxqc9q4H2Rq9wNs+2UsIzBegiJzUtoyh9X9iBe4GIhqu1uOvqA==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -3860,7 +3876,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4103,7 +4118,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -4114,8 +4128,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/color-support": { "version": "1.1.3", @@ -4491,6 +4504,31 @@ } } }, + "node_modules/debug-level": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/debug-level/-/debug-level-3.1.2.tgz", + "integrity": "sha512-YyD8cnCVnTlN7la4/cKUWJq15clsrff8ZXbj7RND8dd0YAl+/SdBhtfemZUY3oU8oxXPfGirrPfPBPFaulLlGA==", + "dependencies": { + "asyncc": "^2.0.6", + "chalk": "^4.1.2", + "fast-safe-stringify": "^2.1.1", + "flatstr": "^1.0.12", + "map-lru": "^2.0.0", + "ms": "^2.1.3", + "sonic-boom": "^3.2.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "debug": "^4.3.1" + } + }, + "node_modules/debug-level/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -5703,6 +5741,11 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "node_modules/fastq": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.0.tgz", @@ -5885,6 +5928,11 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flatstr": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/flatstr/-/flatstr-1.0.12.tgz", + "integrity": "sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw==" + }, "node_modules/flatted": { "version": "3.2.9", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", @@ -6442,7 +6490,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -8565,6 +8612,14 @@ "node": ">=4" } }, + "node_modules/map-lru": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/map-lru/-/map-lru-2.0.0.tgz", + "integrity": "sha512-a5TlnsxvczXMY7U/U4P0b7GI3KSAonc+u2MQtWQS5L21K9UV4fYQbbgktj3eqP5ch04XtCOcoqR0OlILo906nA==", + "engines": { + "node": ">=12" + } + }, "node_modules/map-obj": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz", @@ -12777,6 +12832,14 @@ "node": ">= 10" } }, + "node_modules/sonic-boom": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.0.tgz", + "integrity": "sha512-ybz6OYOUjoQQCQ/i4LU8kaToD8ACtYP+Cj5qd2AO36bwbdewxWJ3ArmJ2cr6AvxlL2o0PqnCcPGUgkILbfkaCA==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/sort-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-2.0.0.tgz", @@ -13136,7 +13199,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, diff --git a/package.json b/package.json index 191ceb5..a9c10e2 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "typescript": "4.9.5" }, "dependencies": { + "debug-level": "^3.1.2", "input": "^1.0.1", "mongodb": "^6.3.0", "mongoose": "^8.1.1", diff --git a/src/bot.ts b/src/bot.ts index f0cd223..3ed6193 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,6 +1,9 @@ -import { Telegraf } from 'telegraf'; +import { Context, NarrowedContext, Telegraf } from 'telegraf'; import { message } from 'telegraf/filters'; -import { ChatFromGetChat } from 'telegraf/typings/core/types/typegram'; +import { ChatFromGetChat, Update } from 'telegraf/typings/core/types/typegram'; +import { ioc } from './utils/ioc'; +import { AudienceChangeController, audienceChangeSymbol } from './controllers/audienceChange'; +import { log } from './utils/log'; export type ChannelInfo = { chatId: number, @@ -9,7 +12,7 @@ export type ChannelInfo = { export type MessageInfo = { -} +}; export class ChannelBot { private readonly bot: Telegraf; @@ -29,6 +32,8 @@ export class ChannelBot { process.once('SIGINT', () => this.bot.stop('SIGINT')); process.once('SIGTERM', () => this.bot.stop('SIGTERM')); + this.bot.on('chat_member', this.handleChatMemberUpdate.bind(this)); + this.bot.launch( { allowedUpdates: [ @@ -43,6 +48,45 @@ export class ChannelBot { ); } + private async handleChatMemberUpdate(ctx: NarrowedContext, Update.ChatMemberUpdate>): Promise { + try { + log.info('chat member change', JSON.stringify(ctx.chatMember)); + + const change = ctx.chatMember; + const status = change.new_chat_member.status; + const statusBefore = change.old_chat_member.status; + const role = ['restricted', 'left', 'kicked'].includes(status) + ? statusBefore + : status; + + const ctrl = ioc.get(audienceChangeSymbol); + + const chatMemberCount = await this.bot.telegram.getChatMembersCount(change.chat.id); + + ctrl.add({ + channelId: change.chat.id, + user: { + id: change.new_chat_member.user.id, + username: change.new_chat_member.user.username, + firstName: change.new_chat_member.user.first_name, + lastName: change.new_chat_member.user.last_name, + isBot: change.new_chat_member.user.is_bot, + isPremium: change.new_chat_member.user.is_premium ? true : false, + role, + }, + timestamp: new Date(change.date * 1000), + status, + statusBefore, + invitedByLink: change.invite_link?.invite_link ?? undefined, + + memberCountAfter: chatMemberCount, + }); + } + catch (err) { + log.error('chat member change processing error', err); + } + } + public async getChannelInfo(channelId: number): Promise { const memberCount = await this.bot.telegram.getChatMembersCount(channelId); @@ -63,21 +107,3 @@ export class ChannelBot { // await sendChannelStats(ctx.chat.id); // }); -// bot.on('new_chat_members', async (ctx) => { -// console.log('new_chat_members'); -// console.dir(ctx.chatMember); - -// await sendChannelStats(ctx.chat.id); -// }); - -// bot.on('chat_member', async (ctx) => { -// console.log('chat_member'); -// console.dir(ctx.chatMember); - -// await sendChannelStats(ctx.chat.id); -// }); - -// // bot.start((ctx) => ctx.reply('Welcome')); -// // bot.help((ctx) => ctx.reply('Send me a sticker')); -// // bot.on(message('sticker'), (ctx) => ctx.reply('👍')); -// // bot.hears('hi', (ctx) => ctx.reply('Hey there')); diff --git a/src/controllers/audienceChange.ts b/src/controllers/audienceChange.ts new file mode 100644 index 0000000..7c6b22b --- /dev/null +++ b/src/controllers/audienceChange.ts @@ -0,0 +1,15 @@ +import { AudienceChangeModel, IAudienceChange } from '../model/audienceChange'; +import { IUser } from '../model/user'; +import { ioc } from '../utils/ioc'; + +export const audienceChangeSymbol = Symbol('audienceChange'); + +export class AudienceChangeController { + public static register() { + ioc.set(audienceChangeSymbol, new AudienceChangeController()); + } + + public async add(change: IAudienceChange): Promise { + await AudienceChangeModel.create(change); + } +} diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..e34bac7 --- /dev/null +++ b/src/database.ts @@ -0,0 +1,8 @@ +import mongoose from "mongoose"; + +export async function connect() { + const dbUri = process.env.MONGO_URI ?? ''; + + await mongoose.connect(dbUri); + console.log("Connected to db"); +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 0fbff72..841effa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,40 +1,58 @@ import { ChannelClient } from './client'; import { ChannelBot } from './bot'; +import { connect } from './database'; +import { ioc } from './utils/ioc'; +import { AudienceChangeController } from './controllers/audienceChange'; (async () => { - const apiId = Number(process.env.TELEGRAM_API_ID ?? '0'); - const apiHash = process.env.TELEGRAM_API_HASH ?? ''; - const apiSession = process.env.TELEGRAM_API_SESSION ?? ''; + try { + await connect(); - const botToken = process.env.TELEGRAM_BOT_TOKEN ?? ''; + AudienceChangeController.register(); - const channelId = Number(process.env.TELEGRAM_CHANNEL_ID ?? '0'); + const apiId = Number(process.env.TELEGRAM_API_ID ?? '0'); + const apiHash = process.env.TELEGRAM_API_HASH ?? ''; + const apiSession = process.env.TELEGRAM_API_SESSION ?? ''; - const client = new ChannelClient( - apiId, - apiHash, - apiSession, - ); + const botToken = process.env.TELEGRAM_BOT_TOKEN ?? ''; - const bot = new ChannelBot( - botToken, - ); + const channelId = Number(process.env.TELEGRAM_CHANNEL_ID ?? '0'); - await bot.connect(); - await client.connect(); + const client = new ChannelClient( + apiId, + apiHash, + apiSession, + ); - const messages = await client.getChannelMessages(channelId, { statsOnly: true }); - console.dir(messages); + const bot = new ChannelBot( + botToken, + ); - const users = await client.getChannelUsers(channelId); + await bot.connect(); + await client.connect(); - console.dir(users); - console.dir(await bot.getChannelInfo(channelId)); + try { + const messages = await client.getChannelMessages(channelId, { statsOnly: true }); + console.dir(messages); - // for (const message of messages) - // console.dir(await client.getChannelMessage(channelId, message.id)); + const users = await client.getChannelUsers(channelId); + console.dir(users); + } + catch (err) { + console.error(err); + } + + console.dir(await bot.getChannelInfo(channelId)); + + // for (const message of messages) + // console.dir(await client.getChannelMessage(channelId, message.id)); + } + catch (err) { + console.error(err); + throw err; + } })() .catch((err) => console.error); diff --git a/src/model/audienceChange.ts b/src/model/audienceChange.ts new file mode 100644 index 0000000..fb26538 --- /dev/null +++ b/src/model/audienceChange.ts @@ -0,0 +1,40 @@ +import mongoose from "mongoose"; + +import { IUser, UserSchema } from './user'; + +export type IAudienceChange = { + channelId: number, + + user: IUser, + + timestamp: Date, + status: string, + statusBefore: string, + invitedByLink: string | undefined, + + memberCountAfter: number; +}; + +const AudienceChangeSchema = new mongoose.Schema( + { + channelId: { type: Number, required: true }, + + user: UserSchema, + + timestamp: Date, + status: { type: String }, + statusBefore: { type: String }, + invitedByLink: { type: String }, + + memberCountAfter: { type: Number, required: true }, + }, + { + collection: 'audienceChange', + timestamps: true, + } +); + +export const AudienceChangeModel = mongoose.model( + 'audienceChange', + AudienceChangeSchema, +); diff --git a/src/model/user.ts b/src/model/user.ts new file mode 100644 index 0000000..683099c --- /dev/null +++ b/src/model/user.ts @@ -0,0 +1,23 @@ +import mongoose from 'mongoose'; + +export type IUser = { + id: number; + username: string | undefined; + firstName: string; + lastName: string | undefined; + isBot: boolean; + isPremium: boolean; + role: string; // 'member' | 'administrator' | 'creator'; +} + +export const UserSchema = new mongoose.Schema( + { + id: { type: Number, required: true }, + username: { type: String, required: true }, + firstName: { type: String }, + lastName: { type: String }, + isBot: { type: Boolean, required: true }, + isPremium: { type: Boolean, required: true }, + role: { type: String, required: true }, + } +); diff --git a/src/utils/ioc.ts b/src/utils/ioc.ts new file mode 100644 index 0000000..8414349 --- /dev/null +++ b/src/utils/ioc.ts @@ -0,0 +1,21 @@ +class IOC { + private readonly items = new Map(); + + public set(key: Symbol, obj: T): void { + this.items.set(key, obj); + } + + public get(key: Symbol): T { + return this.items.get(key) as T; + } + + public create( + key: Symbol, + ...args: A + ): T { + const cls = this.get(key) as { new(...args: A): T; }; + return new cls(...args); + } +} + +export const ioc = new IOC(); diff --git a/src/utils/log.ts b/src/utils/log.ts new file mode 100644 index 0000000..91497e9 --- /dev/null +++ b/src/utils/log.ts @@ -0,0 +1,3 @@ +import Log from 'debug-level'; + +export const log = new Log('');