@2byte/tgbot-framework 1.0.16 → 1.0.18

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.16",
3
+ "version": "1.0.18",
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
@@ -8,6 +8,7 @@ import {
8
8
  import { Section } from "../illumination/Section";
9
9
  import { RunSectionRoute } from "../illumination/RunSectionRoute";
10
10
  import { UserModel } from "../user/UserModel";
11
+ import { AccessKey } from "../models/AccessKey";
11
12
  import { UserStore } from "../user/UserStore";
12
13
  import {
13
14
  AppConfig,
@@ -278,11 +279,16 @@ export class App {
278
279
  ? startPayload.split("key=")[1] || null
279
280
  : null;
280
281
  }
281
-
282
+
282
283
  // Check access by username and register user if not exists
283
284
  if (!this.config.userStorage.exists(tgUsername) && !this.rememberUser(tgUsername)) {
284
285
  const isAuthByUsername = !this.config.accessPublic && !accessKey;
285
-
286
+ this.debugLog(
287
+ "Access control check. isAuthByUsername:",
288
+ isAuthByUsername,
289
+ "accessKey in start payload:",
290
+ accessKey
291
+ );
286
292
  // check access by username for private bots
287
293
  if (isAuthByUsername) {
288
294
  const requestUsername = this.getTgUsername(ctx);
@@ -306,9 +312,14 @@ export class App {
306
312
  const accessKeys =
307
313
  this.config.envConfig.BOT_ACCESS_KEYS &&
308
314
  this.config.envConfig.BOT_ACCESS_KEYS.split(",").map((key) => key.trim());
315
+
316
+ AccessKey.setDatabase(globalThis.db);
317
+ const checkTempKey = AccessKey.checkKeyValid(accessKey);
318
+
309
319
  if (
310
320
  accessKeys &&
311
- accessKeys.every((key) => key.toLowerCase() !== accessKey?.toLowerCase())
321
+ accessKeys.every((key) => key.toLowerCase() !== accessKey?.toLowerCase()) &&
322
+ !checkTempKey
312
323
  ) {
313
324
  return ctx.reply("Access denied. Your access key is not valid.");
314
325
  }
@@ -321,15 +332,18 @@ export class App {
321
332
 
322
333
  const userRefIdFromStart = startPayload ? parseInt(startPayload) : 0;
323
334
 
324
- await this.registerUser({
325
- user_refid: userRefIdFromStart,
326
- tg_id: ctx.from.id,
327
- tg_username: tgUsername,
328
- tg_first_name: ctx.from.first_name || tgUsername,
329
- tg_last_name: ctx.from.last_name || "",
330
- role: "user",
331
- language: ctx.from.language_code || "en",
332
- });
335
+ await this.registerUser(
336
+ {
337
+ user_refid: userRefIdFromStart,
338
+ tg_id: ctx.from.id,
339
+ tg_username: tgUsername,
340
+ tg_first_name: ctx.from.first_name || tgUsername,
341
+ tg_last_name: ctx.from.last_name || "",
342
+ role: "user",
343
+ language: ctx.from.language_code || "en",
344
+ },
345
+ accessKey || undefined
346
+ );
333
347
  }
334
348
 
335
349
  ctx.user = this.config.userStorage.find(tgUsername);
@@ -433,7 +447,9 @@ export class App {
433
447
  ) {
434
448
  this.messageHandlers.forEach(async (handler: any) => {
435
449
  if (ctx.caught) {
436
- this.debugLog("Message already caught by another handler, skipping remaining handlers.");
450
+ this.debugLog(
451
+ "Message already caught by another handler, skipping remaining handlers."
452
+ );
437
453
  return;
438
454
  }
439
455
 
@@ -447,7 +463,9 @@ export class App {
447
463
  },
448
464
  });
449
465
  if (ctx.caught) {
450
- this.debugLog("Message handler route caught the message, skipping remaining handlers.");
466
+ this.debugLog(
467
+ "Message handler route caught the message, skipping remaining handlers."
468
+ );
451
469
  return;
452
470
  }
453
471
  }
@@ -459,17 +477,25 @@ export class App {
459
477
  : handler.constructor?.name || "unknown";
460
478
 
461
479
  if (handlerIsClass && !ctx.caught) {
462
- this.debugLog(`Running message handler class ${nameHandler} for user ${ctx.user.username}`);
480
+ this.debugLog(
481
+ `Running message handler class ${nameHandler} for user ${ctx.user.username}`
482
+ );
463
483
  await new handler(this).handle(ctx);
464
484
  if (ctx.caught) {
465
- this.debugLog("Message handler class caught the message, skipping remaining handlers.");
485
+ this.debugLog(
486
+ "Message handler class caught the message, skipping remaining handlers."
487
+ );
466
488
  return;
467
489
  }
468
490
  } else if (!handlerIsClass && typeof handler === "function" && !ctx.caught) {
469
- this.debugLog(`Running message handler function ${nameHandler} for user ${ctx.user.username}`);
491
+ this.debugLog(
492
+ `Running message handler function ${nameHandler} for user ${ctx.user.username}`
493
+ );
470
494
  await handler(ctx);
471
495
  if (ctx.caught) {
472
- this.debugLog("Message handler function caught the message, skipping remaining handlers.");
496
+ this.debugLog(
497
+ "Message handler function caught the message, skipping remaining handlers."
498
+ );
473
499
  return;
474
500
  }
475
501
  }
@@ -876,7 +902,7 @@ export class App {
876
902
  }
877
903
  sectionClass = this.sectionClasses.get(sectionId) as typeof Section;
878
904
  }
879
- this.debugLog("Using section class:", sectionClass);
905
+ this.debugLog("Using section class:", sectionClass.name);
880
906
 
881
907
  let sectionInstance: Section | undefined;
882
908
 
@@ -890,6 +916,10 @@ export class App {
890
916
  };
891
917
 
892
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
+ }
893
923
  return {
894
924
  instance,
895
925
  route,
@@ -1042,10 +1072,13 @@ export class App {
1042
1072
  return section;
1043
1073
  }
1044
1074
 
1045
- async registerUser(data: UserRegistrationData): Promise<UserModel | null> {
1075
+ async registerUser(data: UserRegistrationData, accessKey?: string): Promise<UserModel | null> {
1046
1076
  try {
1047
1077
  const user = await UserModel.register(data);
1048
1078
 
1079
+ if (accessKey) {
1080
+ AccessKey.markUsed(accessKey, user.id);
1081
+ }
1049
1082
  if (this.config.userStorage) {
1050
1083
  this.config.userStorage.add(data.tg_username, user);
1051
1084
  this.debugLog("User added to storage:", data.tg_username);
@@ -1063,22 +1096,22 @@ export class App {
1063
1096
  * @param tgUsername Telegram username of the user to remember. This method checks if the user exists in storage, and if not, tries to fetch it from the database and add to storage. This is useful for cases when user data might be updated in the database and we want to refresh the storage with the latest data.
1064
1097
  * @returns A boolean indicating whether the user was successfully remembered (true) or not (false).
1065
1098
  */
1066
- async rememberUser(tgUsername: string): Promise<boolean> {
1099
+ rememberUser(tgUsername: string): boolean {
1067
1100
  if (this.config.userStorage && !this.config.userStorage.exists(tgUsername)) {
1068
- this.debugLog("Warning: Username not found in storage:", tgUsername);
1069
- this.debugLog("Trying getting to database:", tgUsername);
1070
-
1101
+ this.debugLog("Warning: Remembering, Username not found in storage:", tgUsername);
1102
+ this.debugLog("Remembering, Trying getting to database:", tgUsername);
1103
+
1071
1104
  // Try to get user from database and add to storage
1072
1105
  UserModel.resolveDb();
1073
1106
  const userFromDb = UserModel.findByUsername(tgUsername);
1074
-
1107
+
1075
1108
  if (userFromDb) {
1076
1109
  this.config.userStorage.add(tgUsername, userFromDb);
1077
1110
  this.debugLog("Success: User found in database and added to storage:", tgUsername);
1078
1111
  this.debugLog('Success: Remembered user "' + tgUsername + '"');
1079
1112
  return true;
1080
1113
  } else {
1081
- this.debugLog("Warning: User not found in database:", tgUsername);
1114
+ this.debugLog("Warning: Remembering, User not found in database:", tgUsername);
1082
1115
  }
1083
1116
  }
1084
1117
  return false;
@@ -1328,7 +1361,7 @@ export class App {
1328
1361
  const source = `${colors.bright}${colors.fg.magenta}AppDebug${colors.reset}`;
1329
1362
 
1330
1363
  // Format args: highlight objects, errors, etc.
1331
- const formattedArgs = args.map(arg => {
1364
+ const formattedArgs = args.map((arg) => {
1332
1365
  if (arg instanceof Error) {
1333
1366
  return `${colors.fg.red}${arg.stack || arg.message}${colors.reset}`;
1334
1367
  }
@@ -1,10 +1,12 @@
1
- import * as path from 'path';
2
1
  import chalk from 'chalk';
3
2
  import { Artisan } from '../illumination/Artisan';
3
+ import { UserModel } from '../user/UserModel';
4
+ import type { Database } from 'bun:sqlite'
4
5
 
5
6
  export interface BotArtisanOptions {
6
7
  botName: string;
7
8
  sectionsPath?: string;
9
+ db?: Database;
8
10
  }
9
11
 
10
12
  export class BotArtisan {
@@ -13,7 +15,11 @@ export class BotArtisan {
13
15
 
14
16
  constructor(botPath: string, options: BotArtisanOptions) {
15
17
  this.options = options;
16
- this.artisan = new Artisan(botPath);
18
+ this.artisan = new Artisan(botPath, { db: options.db });
19
+
20
+ if (options.db) {
21
+ UserModel.setDatabase(options.db);
22
+ }
17
23
  }
18
24
 
19
25
  async run(): Promise<void> {
@@ -49,6 +55,40 @@ export class BotArtisan {
49
55
  await this.artisan.listSections();
50
56
  break;
51
57
 
58
+ case 'list:users': {
59
+ const users = UserModel.getAll();
60
+ if (!users.length) {
61
+ console.log(chalk.yellow('No users found.'));
62
+ break;
63
+ }
64
+ console.log(chalk.green('Users:'));
65
+ users.forEach(u => {
66
+ console.log(
67
+ `ID: ${u.id} | Username: ${u.tgUsername} | Name: ${u.tgName} | Role: ${u.role}`
68
+ );
69
+ });
70
+ break;
71
+ }
72
+
73
+ case 'set:admin': {
74
+ if (args.length < 1) {
75
+ console.error(chalk.red('❌ Error: User ID is required'));
76
+ console.log('Usage: artisan set:admin <userId>');
77
+ break;
78
+ }
79
+ const userId = Number(args[0]);
80
+
81
+ const user = UserModel.findById(userId);
82
+
83
+ if (!user) {
84
+ console.error(chalk.red(`❌ Error: User with ID ${userId} not found`));
85
+ break;
86
+ }
87
+ UserModel.update(userId, { role: 'admin' });
88
+ console.log(chalk.green(`User ${user.tgUsername} (ID: ${userId}) is now admin.`));
89
+ break;
90
+ }
91
+
52
92
  default:
53
93
  console.error(chalk.red(`❌ Unknown command: ${command}`));
54
94
  this.showHelp();
@@ -71,10 +111,15 @@ Available commands:
71
111
  add:method <section> <name> Add a new method to existing section
72
112
  list:sections List all sections
73
113
 
114
+ list:users List all users
115
+ set:admin <userId> Set user role to admin
116
+
74
117
  Examples:
75
118
  artisan make:section Auth Create new AuthSection
76
119
  artisan add:method Auth login Add login method to AuthSection
77
120
  artisan list:sections Show all available sections
121
+ artisan list:users Show all users
122
+ artisan set:admin 1 Set user with ID 1 as admin
78
123
  `);
79
124
  }
80
125
  }
@@ -1,11 +1,18 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import type { Database } from 'bun:sqlite';
4
+
5
+ export type ArtisanOptions = {
6
+ db?: Database;
7
+ };
3
8
 
4
9
  export class Artisan {
5
10
  private basePath: string;
11
+ private options: ArtisanOptions = {};
6
12
 
7
- constructor(basePath: string) {
13
+ constructor(basePath: string, options: ArtisanOptions) {
8
14
  this.basePath = basePath;
15
+ this.options = options;
9
16
  }
10
17
 
11
18
  /**
@@ -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;
@@ -0,0 +1,114 @@
1
+ import { Model } from "./Model";
2
+ import crypto from "crypto";
3
+ import { UserModel } from "../user/UserModel";
4
+
5
+ export interface AccessKeyData {
6
+ id: number;
7
+ user_id: number;
8
+ key: string;
9
+ used?: number;
10
+ used_user_id?: number | null;
11
+ created_at?: string;
12
+ }
13
+
14
+ export class AccessKey extends Model {
15
+ static tableName = "access_keys";
16
+
17
+ /** Generate a secure random key (hex) */
18
+ static generateKey(lengthBytes = 24): string {
19
+ return crypto.randomBytes(lengthBytes).toString("hex");
20
+ }
21
+
22
+ /** Create a new access key and ensure uniqueness */
23
+ static create(data: AccessKeyData): string {
24
+ const { user_id } = data;
25
+
26
+ const key = this.generateKey();
27
+
28
+ try {
29
+ this.execute(
30
+ `INSERT INTO ${this.tableName} (user_id, key, used, used_user_id) VALUES (?, ?, 0, NULL)`,
31
+ [user_id, key]
32
+ );
33
+ return key;
34
+ } catch (e) {
35
+ // If UNIQUE constraint violation, retry generating a new key
36
+ // other errors will bubble up
37
+ const msg = (e && (e as any).message) || "";
38
+ if (!msg.includes("UNIQUE") && !msg.includes("unique")) throw e;
39
+ }
40
+
41
+ return key;
42
+ }
43
+
44
+ static findByKey(key: string): AccessKeyData | null {
45
+ return this.queryOne(`SELECT * FROM ${this.tableName} WHERE key = ?`, [key]);
46
+ }
47
+
48
+ static findById(id: number): AccessKeyData | null {
49
+ return this.queryOne(`SELECT * FROM ${this.tableName} WHERE id = ?`, [id]);
50
+ }
51
+
52
+ static markUsed(key: string, usedUserId?: number): void {
53
+ this.execute(
54
+ `UPDATE ${this.tableName} SET used = 1, used_user_id = ?, created_at = created_at WHERE key = ?`,
55
+ [usedUserId || null, key]
56
+ );
57
+ }
58
+
59
+ static revoke(key: string): void {
60
+ this.execute(`DELETE FROM ${this.tableName} WHERE key = ?`, [key]);
61
+ }
62
+
63
+ static getAll(limit = 100): AccessKeyData[] {
64
+ return this.query(`SELECT * FROM ${this.tableName} ORDER BY created_at DESC LIMIT ?`, [limit]);
65
+ }
66
+
67
+ /** Get all access keys for a given user */
68
+ static findByUser(userId: number, limit = 100): AccessKeyData[] {
69
+ return this.query(
70
+ `SELECT * FROM ${this.tableName} WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`,
71
+ [userId, limit]
72
+ );
73
+ }
74
+
75
+ /** Return the owner `UserModel` for a given access entry or null */
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]);
89
+ if (!userData) return null;
90
+ try {
91
+ return UserModel.make(userData);
92
+ } catch (e) {
93
+ return userData;
94
+ }
95
+ }
96
+
97
+ /** Find owner by key string */
98
+ static getUserByKey(key: string): any | null {
99
+ const entry = this.findByKey(key);
100
+ if (!entry) return null;
101
+ return this.getUserByEntry(entry as AccessKeyData);
102
+ }
103
+
104
+ static checkKeyValid(key: string): boolean {
105
+ const queryKey = this.queryOne(
106
+ `SELECT COUNT(*) as count FROM ${this.tableName} WHERE key = ? AND used = 0`,
107
+ [key]
108
+ );
109
+
110
+ return queryKey && queryKey.count > 0;
111
+ }
112
+ }
113
+
114
+ export default AccessKey;
@@ -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;
@@ -1,3 +1,4 @@
1
1
  export * from './Model';
2
2
  export * from './TgAccount';
3
- export * from './Proxy';
3
+ export * from './Proxy';
4
+ export * from './AccessKey';
@@ -140,6 +140,20 @@ export class UserModel extends Model {
140
140
  throw new Error("Error not found user params:" + JSON.stringify(params));
141
141
  }
142
142
 
143
+ static findById(id: number): UserModel | undefined {
144
+ if (this.db) {
145
+ const userData = this.queryOne(`SELECT * FROM ${this.tableName} WHERE id = ?`, [id]);
146
+
147
+ if (userData) {
148
+ return UserModel.make(userData);
149
+ }
150
+ } else {
151
+ throw new Error("Database connection is not set.");
152
+ }
153
+
154
+ return undefined;
155
+ }
156
+
143
157
  static findByUsername(tgUsername: string): UserModel | undefined {
144
158
  if (this.db) {
145
159
  const userData = this.queryOne(`SELECT * FROM ${this.tableName} WHERE tg_username = ?`, [tgUsername]);
@@ -201,6 +215,32 @@ export class UserModel extends Model {
201
215
  }
202
216
  }
203
217
 
218
+ static update(id: number, attributes: Partial<UserAttributes>): UserModel | undefined {
219
+ if (this.db) {
220
+ const user = this.findById(id);
221
+ if (user) {
222
+ Object.assign(user.attributes, attributes);
223
+ const keys = Object.keys(attributes);
224
+ const values = Object.values(attributes);
225
+ const setString = keys.map(key => `${key} = ?`).join(', ');
226
+ this.db.run(`UPDATE ${this.tableName} SET ${setString} WHERE id = ?`, [...values, id]);
227
+ return user;
228
+ }
229
+ } else {
230
+ throw new Error("Database connection is not set.");
231
+ }
232
+ return undefined;
233
+ }
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
+
204
244
  get username(): string {
205
245
  return this.attributes.tg_username;
206
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
  }
@@ -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.16"
24
+ "@2byte/tgbot-framework": "^1.0.18"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^20.19.8",