@2byte/tgbot-framework 1.0.0 → 1.0.1

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.0",
3
+ "version": "1.0.1",
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
@@ -18,6 +18,7 @@ import { nameToCapitalize } from "./utils";
18
18
 
19
19
  export class App {
20
20
  private config: AppConfig = {
21
+ accessPublic: true,
21
22
  apiUrl: null,
22
23
  envConfig: {},
23
24
  botToken: null,
@@ -75,6 +76,16 @@ export class App {
75
76
  this.app = new App();
76
77
  }
77
78
 
79
+ accessPublic(isPublic: boolean = true): this {
80
+ this.app.config.accessPublic = isPublic;
81
+ return this;
82
+ }
83
+
84
+ accessPrivate(isPrivate: boolean = true): this {
85
+ this.app.config.accessPublic = !isPrivate;
86
+ return this;
87
+ }
88
+
78
89
  apiUrl(url: string): this {
79
90
  this.app.config.apiUrl = url;
80
91
  return this;
@@ -232,15 +243,30 @@ export class App {
232
243
  }
233
244
 
234
245
  if (!this.config.userStorage.exists(tgUsername)) {
235
- const userData = await this.registerUser({
236
- tgUsername,
237
- tgName: this.getTgName(ctx),
238
- tgId: this.getTgId(ctx),
239
- });
246
+ if (!this.config.accessPublic) {
247
+ const requestUsername = this.getTgUsername(ctx);
248
+ this.debugLog("Private access mode. Checking username:", requestUsername);
249
+ const checkAccess = this.config.envConfig.ACCESS_USERNAMES && this.config.envConfig.ACCESS_USERNAMES.split(",").map(name => name.trim());
250
+ if (checkAccess && checkAccess.every(name => name !== requestUsername)) {
251
+ return ctx.reply("Access denied. Your username is not in the access list.");
252
+ }
253
+ }
240
254
 
241
- if (!userData) {
242
- throw new Error("User registration failed");
255
+ if (!ctx.from) {
256
+ return ctx.reply("User information is not available");
243
257
  }
258
+
259
+ const userRefIdFromStart = ctx.startPayload ? parseInt(ctx.startPayload) : 0;
260
+
261
+ await this.registerUser({
262
+ user_refid: userRefIdFromStart,
263
+ tg_id: ctx.from.id,
264
+ tg_username: tgUsername,
265
+ tg_first_name: ctx.from.first_name || tgUsername,
266
+ tg_last_name: ctx.from.last_name || "",
267
+ role: 'user',
268
+ language: ctx.from.language_code || "en",
269
+ })
244
270
  }
245
271
 
246
272
  ctx.user = this.config.userStorage.find(tgUsername);
@@ -785,7 +811,7 @@ export class App {
785
811
  const user = await UserModel.register(data);
786
812
 
787
813
  if (this.config.userStorage) {
788
- this.config.userStorage.add(data.tgUsername, user);
814
+ this.config.userStorage.add(data.tg_username, user);
789
815
  }
790
816
 
791
817
  return user;
@@ -1010,7 +1036,7 @@ export class App {
1010
1036
  return this.config.sections;
1011
1037
  }
1012
1038
 
1013
- get config(): AppConfig {
1039
+ get configApp(): AppConfig {
1014
1040
  return this.config;
1015
1041
  }
1016
1042
  }
@@ -0,0 +1,80 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { MakeManualPaginateButtonsParams, ModelPaginateParams, PaginateResult } from "../types";
3
+ import { Section } from "../illumination/Section";
4
+
5
+ export abstract class Model {
6
+ protected static db: Database;
7
+ protected static tableName: string;
8
+
9
+ static setDatabase(database: Database) {
10
+ this.db = database;
11
+ }
12
+
13
+ protected static resolveDb(): Database {
14
+ if (globalThis.db) {
15
+ this.db = globalThis.db;
16
+ } else {
17
+ throw new Error("Database connection is not set.");
18
+ }
19
+ return this.db;
20
+ }
21
+
22
+ protected static query(sql: string, params: any[] = []): any {
23
+ const stmt = this.db.prepare(sql);
24
+ return stmt.all(...params);
25
+ }
26
+
27
+ protected static run(sql: string, params: any[] = []): { lastInsertRowid: number, changes: number } {
28
+ const stmt = this.db.prepare(sql);
29
+ return stmt.run(...params);
30
+ }
31
+
32
+ protected static queryOne(sql: string, params: any[] = []): any {
33
+ const stmt = this.db.prepare(sql);
34
+ return stmt.get(...params);
35
+ }
36
+
37
+ protected static execute(sql: string, params: any[] = []): void {
38
+ const stmt = this.db.prepare(sql);
39
+ stmt.run(...params);
40
+ }
41
+
42
+ protected static exists(whereSql: string, whereParams: any[] = []): boolean {
43
+ const sql = `SELECT 1 FROM ${this.tableName} ${whereSql} LIMIT 1`;
44
+ const result = this.queryOne(sql, whereParams);
45
+ return !!result;
46
+ }
47
+
48
+ protected static transaction<T>(callback: () => T): T {
49
+ return this.db.transaction(callback)();
50
+ }
51
+
52
+ public static async paginate(paginateParams: ModelPaginateParams): Promise<PaginateResult> {
53
+ const { page, route, routeParams, limit, whereSql, whereParams } = paginateParams;
54
+ const offset = (page - 1) * limit;
55
+ const sql = `SELECT * FROM ${this.tableName} ${whereSql} LIMIT ${offset}, ${limit}`;
56
+
57
+ const result = await this.query(sql, whereParams);
58
+ const queryTotal = await this.queryOne(`SELECT COUNT(*) as count FROM ${this.tableName} ${whereSql}`, whereParams);
59
+ const total = queryTotal ? queryTotal.count : 0;
60
+ const totalPages = Math.ceil(total / limit);
61
+ const hasPreviousPage = page > 1;
62
+ const hasNextPage = page < totalPages;
63
+
64
+ return {
65
+ items: result,
66
+ paginateButtons: Section.makeManualPaginateButtons({
67
+ callbackDataAction: route,
68
+ paramsQuery: routeParams || {},
69
+ currentPage: page,
70
+ totalRecords: total,
71
+ perPage: limit,
72
+ } as MakeManualPaginateButtonsParams),
73
+ total,
74
+ totalPages,
75
+ hasPreviousPage,
76
+ hasNextPage,
77
+ currentPage: page,
78
+ } as PaginateResult;
79
+ }
80
+ }
@@ -80,7 +80,7 @@ export default class Message2byte {
80
80
  return this;
81
81
  }
82
82
 
83
- inlineKeyboard(keyboard: [][] | InlineKeyboard) {
83
+ inlineKeyboard(keyboard: any[][] | InlineKeyboard) {
84
84
  let keyboardArray: any[][];
85
85
 
86
86
  if (keyboard instanceof InlineKeyboard) {
package/src/types.ts CHANGED
@@ -5,6 +5,7 @@ import { Telegraf2byteContext } from './illumination/Telegraf2byteContext';
5
5
  import { RunSectionRoute } from './illumination/RunSectionRoute';
6
6
 
7
7
  export interface AppConfig {
8
+ accessPublic: boolean;
8
9
  apiUrl: string | null;
9
10
  envConfig: EnvVars;
10
11
  botToken: string | null;
@@ -38,9 +39,18 @@ export interface RunnedSection {
38
39
 
39
40
  export interface UserAttributes {
40
41
  id?: number;
42
+ user_refid?: number;
41
43
  tg_id: number;
42
44
  tg_username: string;
43
- tg_name: string;
45
+ tg_first_name: string;
46
+ tg_last_name?: string;
47
+ role: 'user' | 'admin';
48
+ is_banned_by_user?: boolean;
49
+ is_banned_by_admin?: boolean;
50
+ bunned_reason?: string;
51
+ language: string;
52
+ updated_at: string;
53
+ created_at: string;
44
54
  [key: string]: any;
45
55
  }
46
56
 
@@ -89,10 +99,13 @@ export interface UserSession {
89
99
  }
90
100
 
91
101
  export interface UserRegistrationData {
92
- tgUsername: string;
93
- tgName: string;
94
- tgId: number;
95
- userRefid?: number;
102
+ user_refid?: number;
103
+ tg_id: number;
104
+ tg_username: string;
105
+ tg_first_name: string;
106
+ tg_last_name?: string;
107
+ role: 'user' | 'admin';
108
+ language: string;
96
109
  }
97
110
 
98
111
  export interface ComponentOptions {
@@ -141,6 +154,8 @@ export interface EnvVars {
141
154
  BOT_HOOK_PORT?: string;
142
155
  BOT_HOOK_SECRET_TOKEN?: string;
143
156
  BOT_DEV_HOT_RELOAD_SECTIONS?: string;
157
+ BOT_ACCESS?: 'private' | 'public';
158
+ ACCESS_USERNAMES?: string; // comma separated usernames
144
159
  [key: string]: string | undefined;
145
160
  }
146
161
 
@@ -1,7 +1,9 @@
1
- import { UserAttributes, UserServiceAttributes } from '../types';
1
+ import { Model } from '../core/Model';
2
+ import { UserAttributes, UserRegistrationData, UserServiceAttributes } from '../types';
2
3
  import { UserStore } from './UserStore';
3
4
 
4
- export class UserModel {
5
+ export class UserModel extends Model {
6
+ static tableName = 'users';
5
7
  attributes: UserAttributes;
6
8
  serviceAttributes: UserServiceAttributes = {
7
9
  lastActive: new Date(),
@@ -11,6 +13,7 @@ export class UserModel {
11
13
  private userSession: UserStore | null = null;
12
14
 
13
15
  constructor(attributes: UserAttributes) {
16
+ super();
14
17
  this.attributes = attributes;
15
18
  }
16
19
 
@@ -67,22 +70,75 @@ export class UserModel {
67
70
  return false;
68
71
  }
69
72
 
70
- static async register(params: { tgUsername: string; tgId: number; tgName: string; userRefid?: number }): Promise<UserModel> {
73
+ static async register(params: UserRegistrationData): Promise<UserModel> {
71
74
  // Здесь должен быть запрос к API, но мы оставим это для будущей реализации
72
75
  // let resApi = await api.fetch("user/register", "post", {
73
76
  // tg_username: params.tgUsername,
74
77
  // tg_id: params.tgId,
75
- // name: params.tgName,
78
+ // tg_first_name: params.tgFirstName,
79
+ // tg_last_name: params.tgLastName,
76
80
  // user_refid: params.userRefid,
81
+ // role: params.role || 'user',
82
+ // language: params.language || 'en'
77
83
  // });
78
84
 
79
85
  // return UserModel.make(resApi.data);
86
+ this.resolveDb();
80
87
 
81
- return UserModel.make({
82
- tg_id: params.tgId,
83
- tg_username: params.tgUsername,
84
- tg_name: params.tgName
85
- });
88
+ if (this.db && !this.exists('WHERE tg_id = ?', [params.tg_id])) {
89
+ const result = this.run(
90
+ `INSERT INTO ${this.tableName} (tg_id, tg_username, tg_first_name, tg_last_name, user_refid, role, language)
91
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
92
+ [
93
+ params.tg_id,
94
+ params.tg_username,
95
+ params.tg_first_name,
96
+ params.tg_last_name || null,
97
+ params.user_refid || null,
98
+ params.role || 'user',
99
+ params.language || 'en',
100
+ ]
101
+ );
102
+
103
+ if (!result || !result.lastInsertRowid) {
104
+ throw new Error("Failed to register user in the database.");
105
+ }
106
+
107
+ return UserModel.make({
108
+ id: result.lastInsertRowid as number,
109
+ tg_id: params.tg_id,
110
+ tg_username: params.tg_username,
111
+ tg_first_name: params.tg_first_name,
112
+ tg_last_name: params.tg_last_name,
113
+ user_refid: params.user_refid,
114
+ role: params.role || 'user',
115
+ language: params.language || 'en',
116
+ created_at: new Date().toISOString(),
117
+ updated_at: new Date().toISOString()
118
+ });
119
+ }
120
+
121
+ const user = this.findByUsername(params.tg_username);
122
+
123
+ if (user) {
124
+ return user;
125
+ }
126
+
127
+ throw new Error("Error not found user params:" + JSON.stringify(params));
128
+ }
129
+
130
+ static findByUsername(tgUsername: string): UserModel | undefined {
131
+ if (this.db) {
132
+ const userData = this.queryOne(`SELECT * FROM ${this.tableName} WHERE tg_username = ?`, [tgUsername]);
133
+
134
+ if (userData) {
135
+ return UserModel.make(userData);
136
+ }
137
+ } else {
138
+ throw new Error("Database connection is not set.");
139
+ }
140
+
141
+ return undefined;
86
142
  }
87
143
 
88
144
  static async findOnServer(tgUsername: string): Promise<UserModel> {
@@ -90,10 +146,15 @@ export class UserModel {
90
146
  // const user = await api.fetch("user/get/" + tgUsername);
91
147
  // return UserModel.make(user.data);
92
148
 
149
+ const now = new Date().toISOString();
93
150
  return UserModel.make({
94
151
  tg_id: 0,
95
152
  tg_username: tgUsername,
96
- tg_name: tgUsername
153
+ tg_first_name: tgUsername,
154
+ role: 'user',
155
+ language: 'en',
156
+ created_at: now,
157
+ updated_at: now
97
158
  });
98
159
  }
99
160
 
@@ -129,4 +190,99 @@ export class UserModel {
129
190
  get username(): string {
130
191
  return this.attributes.tg_username;
131
192
  }
193
+
194
+ get firstName(): string {
195
+ return this.attributes.tg_first_name;
196
+ }
197
+
198
+ get lastName(): string | undefined {
199
+ return this.attributes.tg_last_name;
200
+ }
201
+
202
+ get fullName(): string {
203
+ const firstName = this.attributes.tg_first_name || '';
204
+ const lastName = this.attributes.tg_last_name || '';
205
+ return `${firstName} ${lastName}`.trim();
206
+ }
207
+
208
+ get role(): 'user' | 'admin' {
209
+ return this.attributes.role;
210
+ }
211
+
212
+ get language(): string {
213
+ return this.attributes.language;
214
+ }
215
+
216
+ get isBannedByUser(): boolean {
217
+ return this.attributes.is_banned_by_user || false;
218
+ }
219
+
220
+ get isBannedByAdmin(): boolean {
221
+ return this.attributes.is_banned_by_admin || false;
222
+ }
223
+
224
+ get bannedReason(): string | undefined {
225
+ return this.attributes.bunned_reason;
226
+ }
227
+
228
+ get userRefId(): number | undefined {
229
+ return this.attributes.user_refid;
230
+ }
231
+
232
+ get createdAt(): string {
233
+ return this.attributes.created_at;
234
+ }
235
+
236
+ get updatedAt(): string {
237
+ return this.attributes.updated_at;
238
+ }
239
+
240
+ setRole(role: 'user' | 'admin'): this {
241
+ this.attributes.role = role;
242
+ return this;
243
+ }
244
+
245
+ setLanguage(language: string): this {
246
+ this.attributes.language = language;
247
+ return this;
248
+ }
249
+
250
+ banByAdmin(reason?: string): this {
251
+ this.attributes.is_banned_by_admin = true;
252
+ if (reason) {
253
+ this.attributes.bunned_reason = reason;
254
+ }
255
+ return this;
256
+ }
257
+
258
+ unbanByAdmin(): this {
259
+ this.attributes.is_banned_by_admin = false;
260
+ this.attributes.bunned_reason = undefined;
261
+ return this;
262
+ }
263
+
264
+ banByUser(): this {
265
+ this.attributes.is_banned_by_user = true;
266
+ return this;
267
+ }
268
+
269
+ unbanByUser(): this {
270
+ this.attributes.is_banned_by_user = false;
271
+ return this;
272
+ }
273
+
274
+ updateFirstName(firstName: string): this {
275
+ this.attributes.tg_first_name = firstName;
276
+ return this;
277
+ }
278
+
279
+ updateLastName(lastName: string): this {
280
+ this.attributes.tg_last_name = lastName;
281
+ return this;
282
+ }
283
+
284
+ updateUsername(username: string): this {
285
+ this.attributes.tg_username = username;
286
+ return this;
287
+ }
132
288
  }
@@ -14,6 +14,8 @@ const requiredEnvVars: (keyof EnvVars)[] = [
14
14
  "BOT_API_URL",
15
15
  "BOT_HOOK_DOMAIN",
16
16
  "BOT_HOOK_PORT",
17
+ "BOT_ACCESS",
18
+ "ACCESS_USERNAMES"
17
19
  ];
18
20
  for (const envVar of requiredEnvVars) {
19
21
  if (!process.env[envVar]) {
@@ -53,6 +55,7 @@ const appController = new App.Builder()
53
55
  // secretToken: process.env.BOT_HOOK_SECRET_TOKEN,
54
56
  // },
55
57
  })
58
+ .accessPublic(process.env.BOT_ACCESS === "public")
56
59
  .apiUrl(process.env.BOT_API_URL as string)
57
60
  .settings(settings)
58
61
  .userStorage(userStorage)
@@ -1,11 +1,13 @@
1
1
  -- UP
2
2
  CREATE TABLE IF NOT EXISTS `users` (
3
3
  `id` INTEGER PRIMARY KEY AUTOINCREMENT,
4
+ `user_refid` INTEGER DEFAULT 0,
4
5
  `role` TEXT DEFAULT 'user',
5
6
  `tg_id` INTEGER NOT NULL UNIQUE,
6
- `username` TEXT,
7
- `first_name` TEXT,
8
- `last_name` TEXT,
7
+ `tg_username` TEXT,
8
+ `tg_full_name` TEXT GENERATED ALWAYS AS (trim(coalesce(tg_first_name, '') || ' ' || coalesce(tg_last_name, ''))) VIRTUAL,
9
+ `tg_first_name` TEXT,
10
+ `tg_last_name` TEXT,
9
11
  `is_banned_by_user` INTEGER DEFAULT 0,
10
12
  `is_banned_by_admin` INTEGER DEFAULT 0,
11
13
  `banned_reason` TEXT,
@@ -1,7 +1,7 @@
1
1
  import { Section, SectionOptions, InlineKeyboard } from "@2byte/tgbot-framework";
2
2
 
3
3
  export default class HomeSection extends Section {
4
- static command = "home";
4
+ static command = "start";
5
5
  static description = "{{className}} Bot Home section";
6
6
  static actionRoutes = {
7
7
  "home.index": "index",
@@ -35,6 +35,7 @@ export default class HomeSection extends Section {
35
35
  `;
36
36
 
37
37
  await this.message(message)
38
+ .markdown()
38
39
  .inlineKeyboard(this.mainInlineKeyboard)
39
40
  .send();
40
41
  }
@@ -55,6 +56,7 @@ export default class HomeSection extends Section {
55
56
  `;
56
57
 
57
58
  await this.message(message)
59
+ .markdown()
58
60
  .inlineKeyboard([
59
61
  [this.makeInlineButton("🏠 На главную", "home.index")],
60
62
  ])