@2byte/tgbot-framework 1.0.17 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2byte/tgbot-framework",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "A TypeScript framework for creating Telegram bots with sections-based architecture (Bun optimized)",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/core/App.ts CHANGED
@@ -279,12 +279,7 @@ export class App {
279
279
  ? startPayload.split("key=")[1] || null
280
280
  : null;
281
281
  }
282
- this.debugLog(
283
- "exists storage",
284
- this.config.userStorage.exists(tgUsername),
285
- "remeber:",
286
- this.rememberUser(tgUsername)
287
- );
282
+
288
283
  // Check access by username and register user if not exists
289
284
  if (!this.config.userStorage.exists(tgUsername) && !this.rememberUser(tgUsername)) {
290
285
  const isAuthByUsername = !this.config.accessPublic && !accessKey;
@@ -907,7 +902,7 @@ export class App {
907
902
  }
908
903
  sectionClass = this.sectionClasses.get(sectionId) as typeof Section;
909
904
  }
910
- this.debugLog("Using section class:", sectionClass.constructor.name);
905
+ this.debugLog("Using section class:", sectionClass.name);
911
906
 
912
907
  let sectionInstance: Section | undefined;
913
908
 
@@ -921,6 +916,10 @@ export class App {
921
916
  };
922
917
 
923
918
  const createRunnedSection = (instance: Section, route: RunSectionRoute): RunnedSection => {
919
+ if (route.hasCallbackParams()) {
920
+ instance.setCallbackParams(route.getCallbackParams());
921
+ this.debugLog("Set callback params for section instance:", route.getCallbackParams());
922
+ }
924
923
  return {
925
924
  instance,
926
925
  route,
@@ -38,6 +38,16 @@ export class InlineKeyboard {
38
38
  return this;
39
39
  }
40
40
 
41
+ appendArray(rows: any[][]): InlineKeyboard {
42
+ rows.forEach(row => this.append(row));
43
+ return this;
44
+ }
45
+
46
+ prependArray(rows: any[][]): InlineKeyboard {
47
+ rows.forEach(row => this.prepend(row));
48
+ return this;
49
+ }
50
+
41
51
  prepend(...row: any[]): InlineKeyboard {
42
52
  if (!Array.isArray(row)) {
43
53
  this.keyboard.unshift([row]);
@@ -202,7 +202,7 @@ export default class Message2byte {
202
202
  if (message) {
203
203
  if ("media_group_id" in message || "caption" in message) {
204
204
  const editMessageCaption = this.editMessageCaption(this.messageValue, this.messageExtra);
205
-
205
+
206
206
  if (editMessageCaption && "message_id" in editMessageCaption) {
207
207
  this.messageId = editMessageCaption.message_id as number;
208
208
  }
@@ -74,6 +74,10 @@ export class RunSectionRoute {
74
74
  getTriggers(): RunSectionRouteTrigger[] {
75
75
  return this.runParams.triggers;
76
76
  }
77
+
78
+ hasCallbackParams(): boolean {
79
+ return this.runParams.callbackParams && Array.from(this.runParams.callbackParams).length > 0;
80
+ }
77
81
 
78
82
  getMethod(): string | null {
79
83
  return this.runParams.method;
@@ -3,9 +3,9 @@ import crypto from "crypto";
3
3
  import { UserModel } from "../user/UserModel";
4
4
 
5
5
  export interface AccessKeyData {
6
- id?: number;
6
+ id: number;
7
7
  user_id: number;
8
- key?: string;
8
+ key: string;
9
9
  used?: number;
10
10
  used_user_id?: number | null;
11
11
  created_at?: string;
@@ -41,31 +41,31 @@ export class AccessKey extends Model {
41
41
  return key;
42
42
  }
43
43
 
44
- static async findByKey(key: string): Promise<AccessKeyData | null> {
44
+ static findByKey(key: string): AccessKeyData | null {
45
45
  return this.queryOne(`SELECT * FROM ${this.tableName} WHERE key = ?`, [key]);
46
46
  }
47
47
 
48
- static async findById(id: number): Promise<AccessKeyData | null> {
48
+ static findById(id: number): AccessKeyData | null {
49
49
  return this.queryOne(`SELECT * FROM ${this.tableName} WHERE id = ?`, [id]);
50
50
  }
51
51
 
52
- static markUsed(key: string, usedUserId?: number): Promise<void> {
52
+ static markUsed(key: string, usedUserId?: number): void {
53
53
  this.execute(
54
54
  `UPDATE ${this.tableName} SET used = 1, used_user_id = ?, created_at = created_at WHERE key = ?`,
55
55
  [usedUserId || null, key]
56
56
  );
57
57
  }
58
58
 
59
- static async revoke(key: string): Promise<void> {
60
- await this.execute(`DELETE FROM ${this.tableName} WHERE key = ?`, [key]);
59
+ static revoke(key: string): void {
60
+ this.execute(`DELETE FROM ${this.tableName} WHERE key = ?`, [key]);
61
61
  }
62
62
 
63
- static async getAll(limit = 100): Promise<AccessKeyData[]> {
63
+ static getAll(limit = 100): AccessKeyData[] {
64
64
  return this.query(`SELECT * FROM ${this.tableName} ORDER BY created_at DESC LIMIT ?`, [limit]);
65
65
  }
66
66
 
67
67
  /** Get all access keys for a given user */
68
- static async findByUser(userId: number, limit = 100): Promise<AccessKeyData[]> {
68
+ static findByUser(userId: number, limit = 100): AccessKeyData[] {
69
69
  return this.query(
70
70
  `SELECT * FROM ${this.tableName} WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`,
71
71
  [userId, limit]
@@ -73,8 +73,19 @@ export class AccessKey extends Model {
73
73
  }
74
74
 
75
75
  /** Return the owner `UserModel` for a given access entry or null */
76
- static async getUserByEntry(entry: AccessKeyData): Promise<any | null> {
77
- const userData = await this.queryOne(`SELECT * FROM users WHERE id = ?`, [entry.user_id]);
76
+ static getUserByEntry(entry: AccessKeyData): any | null {
77
+ const userData = this.queryOne(`SELECT * FROM users WHERE id = ?`, [entry.user_id]);
78
+ if (!userData) return null;
79
+ try {
80
+ return UserModel.make(userData);
81
+ } catch (e) {
82
+ return userData;
83
+ }
84
+ }
85
+
86
+ static getUsedUserByEntry(entry: AccessKeyData): any | null {
87
+ if (!entry.used_user_id) return null;
88
+ const userData = this.queryOne(`SELECT * FROM users WHERE id = ?`, [entry.used_user_id]);
78
89
  if (!userData) return null;
79
90
  try {
80
91
  return UserModel.make(userData);
@@ -84,8 +95,8 @@ export class AccessKey extends Model {
84
95
  }
85
96
 
86
97
  /** Find owner by key string */
87
- static async getUserByKey(key: string): Promise<any | null> {
88
- const entry = await this.findByKey(key);
98
+ static getUserByKey(key: string): any | null {
99
+ const entry = this.findByKey(key);
89
100
  if (!entry) return null;
90
101
  return this.getUserByEntry(entry as AccessKeyData);
91
102
  }
@@ -29,13 +29,13 @@ export abstract class Model {
29
29
  return this.db.transaction(callback)();
30
30
  }
31
31
 
32
- public static async paginate(paginateParams: ModelPaginateParams): Promise<PaginateResult> {
32
+ public static paginate(paginateParams: ModelPaginateParams): PaginateResult {
33
33
  const { page, route, routeParams, limit, whereSql, whereParams } = paginateParams;
34
34
  const offset = (page - 1) * limit;
35
35
  const sql = `SELECT * FROM ${this.tableName} ${whereSql} LIMIT ${offset}, ${limit}`;
36
36
 
37
- const result = await this.query(sql, whereParams);
38
- const queryTotal = await this.queryOne(`SELECT COUNT(*) as count FROM ${this.tableName} ${whereSql}`, whereParams);
37
+ const result = this.query(sql, whereParams);
38
+ const queryTotal = this.queryOne(`SELECT COUNT(*) as count FROM ${this.tableName} ${whereSql}`, whereParams);
39
39
  const total = queryTotal ? queryTotal.count : 0;
40
40
  const totalPages = Math.ceil(total / limit);
41
41
  const hasPreviousPage = page > 1;
@@ -232,6 +232,15 @@ export class UserModel extends Model {
232
232
  return undefined;
233
233
  }
234
234
 
235
+ static delete(id: number): boolean {
236
+ if (this.db) {
237
+ const result = this.db.run(`DELETE FROM ${this.tableName} WHERE id = ?`, [id]);
238
+ return result.changes > 0;
239
+ } else {
240
+ throw new Error("Database connection is not set.");
241
+ }
242
+ }
243
+
235
244
  get username(): string {
236
245
  return this.attributes.tg_username;
237
246
  }
@@ -116,4 +116,23 @@ export class UserStore {
116
116
  });
117
117
  }, 60 * 1000);
118
118
  }
119
+
120
+ deleteByUsername(tgUsername: string): boolean {
121
+ const user = this.usersMap.get(tgUsername);
122
+ if (user) {
123
+ this.usersSession.delete(user);
124
+ return this.usersMap.delete(tgUsername);
125
+ }
126
+ return false;
127
+ }
128
+
129
+ deleteById(userId: number): boolean {
130
+ const userEntry = Array.from(this.usersMap).find(([_, user]) => user.id === userId);
131
+ if (userEntry) {
132
+ const [tgUsername, user] = userEntry;
133
+ this.usersSession.delete(user);
134
+ return this.usersMap.delete(tgUsername);
135
+ }
136
+ return false;
137
+ }
119
138
  }
@@ -1,11 +1,87 @@
1
- import { ExtraReplyMessage } from "telegraf/typings/telegram-types";
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { basename } from "path";
2
3
  import { ApiService } from "../../core/ApiService";
3
4
  import { UserModel } from "../../user/UserModel";
4
5
 
5
6
  export type MassSendApiParams = {
6
- port?: number;
7
+ port?: number;
7
8
  };
8
9
 
10
+ /** Supported mass-send message types */
11
+ export type MassSendType = "text" | "photo" | "video" | "document" | "audio" | "animation" | "voice";
12
+
13
+ /** Base fields common to all mass-send requests */
14
+ export interface MassSendBasePayload {
15
+ /** Telegram user IDs to send to. Omit or pass empty array to send to ALL users. */
16
+ userIds?: number[];
17
+ /** Comma-separated list of tg_usernames to send to (resolves alongside userIds). */
18
+ usernames?: string[];
19
+ /** Parse mode for text / caption: "HTML" | "Markdown" | "MarkdownV2" */
20
+ parseMode?: "HTML" | "Markdown" | "MarkdownV2";
21
+ /** Milliseconds delay between each message to avoid flood limits. Default: 50ms */
22
+ delay?: number;
23
+ }
24
+
25
+ export interface MassSendTextPayload extends MassSendBasePayload {
26
+ type: "text";
27
+ message: string;
28
+ }
29
+
30
+ export interface MassSendPhotoPayload extends MassSendBasePayload {
31
+ type: "photo";
32
+ /** file_id, URL or local path accessible from the bot */
33
+ media: string;
34
+ caption?: string;
35
+ }
36
+
37
+ export interface MassSendVideoPayload extends MassSendBasePayload {
38
+ type: "video";
39
+ media: string;
40
+ caption?: string;
41
+ /** Send video without sound (as animation) */
42
+ supportsStreaming?: boolean;
43
+ }
44
+
45
+ export interface MassSendDocumentPayload extends MassSendBasePayload {
46
+ type: "document";
47
+ media: string;
48
+ caption?: string;
49
+ }
50
+
51
+ export interface MassSendAudioPayload extends MassSendBasePayload {
52
+ type: "audio";
53
+ media: string;
54
+ caption?: string;
55
+ /** Duration in seconds */
56
+ duration?: number;
57
+ performer?: string;
58
+ title?: string;
59
+ }
60
+
61
+ export interface MassSendAnimationPayload extends MassSendBasePayload {
62
+ type: "animation";
63
+ media: string;
64
+ caption?: string;
65
+ }
66
+
67
+ export interface MassSendVoicePayload extends MassSendBasePayload {
68
+ type: "voice";
69
+ media: string;
70
+ caption?: string;
71
+ duration?: number;
72
+ }
73
+
74
+ export type MassSendPayload =
75
+ | MassSendTextPayload
76
+ | MassSendPhotoPayload
77
+ | MassSendVideoPayload
78
+ | MassSendDocumentPayload
79
+ | MassSendAudioPayload
80
+ | MassSendAnimationPayload
81
+ | MassSendVoicePayload;
82
+
83
+ // ---------------------------------------------------------------------------
84
+
9
85
  export class MassSendApiService extends ApiService<MassSendApiParams> {
10
86
  public override name = "MassSendApiService";
11
87
  private bunServerInstance: any;
@@ -15,79 +91,164 @@ export class MassSendApiService extends ApiService<MassSendApiParams> {
15
91
  }
16
92
 
17
93
  public async unsetup(): Promise<void> {
94
+ if (this.bunServerInstance) {
95
+ this.bunServerInstance.stop();
96
+ }
18
97
  return Promise.resolve();
19
98
  }
20
99
 
21
100
  public async run(): Promise<void> {
101
+ const port =
102
+ this.params.port || this.app.configApp.envConfig.BOT_APP_API_PORT || 3033;
103
+
22
104
  this.bunServerInstance = Bun.serve({
23
- port: this.params.port || this.app.configApp.envConfig.BOT_APP_API_PORT || 3033,
105
+ port,
24
106
  routes: {
25
- "/": async (req) => {
26
- const receivedData = (await req.json()) as {
27
- userIds?: number[];
28
- message?: string;
29
- extra?: ExtraReplyMessage;
30
- };
31
- this.app.debugLog("Received data for mass message:", receivedData);
32
-
33
- let userIds: number[] = [];
34
-
35
- if (receivedData && typeof receivedData == "object" && receivedData.message) {
36
- userIds = receivedData?.userIds || [];
37
- const message = receivedData?.message;
38
-
39
- this.sendMassMessage(userIds, message, receivedData.extra);
107
+ "/send": async (req) => {
108
+ if (req.method !== "POST") {
109
+ return Response.json({ status: 405, error: "Method Not Allowed" }, { status: 405 });
110
+ }
111
+ const payload = (await req.json()) as MassSendPayload;
112
+ this.app.debugLog("MassSendApiService received payload:", payload);
113
+
114
+ if (!payload || !payload.type) {
115
+ return Response.json(
116
+ { status: 400, error: 'Missing required field "type"' },
117
+ { status: 400 }
118
+ );
40
119
  }
41
120
 
42
- return Response.json({ status: 200, body: "Mass message sending initiated." });
121
+ // fire-and-forget
122
+ this.dispatch(payload);
123
+
124
+ return Response.json({ status: 200, message: "Mass send initiated." });
43
125
  },
126
+
127
+ // health-check
128
+ "/": async () => Response.json({ status: 200, service: "MassSendApiService" }),
44
129
  },
45
130
  });
46
131
 
47
132
  this.app.debugLog(
48
133
  `MassSendApiService Bun server running at http://localhost:${this.bunServerInstance.port}/`
49
134
  );
50
-
51
135
  return Promise.resolve();
52
136
  }
53
137
 
54
- private async sendMassMessage(
55
- userIds: number[] = [],
56
- message: string,
57
- extra?: ExtraReplyMessage
58
- ): Promise<void> {
59
- if (userIds.length === 0) {
60
- if (!db) {
61
- throw new Error("Database connection is not established.");
138
+ // ---------------------------------------------------------------------------
139
+ // Internal dispatcher
140
+ // ---------------------------------------------------------------------------
141
+
142
+ private async dispatch(payload: MassSendPayload): Promise<void> {
143
+ const chatIds = await this.resolveChatIds(payload);
144
+ const delay = payload.delay ?? 50;
145
+
146
+ for (const chatId of chatIds) {
147
+ try {
148
+ await this.sendOne(chatId, payload);
149
+ } catch (error) {
150
+ this.app.debugLog(`Failed to send to chatId ${chatId}:`, error);
62
151
  }
152
+ if (delay > 0) await this.sleep(delay);
153
+ }
154
+ }
63
155
 
64
- UserModel.setDatabase(db);
156
+ /**
157
+ * If `media` is a local file path that exists on disk, returns
158
+ * `{ source: Buffer, filename }` so Telegraf uploads it.
159
+ * Otherwise returns the string as-is (file_id or HTTPS URL).
160
+ */
161
+ private resolveMedia(media: string): string | { source: Buffer; filename: string } {
162
+ const isPath = media.startsWith("/") || media.startsWith("./") || media.startsWith("../");
163
+ if (isPath && existsSync(media)) {
164
+ return { source: readFileSync(media), filename: basename(media) };
165
+ }
166
+ return media;
167
+ }
65
168
 
66
- this.app.debugLog("Fetching all users for mass message...");
67
- const users = UserModel.getAll();
169
+ private async sendOne(chatId: number, payload: MassSendPayload): Promise<void> {
170
+ const tg = this.app.bot.telegram;
171
+ const parseMode = payload.parseMode;
172
+
173
+ switch (payload.type) {
174
+ case "text":
175
+ await tg.sendMessage(chatId, payload.message, { parse_mode: parseMode });
176
+ break;
177
+
178
+ case "photo":
179
+ await tg.sendPhoto(chatId, this.resolveMedia(payload.media) as any, {
180
+ caption: payload.caption,
181
+ parse_mode: parseMode,
182
+ });
183
+ break;
184
+
185
+ case "video":
186
+ await tg.sendVideo(chatId, this.resolveMedia(payload.media) as any, {
187
+ caption: payload.caption,
188
+ parse_mode: parseMode,
189
+ supports_streaming: (payload as MassSendVideoPayload).supportsStreaming,
190
+ });
191
+ break;
192
+
193
+ case "document":
194
+ await tg.sendDocument(chatId, this.resolveMedia(payload.media) as any, {
195
+ caption: payload.caption,
196
+ parse_mode: parseMode,
197
+ });
198
+ break;
199
+
200
+ case "audio":
201
+ await tg.sendAudio(chatId, this.resolveMedia(payload.media) as any, {
202
+ caption: payload.caption,
203
+ parse_mode: parseMode,
204
+ duration: (payload as MassSendAudioPayload).duration,
205
+ performer: (payload as MassSendAudioPayload).performer,
206
+ title: (payload as MassSendAudioPayload).title,
207
+ });
208
+ break;
209
+
210
+ case "animation":
211
+ await tg.sendAnimation(chatId, this.resolveMedia(payload.media) as any, {
212
+ caption: payload.caption,
213
+ parse_mode: parseMode,
214
+ });
215
+ break;
216
+
217
+ case "voice":
218
+ await tg.sendVoice(chatId, this.resolveMedia(payload.media) as any, {
219
+ caption: payload.caption,
220
+ parse_mode: parseMode,
221
+ duration: (payload as MassSendVoicePayload).duration,
222
+ });
223
+ break;
224
+
225
+ default:
226
+ this.app.debugLog(`Unknown message type: ${(payload as any).type}`);
227
+ }
228
+ }
68
229
 
69
- this.app.debugLog("Fetched users for mass message:", users);
230
+ // ---------------------------------------------------------------------------
231
+ // Helpers
232
+ // ---------------------------------------------------------------------------
70
233
 
71
- if (users && users.length > 0) {
72
- for (const user of users) {
73
- this.app.debugLog(`Sending message to user ID: ${user.tgId} username: ${user.username}`);
234
+ private async resolveChatIds(payload: MassSendBasePayload): Promise<number[]> {
235
+ const explicitIds: number[] = payload.userIds ?? [];
74
236
 
75
- try {
76
- const extraOptions = extra || {};
77
- await this.app.bot.telegram.sendMessage(user.tgId, message, extraOptions);
78
- this.app.debugLog(`Message sent to user ID: ${user.tgId} username: ${user.username}`);
79
- } catch (error) {
80
- this.app.debugLog(
81
- `Sending message ${message} to user ID: ${user.tgId} username: ${user.username} failed`,
82
- error
83
- );
84
- this.app.debugLog(
85
- `Failed to send message to user ID: ${user.tgId} username: ${user.username}`,
86
- error
87
- );
88
- }
89
- }
90
- }
237
+ if (explicitIds.length > 0) {
238
+ return explicitIds;
239
+ }
240
+
241
+ // Fetch from DB
242
+ if (!db) {
243
+ throw new Error("Database connection is not established.");
91
244
  }
245
+ UserModel.setDatabase(db);
246
+ this.app.debugLog("Fetching all users for mass send...");
247
+ const users = UserModel.getAll();
248
+ return users.map((u) => u.tgId);
249
+ }
250
+
251
+ private sleep(ms: number): Promise<void> {
252
+ return new Promise((resolve) => setTimeout(resolve, ms));
92
253
  }
93
254
  }
@@ -0,0 +1,12 @@
1
+ -- UP
2
+ CREATE TABLE IF NOT EXISTS access_keys (
3
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4
+ user_id INTEGER NOT NULL,
5
+ key TEXT NOT NULL UNIQUE,
6
+ used INTEGER NOT NULL DEFAULT 0,
7
+ used_user_id INTEGER DEFAULT NULL,
8
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
9
+ );
10
+
11
+ -- DOWN
12
+ DROP TABLE IF EXISTS access_keys;
@@ -21,7 +21,7 @@
21
21
  "author": "{{author}}",
22
22
  "license": "MIT",
23
23
  "dependencies": {
24
- "@2byte/tgbot-framework": "^1.0.17"
24
+ "@2byte/tgbot-framework": "^1.0.19"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^20.19.8",