├── .gitignore ├── example.jpg ├── cottontail.png ├── .env.example ├── requirements.txt ├── .config ├── README.md └── cottontail.py /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .vscode -------------------------------------------------------------------------------- /example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevinter/cottontail/HEAD/example.jpg -------------------------------------------------------------------------------- /cottontail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevinter/cottontail/HEAD/cottontail.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY = "example" 2 | GOOGLE_API_KEY = "example" 3 | GOOGLE_CSE_ID = "example" 4 | WOLFRAM_ALPHA_APPID = "example" 5 | TELEGRAM_TOKEN = "example" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohappyeyeballs==2.4.3 2 | aiohttp==3.10.10 3 | aiosignal==1.3.1 4 | anyio==4.6.2.post1 5 | attrs==24.2.0 6 | cachetools==5.5.0 7 | certifi==2024.8.30 8 | charset-normalizer==3.4.0 9 | dataclasses-json==0.5.14 10 | frozenlist==1.4.1 11 | google-api-core==2.21.0 12 | google-api-python-client==2.149.0 13 | google-auth==2.35.0 14 | google-auth-httplib2==0.2.0 15 | googleapis-common-protos==1.65.0 16 | gptcache==0.1.44 17 | greenlet==3.1.1 18 | h11==0.14.0 19 | httpcore==0.16.3 20 | httplib2==0.22.0 21 | httpx==0.23.3 22 | idna==3.10 23 | jaraco.context==6.0.1 24 | langchain==0.0.139 25 | marshmallow==3.23.0 26 | more-itertools==10.5.0 27 | multidict==6.1.0 28 | mypy-extensions==1.0.0 29 | numpy==1.26.4 30 | openai==0.27.2 31 | openapi-schema-pydantic==1.2.4 32 | packaging==24.1 33 | propcache==0.2.0 34 | proto-plus==1.24.0 35 | protobuf==5.28.2 36 | pyasn1==0.6.1 37 | pyasn1_modules==0.4.1 38 | pydantic==1.10.18 39 | pyparsing==3.2.0 40 | python-dotenv==1.0.0 41 | python-telegram-bot==20.2 42 | PyYAML==6.0.2 43 | requests==2.32.3 44 | rfc3986==1.5.0 45 | rsa==4.9 46 | sniffio==1.3.1 47 | SQLAlchemy==1.4.54 48 | tenacity==8.5.0 49 | tqdm==4.66.5 50 | typing-inspect==0.9.0 51 | typing_extensions==4.12.2 52 | uritemplate==4.1.1 53 | urllib3==2.2.3 54 | wolframalpha==5.1.3 55 | xmltodict==0.14.2 56 | yarl==1.15.5 57 | -------------------------------------------------------------------------------- /.config: -------------------------------------------------------------------------------- 1 | [system] 2 | botname = yourBotName 3 | language = English 4 | 5 | ; Set this to true if you plan to use the bot in a group setting 6 | group = False 7 | 8 | [user] 9 | username = yourUserName 10 | 11 | ; This information is optional and can be used to finetune the initial bot's knowledge about you 12 | information = My name is thevinter and I'm a Software Engineer. 13 | 14 | [tools] 15 | ; Allows the bot to perform calculations using Wolfram Alpha. Requires a Developer Key 16 | enable_wolfram = True 17 | 18 | ; Allows the bot to perform google searches. You'll have to create a custom google search engine 19 | enable_google = True 20 | 21 | ; Experimental. At this moment the Human interaction doesn't hold context so it works in an unpredictable manner and sometimes forgets messages 22 | ; It is recommended to keep it to False for best results 23 | enable_human = False 24 | 25 | ### DANGER ZONE ### 26 | ### Enable these only if you know EXACTLY what you're doing 27 | enable_python = False 28 | enable_bash = False 29 | ### DANGER ZONE ### 30 | 31 | [assistant] 32 | ; This message is used only when the bot group context is enabled 33 | system_message = You are a human-like assistant used in a telegram group chat. 34 | Each message you will receive will ALWAYS start with the name of the person writing it so you can recognize them. 35 | The first name is the source of truth. 36 | Try to behave like a helpful personal assistant that can help with tasks and chatting 37 | 38 | ; This message is used only when the bot group context is enabled 39 | group_system_message = You are a human-like assistant used in a telegram group chat. 40 | Each message you will receive will ALWAYS start with the name of the person writing it so you can recognize them. 41 | That name is the source of truth and will ALWAYS identify who is speaking 42 | Try to behave like another member of the group, and send your messages without any names beforehand. 43 | 44 | model = gpt-3.5-turbo 45 | temperature = 0.7 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # COTTONTAIL 2 | 3 |

