Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Access control #3

Merged
merged 8 commits into from
Aug 6, 2023
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This GitHub repository contains the implementation of a telegram bot, designed t
7. **Model Support**: The bot supports both gpt-3.5-turbo and gpt-4 models with the capability to switch between them on-the-fly.
8. **Customizable System Prompts**: Enables the user to initiate conversations with custom system prompts to shape the bot's behavior.
9. **Context Window Size Customization**: The bot provides a feature to customize the maximum context window size. This allows users to set the context size for gpt-3.5-turbo and gpt-4 models individually, enabling more granular control over usage costs. This feature is particularly useful for managing API usage and optimizing the balance between cost and performance.
10. **Access Control**: The bot includes a feature for access control. Each user is assigned a role (stranger, basic, advanced, admin), and depending on the role, they gain access to the bot. Role management is carried out through a messaging mechanism, with inline buttons sent to the admin for role changes.

The purpose of this telegram bot is to create a ChatGpt-like user-friendly platform for interacting with GPT models. The repository is open for exploration, feedback, and contributions.

Expand Down
41 changes: 30 additions & 11 deletions app/bot/settings_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from aiogram import Bot, types, Dispatcher

from app.storage.db import User, DB

from app.storage.user_role import check_access_conditions

GPT_MODELS_OPTIONS = {
'gpt-3.5-turbo': 'GPT-3.5',
Expand All @@ -12,6 +12,10 @@
}


SETTINGS_PREFIX = 'settings'
HIDE_COMMAND = 'hide'


class VisibleOptionsSetting:
def __init__(self, model_field: str, options):
self.model_field = model_field
Expand Down Expand Up @@ -90,35 +94,50 @@ def __init__(self, bot: Bot, dispatcher: Dispatcher, db: DB):
'use_functions': OnOffSetting('Use functions', 'use_functions'),
'auto_summarize': OnOffSetting('Auto summarize', 'auto_summarize'),
}
self.dispatcher.register_callback_query_handler(self.process_callback, lambda c: c.data in self.settings or c.data == 'settings.hide')
self.minimum_required_roles = {
'current_model': settings.USER_ROLE_CHOOSE_MODEL,
}
self.dispatcher.register_callback_query_handler(self.process_callback, lambda c: SETTINGS_PREFIX in c.data)

async def send_settings(self, message: types.Message):
user = await self.db.get_or_create_user(message.from_user.id)
async def send_settings(self, message: types.Message, user: User):
await message.answer("Settings:", reply_markup=self.get_keyboard(user), parse_mode=types.ParseMode.MARKDOWN)

def is_setting_available_for_user(self, setting_name: str, user: User):
mininum_required_role = self.minimum_required_roles.get(setting_name)
if mininum_required_role and not check_access_conditions(mininum_required_role, user.role):
return False
return True

def get_keyboard(self, user: User):
keyboard = types.InlineKeyboardMarkup()
for setting_name, setting_obj in self.settings.items():
if not self.is_setting_available_for_user(setting_name, user):
continue

text = setting_obj.get_button_string(user)
keyboard.add(types.InlineKeyboardButton(text=text, callback_data=setting_name))
keyboard.add(types.InlineKeyboardButton(text='Hide settings', callback_data='settings.hide'))
keyboard.add(types.InlineKeyboardButton(text=text, callback_data=f'{SETTINGS_PREFIX}.{setting_name}'))
keyboard.add(types.InlineKeyboardButton(text='Hide settings', callback_data=f'{SETTINGS_PREFIX}.{HIDE_COMMAND}'))
return keyboard

def toggle_setting(self, user: User, setting: str):
setting = self.settings[setting]
user = setting.toggle(user)
def toggle_setting(self, user: User, setting_name: str):
if not self.is_setting_available_for_user(setting_name, user):
return user
setting_name = self.settings[setting_name]
user = setting_name.toggle(user)
return user

async def process_callback(self, callback_query: types.CallbackQuery):
if callback_query.data == 'settings.hide':
_, command = callback_query.data.split('.')
if command == HIDE_COMMAND:
await self.bot.delete_message(
chat_id=callback_query.from_user.id,
message_id=callback_query.message.message_id
)
await self.bot.answer_callback_query(callback_query.id)
else:
setting = command
user = await self.db.get_or_create_user(callback_query.from_user.id)
user = self.toggle_setting(user, callback_query.data)
user = self.toggle_setting(user, setting)
await self.db.update_user(user)

