├── .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 | 
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 |
--------------------------------------------------------------------------------