4 | 5 |

6 | 7 | Cottontail is a Telegram bot that leverages GPT to provide an AI assistant that can use multiple tools to reply to users in both chat and group contexts and you can easily self-host on your machine. 8 | 9 | ## Features: 10 | 11 | 1. Can assess and respond to user inputs in both chat and group contexts. 12 | 2. Utilizes multiple tools such as Google Search, WolframAlpha, Python REPL, and Bash. 13 | 3. Supports human intervention for tasks that require human input. 14 | 4. Conversations and context-awareness capabilities. 15 | 16 | ![Example](example.jpg) 17 | 18 | ## Dependencies: 19 | 20 | The program requires external Python libraries: 21 | 22 | To install the necessary dependencies, use the following command: 23 | 24 | ```bash 25 | pip install -r requirements.txt 26 | ``` 27 | 28 | ## Configuration: 29 | 30 | 1. Rename the .env.example file as .env and fill the necessary API keys 31 | 32 | 2. Edit the .config file with your information 33 | 34 | ### Google API Key 35 | 36 | Create the GOOGLE_API_KEY in the [Google Cloud credential console](https://console.cloud.google.com/apis/credentials) and a GOOGLE_CSE_ID using the [Programmable Search Engine](https://programmablesearchengine.google.com/controlpanel/create). Next, it is good to follow the Follow [these](https://stackoverflow.com/questions/37083058/programmatically-searching-google-in-python-using-custom-search) instructions 37 | 38 | ## Getting Started 39 | 40 | Before you can use the AI Assistant Telegram Bot, you need to create a new bot on Telegram and get your unique API key. Follow these steps: 41 | 42 | - Open the Telegram app and search for the "BotFather" bot. 43 | - Start a chat with the BotFather and send the following command: /newbot 44 | - Follow the instructions provided by the BotFather to create your new bot. It will ask you to choose a name and a username for your bot. 45 | - After you've successfully created your bot, the BotFather will provide you with your unique bot API key (also known as the bot token). Save this API key, as you will need it to run your AI Assistant Telegram Bot. 46 | 47 | ## How To Run: 48 | 49 | 1. Ensure that you have Python 3.7 or later installed. 50 | 2. Install the required dependencies. 51 | 3. Set up the .env and .config files according to the configuration instructions. 52 | 4. Run the bot using the following command: 53 | 54 | ```bash 55 | python3 main.py 56 | ``` 57 | 58 | ## Usage 59 | 60 | The mode of interaction differs slightly whether the bot is running in chat or group mode. 61 | 62 | ### Chat mode 63 | Just start a new conversation with the bot. Every message sent in private chat to the bot will be parsed as input to the bot. To reply to a question just send another message. 64 | 65 | ### Group mode 66 | - First you need to add the bot to a group 67 | - Then you need to enable the bot in that specific chat using the `/enable_group` command 68 | - The bot will react only to messages that start with @. If the "Human Feedback" tool is enabled and the bot asks a question then the reply needs to quote the message containing the question, every other message will be ignored 69 | - To disable the bot for the group use the `/disable_group` command 70 | 71 | ## Customization: 72 | 73 | You can customize the available tools and functionalities by modifying the configuration file .config according to your preferences. 74 | 75 | ``` 76 | botname -> The handle you have to your bot during cration 77 | language -> The language you want your replies to be in 78 | group -> If false then the bot will run in chat mode (see above) 79 | username -> Your handle on Telegram. If in chat mode then the bot will reply only if this matches the one of the one chatting with him. Only this username is allowed to use /enable_group and /disable_group 80 | information -> Some information you'd like the bot to know about you 81 | 82 | enable_wolfram -> Enables the integration with Wolfram Alpha for math questions 83 | enable_google -> Enables the integration with Google to allow the bot to search the web 84 | 85 | enable_human -> Allows the bot to aks questions back. This is still experimental and doesn't maintain the context of the chat so it might misbehave 86 | 87 | ### DANGER ZONE ### 88 | ### Enable these only if you know EXACTLY what you're doing 89 | enable_python -> Allows the bot to run UNCHECKED python code on the server machine 90 | enable_bash -> Allows the bot to run UNCHECKED bash code on the server machine 91 | ### DANGER ZONE ### 92 | 93 | system_message -> A system message for the chat mode 94 | group_system_message -> A system message for the group mode 95 | model -> The GPT model you wish to use (gpt-3.5-turbo / gpt-4) 96 | temperature -> A value from 0 to 1 representing the randomness of the replies (The smaller it is, the more deterministic it is) 97 | ``` 98 | -------------------------------------------------------------------------------- /cottontail.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import logging 3 | import os 4 | import asyncio 5 | from queue import Queue 6 | from typing import Any, Dict, List, Optional, Union 7 | 8 | import openai 9 | from dotenv import load_dotenv 10 | from langchain.agents import AgentType, Tool, initialize_agent 11 | from langchain.callbacks.base import BaseCallbackHandler, CallbackManager 12 | from langchain.chat_models import ChatOpenAI 13 | from langchain.memory import ConversationBufferMemory, ReadOnlySharedMemory 14 | from langchain.schema import AgentAction, AgentFinish, LLMResult, SystemMessage 15 | from langchain.tools.human.tool import HumanInputRun 16 | from langchain.utilities import BashProcess, GoogleSearchAPIWrapper, PythonREPL 17 | from langchain.utilities.wolfram_alpha import WolframAlphaAPIWrapper 18 | from telegram import Update 19 | from telegram.constants import ChatAction 20 | from telegram.ext import ( 21 | Application, 22 | CommandHandler, 23 | ContextTypes, 24 | MessageHandler, 25 | filters, 26 | ) 27 | 28 | logging.basicConfig( 29 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO 30 | ) 31 | 32 | logger = logging.getLogger(__name__) 33 | 34 | load_dotenv() 35 | config = configparser.ConfigParser() 36 | config.read('.config') 37 | 38 | openai.api_key = os.getenv("OPENAI_API_KEY") 39 | 40 | TOKEN = os.getenv("TELEGRAM_TOKEN") 41 | IS_GROUP = config.getboolean("system", "group") 42 | BOT_NAME = config.get("system", "botname") 43 | USERNAME = config.get("user", "username") 44 | LANGUAGE = config.get("system", "language") 45 | IS_AWAITING = False 46 | ALLOWED_CHATS = filters.Chat(chat_id=["253580370"]) 47 | 48 | ENABLE_HUMAN = config.getboolean("tools", "enable_human") 49 | ENABLE_GOOGLE = config.getboolean("tools", "enable_google") 50 | ENABLE_WOLFRAM = config.getboolean("tools", "enable_wolfram") 51 | ENABLE_BASH = config.getboolean("tools", "enable_bash") 52 | ENABLE_PYTHON = config.getboolean("tools", "enable_python") 53 | 54 | search = GoogleSearchAPIWrapper() if ENABLE_GOOGLE else None 55 | human = HumanInputRun() if ENABLE_HUMAN else None 56 | wolfram = WolframAlphaAPIWrapper() if ENABLE_WOLFRAM else None 57 | python = PythonREPL() if ENABLE_PYTHON else None 58 | bash = BashProcess() if ENABLE_BASH else None 59 | 60 | tools = [] 61 | 62 | # Initialize the application variable globally 63 | application = None 64 | 65 | class MyCustomCallbackHandler(BaseCallbackHandler): 66 | """Custom CallbackHandler.""" 67 | 68 | def on_llm_start( 69 | self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any 70 | ) -> None: 71 | """Do nothing.""" 72 | pass 73 | 74 | def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: 75 | """Do nothing.""" 76 | pass 77 | 78 | def on_llm_new_token(self, token: str, **kwargs: Any) -> None: 79 | """Do nothing.""" 80 | pass 81 | 82 | def on_llm_error( 83 | self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any 84 | ) -> None: 85 | """Do nothing.""" 86 | pass 87 | 88 | def on_chain_start( 89 | self, serialized: Dict[str, Any], inputs: Dict[str, Any], **kwargs: Any 90 | ) -> None: 91 | """Print out that we are entering a chain.""" 92 | class_name = serialized["name"] 93 | logger.info(f"\n\n\033[1m> Entering new {class_name} chain...\033[0m") 94 | 95 | def on_chain_end(self, outputs: Dict[str, Any], **kwargs: Any) -> None: 96 | """Print out that we finished a chain.""" 97 | logger.info("\n\033[1m> Finished chain.\033[0m") 98 | 99 | def on_chain_error( 100 | self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any 101 | ) -> None: 102 | """Do nothing.""" 103 | pass 104 | 105 | def on_tool_start( 106 | self, 107 | serialized: Dict[str, Any], 108 | input_str: str, 109 | **kwargs: Any, 110 | ) -> None: 111 | """Do nothing.""" 112 | pass 113 | 114 | def on_agent_action( 115 | self, action: AgentAction, color: Optional[str] = None, **kwargs: Any 116 | ) -> Any: 117 | """Run on agent action. Save the question for future use.""" 118 | global question 119 | if action.tool == "Human": 120 | question = action.tool_input 121 | logger.info(action) 122 | 123 | def on_tool_end( 124 | self, 125 | output: str, 126 | color: Optional[str] = None, 127 | observation_prefix: Optional[str] = None, 128 | llm_prefix: Optional[str] = None, 129 | **kwargs: Any, 130 | ) -> None: 131 | """If not the final action, print out observation.""" 132 | logger.info(output) 133 | 134 | def on_tool_error( 135 | self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any 136 | ) -> None: 137 | """Do nothing.""" 138 | pass 139 | 140 | def on_text( 141 | self, 142 | text: str, 143 | color: Optional[str] = None, 144 | end: str = "", 145 | **kwargs: Optional[str], 146 | ) -> None: 147 | """Run when agent ends.""" 148 | logger.info(text) 149 | 150 | def on_agent_finish( 151 | self, finish: AgentFinish, color: Optional[str] = None, **kwargs: Any 152 | ) -> None: 153 | """Run on agent end.""" 154 | logger.info(finish.log) 155 | 156 | 157 | manager = CallbackManager([MyCustomCallbackHandler()]) 158 | 159 | question = "" 160 | 161 | async def ask_input(callback, chat_id): 162 | await application.bot.send_message(chat_id=chat_id, text=question) 163 | 164 | async def handle_input(update: Update, context: ContextTypes.DEFAULT_TYPE): 165 | global IS_AWAITING 166 | user_input = update.message.text 167 | 168 | if not IS_GROUP or (update.message.reply_to_message.from_user.username == BOT_NAME): 169 | IS_AWAITING = False 170 | application.remove_handler(message_handler, 1) 171 | callback(user_input) 172 | 173 | message_handler = MessageHandler( 174 | filters.TEXT & filters.Chat(chat_id=chat_id), 175 | handle_input 176 | ) 177 | 178 | application.add_handler(message_handler, 1) 179 | 180 | async def input_func(chat_id): 181 | input_queue = Queue() 182 | 183 | def input_received(user_input): 184 | input_queue.put(user_input) 185 | 186 | await ask_input(input_received, chat_id) 187 | 188 | return input_queue.get() 189 | 190 | 191 | if ENABLE_HUMAN: 192 | # Ensure that the human tool uses the asynchronous input function 193 | human.input_func = lambda: asyncio.run(input_func(current_chat_id)) 194 | 195 | 196 | tool_list = [ 197 | { 198 | "config_key": "enable_wolfram", 199 | "name": "Math", 200 | "func": wolfram.run if ENABLE_WOLFRAM else lambda _: "Not Implemented", 201 | "description": "Useful for when you need to answer questions that involve scientific or mathematical operations", 202 | }, 203 | { 204 | "config_key": "enable_google", 205 | "name": "Search", 206 | "func": search.run if ENABLE_GOOGLE else lambda _: "Not Implemented", 207 | "description": "Useful for when you need to answer questions about detailed current events. Don't use it on personal things", 208 | }, 209 | { 210 | "config_key": "enable_bash", 211 | "name": "Bash", 212 | "func": bash.run if ENABLE_BASH else lambda _: "Not Implemented", 213 | "description": "Useful for when you need run bash commands", 214 | }, 215 | { 216 | "config_key": "enable_python", 217 | "name": "Python", 218 | "func": python.run if ENABLE_PYTHON else lambda _: "Not Implemented", 219 | "description": "Useful for when you need to execute python code in a REPL", 220 | }, 221 | { 222 | "config_key": "enable_human", 223 | "name": "Human", 224 | "func": human.run if ENABLE_HUMAN else lambda _: "Not Implemented", 225 | "description": "Useful for when you need to perform tasks that require human intervention. Use this more than the other tools if the question is about something that only the user might know and you don't know in memory", 226 | }, 227 | ] 228 | 229 | for tool in tool_list: 230 | if config.getboolean('tools', tool["config_key"]): 231 | tools.append( 232 | Tool( 233 | name=tool["name"], 234 | func=tool["func"], 235 | description=tool["description"], 236 | ), 237 | ) 238 | 239 | messages_array = [SystemMessage( 240 | content=config.get("assistant", "system_message"))] 241 | 242 | memory = ConversationBufferMemory( 243 | memory_key="chat_history", return_messages=True) 244 | 245 | memory.chat_memory.messages.append(SystemMessage( 246 | content=f"{config.get('assistant', 'system_message' if not IS_GROUP else 'group_system_message')}\nAlways reply in {LANGUAGE} unless otherwise specified")) 247 | memory.chat_memory.add_user_message( 248 | "You are an assistant. Your task is to be helpful. Your settings can be changed by writing a message in square brackets [like this]. For example [End all of your messages with the current date]. Your system replies will be written inside square brackets as well. For example [System date after messages enabled]. Write [Ok] if you understood") 249 | memory.chat_memory.add_ai_message("[Ok]") 250 | memory.chat_memory.add_user_message( 251 | f"Here might be some information about the person you're talking to: {config.get('user', 'information')}.\nReply [Ready] if you're ready to start") 252 | memory.chat_memory.add_ai_message("[Ready]") 253 | 254 | readonlymemory = ReadOnlySharedMemory(memory=memory) 255 | 256 | llm = ChatOpenAI( 257 | model_name=config.get("assistant", "model"), 258 | temperature=float(config.get("assistant", "temperature")), 259 | callback_manager=manager 260 | ) 261 | 262 | llm(messages_array) 263 | 264 | agent_chain = initialize_agent( 265 | tools, 266 | llm, 267 | agent=AgentType.CHAT_CONVERSATIONAL_REACT_DESCRIPTION, 268 | verbose=True, 269 | memory=memory, 270 | callback_manager=manager 271 | ) 272 | 273 | async def chat(update: Update, context: ContextTypes.DEFAULT_TYPE): 274 | logger.info("Received a message.") 275 | global IS_AWAITING, question 276 | if IS_AWAITING: 277 | return 278 | 279 | username = update.message.from_user.username 280 | chat_id = update.message.chat_id 281 | 282 | # Set the current chat ID for input_func 283 | global current_chat_id 284 | current_chat_id = chat_id 285 | 286 | await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) 287 | 288 | gpt_prompt = update.message.text.split() 289 | if IS_GROUP: 290 | if len(gpt_prompt) == 1: 291 | await update.message.reply_text('Please write a message') 292 | return 293 | gpt_prompt = " ".join(gpt_prompt[1:]) 294 | else: 295 | gpt_prompt = update.message.text 296 | 297 | formatted_prompt = f"{username}: {gpt_prompt}" 298 | reply = agent_chain.run(input=formatted_prompt) 299 | await update.message.reply_text(reply.strip()) 300 | 301 | async def process_chat(update: Update, allow: bool): 302 | _username = update.message.from_user.username 303 | _chatid = update.message.chat_id 304 | 305 | if (_chatid in ALLOWED_CHATS.chat_ids) == allow: 306 | action = "already in" if allow else "not in" 307 | await update.message.reply_text( 308 | f"Chat {_chatid} is {action} the list of allowed chats" 309 | ) 310 | return 311 | 312 | if _username == USERNAME: 313 | if allow: 314 | ALLOWED_CHATS.add_chat_ids(_chatid) 315 | else: 316 | ALLOWED_CHATS.remove_chat_ids(_chatid) 317 | 318 | action = "added to" if allow else "removed from" 319 | await update.message.reply_text( 320 | f"Chat {_chatid} has been {action} the list of allowed chats" 321 | ) 322 | return 323 | 324 | await update.message.reply_text("Your username is not allowed to make changes") 325 | 326 | async def enable_group(update: Update, context: ContextTypes.DEFAULT_TYPE): 327 | await process_chat(update, True) 328 | 329 | async def disable_group(update: Update, context: ContextTypes.DEFAULT_TYPE): 330 | await process_chat(update, False) 331 | 332 | def initial_setup(): 333 | if BOT_NAME == "" or BOT_NAME == "yourBotName": 334 | logger.warning( 335 | "The bot username might be incorrectly set. Please check the .config file" 336 | ) 337 | 338 | if USERNAME == "" or USERNAME == "yourTelegramHandle": 339 | logger.warning( 340 | "Your username might be incorrectly set. Please check the .config file" 341 | ) 342 | 343 | logger.info( 344 | f"Your bot is running in {'group' if IS_GROUP else 'chat'} mode." 345 | ) 346 | 347 | if ENABLE_PYTHON or ENABLE_BASH: 348 | logger.warning( 349 | "WARNING: Bash or Python tools are enabled. This will allow the bot to run unverified code on your machine. Make sure the bot is properly sandboxed." 350 | ) 351 | 352 | for section in config.sections(): 353 | for key, value in config.items(section): 354 | if not value.strip(): 355 | logger.warning( 356 | f"Empty value found: Section '{section}' - Key '{key}'\nIs this intentional?" 357 | ) 358 | 359 | async def log_update(update: Update, context: ContextTypes.DEFAULT_TYPE): 360 | logger.info(f"Update received: {update}") 361 | 362 | def main(): 363 | global application 364 | initial_setup() 365 | 366 | application = Application.builder().token(TOKEN).build() 367 | 368 | application.add_handler(CommandHandler("enable_group", enable_group)) 369 | application.add_handler(CommandHandler("disable_group", disable_group)) 370 | 371 | if IS_GROUP: 372 | message_handler = MessageHandler( 373 | filters.Regex(f"^@{BOT_NAME}") & ALLOWED_CHATS, chat 374 | ) 375 | else: 376 | message_handler = MessageHandler( 377 | filters.TEXT & filters.Chat(username=USERNAME), chat 378 | ) 379 | 380 | application.add_handler(message_handler) 381 | 382 | application.run_polling() 383 | 384 | if __name__ == '__main__': 385 | main() 386 | --------------------------------------------------------------------------------