await self.bot.answer_callback_query(callback_query.id)
Expand Down
12 changes: 10 additions & 2 deletions app/bot/telegram_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
from app.bot.dialog_manager import DialogUtils
from app.bot.settings_menu import Settings
from app.bot.user_middleware import UserMiddleware
from app.bot.user_role_manager import UserRoleManager
from app.bot.utils import TypingWorker, detect_and_extract_code, get_username, message_is_forward, get_hide_button
from app.context.context_manager import build_context_manager
from app.openai_helpers.function_storage import FunctionStorage
from app.openai_helpers.utils import calculate_completion_usage_price, calculate_whisper_usage_price
from app.openai_helpers.whisper import get_audio_speech_to_text
from app.storage.db import DBFactory, User
from app.storage.user_role import check_access_conditions
from app.openai_helpers.chatgpt import ChatGPT, GptModel

from aiogram.utils.exceptions import CantParseEntities
Expand All @@ -37,13 +39,15 @@ def __init__(self, bot: Bot, dispatcher: Dispatcher, function_storage: FunctionS

# initialized in on_startup
self.settings = None
self.role_manager = None

async def on_startup(self, _):
self.db = await DBFactory().create_database(
settings.POSTGRES_USER, settings.POSTGRES_PASSWORD,
settings.POSTGRES_HOST, settings.POSTGRES_PORT, settings.POSTGRES_DATABASE
)
self.settings = Settings(self.bot, self.dispatcher, self.db)
self.role_manager = UserRoleManager(self.bot, self.dispatcher, self.db)
self.dispatcher.middleware.setup(UserMiddleware(self.db))

commands = [
Expand Down Expand Up @@ -185,6 +189,10 @@ async def reset_dialog(self, message: types.Message, user: User):
await message.answer('👌')

async def set_current_model(self, message: types.Message, user: User):
if not check_access_conditions(settings.USER_ROLE_CHOOSE_MODEL, user.role):
await message.answer(f'Your model is {user.current_model}. You have no permissions to change model')
return

model = GptModel.GPT_35_TURBO if message.get_command() == '/gpt3' else GptModel.GPT_4
user.current_model = model
await self.db.update_user(user)
Expand All @@ -209,9 +217,9 @@ async def get_usage(self, message: types.Message, user: User):
message, '\n'.join(result), types.ParseMode.MARKDOWN, reply_markup=get_hide_button()
)

async def open_settings(self, message: types.Message):
async def open_settings(self, message: types.Message, user: User):
await self.bot.delete_message(
chat_id=message.from_user.id,
message_id=message.message_id,
)
await self.settings.send_settings(message)
await self.settings.send_settings(message, user)
38 changes: 34 additions & 4 deletions app/bot/user_middleware.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,46 @@
import settings
from app.bot.user_role_manager import UserRoleManager
from app.storage.db import DB
from app.storage.user_role import check_access_conditions

from aiogram import types
from aiogram.dispatcher.handler import CancelHandler
from aiogram.dispatcher.middlewares import BaseMiddleware

from app.storage.db import DB


class UserMiddleware(BaseMiddleware):
def __init__(self, db: DB):
super().__init__()
self.db = db

async def on_pre_process_message(self, message: types.Message, data: dict):
is_new_user = False

user_id = message.from_user.id
# Здесь вы можете получить пользователя из базы данных
user = await self.db.get_or_create_user(user_id)
user = await self.db.get_user(user_id)
if user is None:
user = await self.db.create_user(user_id, settings.USER_ROLE_DEFAULT)
is_new_user = True

if user.role is None:
user.role = settings.USER_ROLE_DEFAULT
await self.db.update_user(user)

full_name = message.from_user.full_name
username = message.from_user.username
if user.full_name != full_name or user.username != username:
user.full_name = full_name
user.username = username
await self.db.update_user(user)

if settings.ENABLE_USER_ROLE_MANAGER_CHAT and is_new_user:
await UserRoleManager.send_new_user_to_admin(message, user)

user_have_access = check_access_conditions(settings.USER_ROLE_BOT_ACCESS, user.role)
if not user_have_access:
await message.answer(
"You currently don't have access to this bot. You will be notified once the admin grants you access."
)
raise CancelHandler()

data['user'] = user
76 changes: 76 additions & 0 deletions app/bot/user_role_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from aiogram import types, Bot, Dispatcher

import settings
from app.storage.db import User, DB
from app.storage.user_role import UserRole, check_access_conditions


SET_ROLE_COMMAND = 'setrole'
UPDATE_INFO_COMMAND = 'updinfo'


class UserRoleManager:
def __init__(self, bot: Bot, dispatcher: Dispatcher, db: DB):
self.bot = bot
self.dispatcher = dispatcher
self.db = db
self.dispatcher.register_callback_query_handler(
self.setrole_callback, lambda c: SET_ROLE_COMMAND in c.data,
)
self.dispatcher.register_callback_query_handler(
self.updaterole_callback, lambda c: UPDATE_INFO_COMMAND in c.data,
)

@staticmethod
def get_keyboard(user: User):
keyboard = types.InlineKeyboardMarkup()

for role in UserRole:
callback_data = f'{SET_ROLE_COMMAND}.{user.telegram_id}.{role.value}'
if role == user.role:
keyboard.add(types.InlineKeyboardButton(text=f'<{role.value}>', callback_data=callback_data))
else:
keyboard.add(types.InlineKeyboardButton(text=role.value, callback_data=callback_data))
keyboard.add(types.InlineKeyboardButton(text='🔄', callback_data=f'{UPDATE_INFO_COMMAND}.{user.telegram_id}'))
return keyboard

@staticmethod
def user_to_string(user):
result = [f'*User Id*: {user.id}', f'*Telegram Id*: {user.telegram_id}']
if user.full_name:
result.append(f'*Full name*: {user.full_name}')
if user.username:
result.append(f'*Username*: @{user.username}')
result.append(f'*Role*: {user.role.value}')
return '\n'.join(result)

@classmethod
async def send_new_user_to_admin(cls, message: types.Message, user: User):
bot = message.bot
text = cls.user_to_string(user)
await bot.send_message(
settings.USER_ROLE_MANAGER_CHAT_ID, text, reply_markup=cls.get_keyboard(user), parse_mode=types.ParseMode.MARKDOWN
)

async def update_message(self, message: types.Message, user: User):
text = self.user_to_string(user)
await message.edit_text(text, reply_markup=self.get_keyboard(user), parse_mode=types.ParseMode.MARKDOWN)

async def setrole_callback(self, callback_query: types.CallbackQuery):
command, tg_user_id, role_value = callback_query.data.split('.')
tg_user_id = int(tg_user_id)
user = await self.db.get_user(tg_user_id)
user_had_access = check_access_conditions(settings.USER_ROLE_BOT_ACCESS, user.role)
user.role = UserRole(role_value)
await self.db.update_user(user)
await self.bot.answer_callback_query(callback_query.id)
await self.update_message(callback_query.message, user)
if check_access_conditions(settings.USER_ROLE_BOT_ACCESS, user.role) and not user_had_access:
await self.bot.send_message(tg_user_id, f'You have been granted access to the bot.')

async def updaterole_callback(self, callback_query: types.CallbackQuery):
command, tg_user_id = callback_query.data.split('.')
tg_user_id = int(tg_user_id)
user = await self.db.get_user(tg_user_id)
await self.bot.answer_callback_query(callback_query.id)
await self.update_message(callback_query.message, user)
1 change: 0 additions & 1 deletion app/openai_helpers/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import openai



COMPLETION_PRICE = {
'gpt-3.5-turbo': (Decimal('0.0015'), Decimal('0.002')),
'gpt-3.5-turbo-16k': (Decimal('0.003'), Decimal('0.004')),
Expand Down
27 changes: 17 additions & 10 deletions app/storage/db.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import json
from datetime import datetime
from enum import Enum
from typing import List
from typing import List, Optional

import settings
from app.openai_helpers.chatgpt import DialogMessage, CompletionUsage
from app.storage.user_role import UserRole

import asyncpg
import pydantic

from app.openai_helpers.chatgpt import DialogMessage, CompletionUsage


class User(pydantic.BaseModel):
id: int
Expand All @@ -18,6 +20,9 @@ class User(pydantic.BaseModel):
voice_as_prompt: bool
use_functions: bool
auto_summarize: bool
full_name: Optional[str]
username: Optional[str]
role: Optional[UserRole]


class MessageType(Enum):
Expand Down Expand Up @@ -45,7 +50,7 @@ def __init__(self, connection_pool: asyncpg.Pool):
async def get_or_create_user(self, telegram_user_id):
user = await self.get_user(telegram_user_id)
if user is None:
user = await self.create_user(telegram_user_id)
user = await self.create_user(telegram_user_id, settings.USER_ROLE_DEFAULT)
return user

async def get_user(self, telegram_user_id):
Expand All @@ -58,17 +63,19 @@ async def get_user(self, telegram_user_id):
async def update_user(self, user: User):
sql = '''UPDATE chatgpttg.user
SET current_model = $1, gpt_mode = $2, forward_as_prompt = $3,
voice_as_prompt = $4, use_functions = $5, auto_summarize = $6 WHERE id = $7 RETURNING *'''
voice_as_prompt = $4, use_functions = $5, auto_summarize = $6,
full_name = $7, username = $8, role = $9 WHERE id = $10 RETURNING *'''
return User(**await self.connection_pool.fetchrow(
sql, user.current_model, user.gpt_mode, user.forward_as_prompt,
user.voice_as_prompt, user.use_functions, user.auto_summarize, user.id,
user.voice_as_prompt, user.use_functions, user.auto_summarize,
user.full_name, user.username, user.role.value, user.id,
))

async def create_user(self, telegram_user_id):
sql = 'INSERT INTO chatgpttg.user (telegram_id) VALUES ($1) RETURNING *'
return User(**await self.connection_pool.fetchrow(sql, telegram_user_id))
async def create_user(self, telegram_user_id: int, role: UserRole):
sql = 'INSERT INTO chatgpttg.user (telegram_id, role) VALUES ($1, $2) RETURNING *'
return User(**await self.connection_pool.fetchrow(sql, telegram_user_id, role.value))

async def get_telegram_message(self, tg_chat_id, tg_message_id):
async def get_telegram_message(self, tg_chat_id: int, tg_message_id: int):
sql = 'SELECT * FROM chatgpttg.message WHERE tg_chat_id = $1 AND tg_message_id = $2'
tg_message_record = await self.connection_pool.fetchrow(sql, tg_chat_id, tg_message_id)
if tg_message_record is None:
Expand Down
15 changes: 15 additions & 0 deletions app/storage/user_role.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from enum import Enum


class UserRole(Enum):
ADMIN = 'admin'
ADVANCED = 'advanced'
BASIC = 'basic'
STRANGER = 'stranger'


ROLE_ORDER = [UserRole.STRANGER, UserRole.BASIC, UserRole.ADVANCED, UserRole.ADMIN]


def check_access_conditions(required_role: UserRole, user_role: UserRole) -> bool:
return ROLE_ORDER.index(user_role) >= ROLE_ORDER.index(required_role)
7 changes: 6 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Optional

import settings
from app.bot.queued_dispatcher import QueuedDispatcher
from app.bot.telegram_bot import TelegramBot
Expand All @@ -11,12 +13,15 @@
dp = QueuedDispatcher(bot)


def setup_function_storage() -> FunctionStorage:
def setup_function_storage() -> Optional[FunctionStorage]:
functions = []

if settings.ENABLE_WOLFRAMALPHA:
functions.append(query_wolframalpha)

if not functions:
return None

function_storage = FunctionStorage()
for function in functions:
function_storage.register(function)
Expand Down
14 changes: 14 additions & 0 deletions migrations/sql/0003_add_user_roles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ALTER TABLE chatgpttg.user ADD COLUMN IF NOT EXISTS full_name text;
ALTER TABLE chatgpttg.user ADD COLUMN IF NOT EXISTS username text;

-- create user_role field
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_roles') THEN
CREATE TYPE chatgpttg.user_roles AS ENUM ('admin', 'advanced', 'basic', 'stranger');
END IF;
END
$$;
ALTER TABLE chatgpttg.user ADD COLUMN IF NOT EXISTS role chatgpttg.user_roles;
ALTER TABLE chatgpttg.user ADD COLUMN IF NOT EXISTS cdate timestamp WITH TIME ZONE NOT NULL default NOW();

Loading