Initial commit

This commit is contained in:
Nikita Tyukalov, ASUS, Linux
2026-07-19 22:02:06 +03:00
commit 2bc979c9b4
9 changed files with 811 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
__pycache__
venv
*.swp
*.swo
*.json
config.py

14
README.md Normal file
View File

@@ -0,0 +1,14 @@
```bash
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp config.py.proto config.py
```
Отредактируйте config.py
```bash
python main.py
```
Авторизуйтесь

150
ai.py Normal file
View File

@@ -0,0 +1,150 @@
"""This module interfaces with OpenWebUI using OpenAI-compatible /chat/completions API"""
import asyncio
import json
import traceback
import os
import importlib.util
import inspect
import sys
import re
from typing import Callable
from dataclasses import dataclass
from enum import Enum
from ollama import AsyncClient
import config
class Role(Enum):
SYSTEM = "system"
USER = "user"
ASSISTANT = "assistant"
@dataclass
class Message:
role: Role
content: str
#
# Data
#
_tools = None
#
# Private
#
def _load_tools() -> list[Callable]:
"""Returns a list of all callables in tools.*"""
tools = []
tools_path = os.path.abspath("./tools/")
for tool_path in os.listdir(tools_path):
if not tool_path.endswith(".py"):
continue
module_name = tool_path[:-3]
file_path = os.path.join(tools_path, tool_path)
try:
spec = importlib.util.spec_from_file_location(module_name, file_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
for name, obj in inspect.getmembers(module):
if inspect.isfunction(obj) and obj.__module__ == module.__name__ and not name.startswith("_"):
tools.append(obj)
except:
traceback.print_exc()
continue
return tools
#
# Public
#
async def chat(model: str, messages: list[Message], tools_whitelist: list[str] = [], tools_blacklist: list[str] = [], **kwargs) -> str | None:
"""Chat with ollama"""
# load tools if they are not loaded
global _tools
if _tools is None:
_tools = _load_tools()
try:
client = AsyncClient(
host=config.OLLAMA_URL,
headers=config.OLLAMA_HEADERS
)
# get tools list allowed for this chat
allowed_tools = []
# add all whitelisted tools (if whitelist is enabled)
if tools_whitelist:
for tool in _tools:
for pattern in tools_whitelist:
if re.match(pattern, tool.__name__):
print(f"[I] Allowing tool {tool.__name__} (matched by `{pattern}`)")
allowed_tools.append(tool)
break
# add all tools if whitelist is missing
else:
allowed_tools = list(_tools)
# remove blacklisted tools if blacklist is present
if tools_blacklist:
for tool in list(allowed_tools):
for pattern in tools_blacklist:
if not pattern:
continue
if re.match(pattern, tool.__name__):
print(f"[I] Removing tool {tool.__name__} (matched by `{pattern}`)")
allowed_tools.remove(tool)
break
# convert messages to valid format
messages = list({"role": m.role.value, "content": m.content} for m in messages)
# execute until done
while True:
response = await client.chat(
model=model,
messages=messages,
tools=allowed_tools,
**kwargs
)
if not response.message.tool_calls:
return response.message.content
messages.append(response.message)
calls = response.message.tool_calls
for call in calls:
tool_name = call.function.name
try:
print(f"[I] Calling tool `{tool_name}`...")
args = call.function.arguments
for a in args:
print(f"{a} = {args[a]}")
for t in allowed_tools:
if t.__name__ != tool_name:
continue
if inspect.iscoroutinefunction(t):
result = await t(**args)
else:
result = t(**args)
messages.append({
"role": "tool",
"tool_name": tool_name,
"content": f"{result}"
})
break
else:
print(f"[!] Tool `{tool_name}` is not found")
messages.append({
"role": "tool",
"tool_name": tool_name,
"content": f"Tool `{tool_name}` does not exist"
})
except Exception as e:
traceback.print_exc()
messages.append({
"role": "tool",
"tool_name": tool_name,
"content": f"Exception occured: {e}"
})
return response.message.content
except:
traceback.print_exc()
return None

318
bot.py Normal file
View File

@@ -0,0 +1,318 @@
"""This module implements matrix bot"""
import asyncio
import json
import traceback
import time
import html
from datetime import datetime
from nio import ClientConfig, AsyncClient
from nio import LoginResponse, SyncResponse, RoomMessagesResponse
from nio import MatrixRoom, RoomMessageText
from nio import RoomGetStateEventResponse, RoomSendResponse
import config
import ai
#
# Data
#
_client: AsyncClient = None
_client_task: asyncio.Task = None
_next_batch: str = None
_message_tasks = set()
#
# Helpers
#
def _unix_time_to_readable_date(timestamp: int | float) -> str:
"""Convert UNIX time to readable date"""
dt = datetime.fromtimestamp(timestamp)
return dt.strftime("%d.%m.%Y, %H:%M")
async def _matrix_get_room_topic(room_id: str) -> str | None:
"""Returns room topic as string"""
try:
response = await _client.room_get_state_event(room_id, "m.room.topic")
if not isinstance(response, RoomGetStateEventResponse):
print("[!] Failed to get room topic: {response}")
return None
return response.content["m.topic"]["m.text"][0]["body"].replace("<br>", "\n").replace("</br>", "\n")
except:
return None
async def _matrix_send_text_to_room(room_id: str, text: str) -> str | None:
try:
response = await _client.room_send(
room_id=room_id,
message_type="m.room.message",
content={
"msgtype": "m.text",
"body": str(text)
}
)
if not isinstance(response, RoomSendResponse):
return None
return response.event_id
except:
traceback.print_exc()
return None
async def _matrix_get_last_messages(room_id: str, n: int = 200) -> list[tuple[str, str]]:
response = await _client.room_messages(
room_id=room_id,
start=_next_batch,
direction="b",
limit=n
)
if not isinstance(response, RoomMessagesResponse):
return []
messages = []
for e in response.chunk:
if not isinstance(e, RoomMessageText):
continue
if not e.body:
continue
if e.body.startswith("IGNORE"):
continue
messages.append((e.sender, e.body, _unix_time_to_readable_date(e.server_timestamp / 1000)))
return list(reversed(messages))
#
# Private
#
def _load_next_batch() -> str | None:
"""Read state from disk"""
try:
with open(config.MATRIX_STATE_JSON, "r") as f:
j = json.load(f)
if j["access_token"] != _client.access_token:
raise RuntimeError()
return j["next_batch"]
except:
return None
def _dump_next_batch(next_batch: str):
try:
j = {
"access_token": _client.access_token,
"next_batch": next_batch
}
with open(config.MATRIX_STATE_JSON, "w") as f:
json.dump(j, f)
return True
except:
return False
def _get_login_data() -> dict:
"""Returns data to use during login. This function decides whether to login or use existing access token."""
try:
with open(config.MATRIX_SESSION_JSON, "r") as f:
existing_data = json.load(f)
# check if required fields are present
if not isinstance(existing_data["homeserver"], str):
raise RuntimeError()
if not isinstance(existing_data["username"], str):
raise RuntimeError()
if not isinstance(existing_data["device_id"], str):
raise RuntimeError()
if not isinstance(existing_data["access_token"], str):
raise RuntimeError()
# check if data has changed
if existing_data["homeserver"] != config.MATRIX_HOMESERVER:
raise RuntimeError()
if existing_data["username"] != config.MATRIX_USERNAME:
raise RuntimeError()
return existing_data
except:
traceback.print_exc()
return {
"homeserver": config.MATRIX_HOMESERVER,
"username": config.MATRIX_USERNAME
}
def _write_login_data(login_response: LoginResponse) -> bool:
"""Writes login data to disk. It will be used on the next start."""
try:
j = {
"homeserver": config.MATRIX_HOMESERVER,
"username": login_response.user_id,
"device_id": login_response.device_id,
"access_token": login_response.access_token
}
with open(config.MATRIX_SESSION_JSON, "w") as f:
json.dump(j, f, indent=4)
return True
except:
traceback.print_exc()
return False
async def _on_message_handle(room_id: str, model: str, messages: list, whitelist: list[str], blacklist: list[str]):
temp_id = await _matrix_send_text_to_room(room_id, "IGNORE Calling ollama... (btw send `CUT` to reset chat memory)")
try:
ollama_response = await ai.chat(
model,
messages,
whitelist,
blacklist
)
await _client.room_redact(
room_id=room_id,
event_id=temp_id
)
await _matrix_send_text_to_room(room_id, str(ollama_response))
except Exception as e:
traceback.print_exc()
await _matrix_send_text_to_room(room_id, f"IGNORE Failed to call ollama: {e}")
async def _on_message(room: MatrixRoom, event: RoomMessageText) -> None:
"""This event is called when there is a new message."""
# ignore message by ourselves
if event.sender == config.MATRIX_USERNAME:
return
# cut
if event.body.lower() == "cut":
await _matrix_send_text_to_room(
room.room_id,
"IGNORE Chat memory is erased"
)
return
room_topic = await _matrix_get_room_topic(room.room_id)
if not room_topic:
await _matrix_send_text_to_room(
room.room_id,
"Missing room topic"
)
return
# check if required fields are present
try:
j = json.loads(room_topic)
if "ollama" not in j:
raise RuntimeError("Missing `root[\"ollama\"]`")
if "model" not in j["ollama"]:
raise RuntimeError("Missing `root[\"ollama\"][\"model\"]`")
if "tools" not in j:
raise RuntimeError("Missing `root[\"tools\"]`")
if "whitelist" not in j["tools"]:
raise RuntimeError("Missing `root[\"tools\"][\"whitelist\"]`")
if "blacklist" not in j["tools"]:
raise RuntimeError("Missing `root[\"tools\"][\"blacklist\"]`")
if "system" not in j:
raise RuntimeError("Missing `root[\"system\"]`")
except Exception as e:
traceback.print_exc()
await _matrix_send_text_to_room(
room.room_id,
f"Exception has occured: {e}"
)
return
# last messages
last_messages = (await _matrix_get_last_messages(room.room_id))[-20:]
for i in range(len(last_messages) - 1, -1, -1):
if last_messages[i][1].lower() == "cut":
last_messages = last_messages[i+1:]
break
# message text with timestamp
timestamp = _unix_time_to_readable_date(event.server_timestamp / 1000)
message_text = f"[Сообщение от {timestamp}]\n\n{event.body}"
# build messages history for ollama
messages = []
messages.append(ai.Message(ai.Role.SYSTEM, j["system"]))
for m in last_messages:
role = ai.Role.SYSTEM if m[0] == config.MATRIX_USERNAME else ai.Role.USER
body = f"[Сообщение от {m[2]}]\n\n{m[1]}"
messages.append(ai.Message(role, body))
messages.append(ai.Message(ai.Role.USER, message_text))
# handle in background
t = asyncio.create_task(_on_message_handle(
room.room_id,
j["ollama"]["model"],
messages,
j["tools"]["whitelist"],
j["tools"]["blacklist"]
))
_message_tasks.add(t)
t.add_done_callback(_message_tasks.discard)
async def _sync_loop() -> None:
"""This loop syncs forever."""
global _next_batch
last_save_time = 0
while True:
try:
response = await _client.sync(timeout=30000, since=_next_batch)
if isinstance(response, SyncResponse):
_next_batch = response.next_batch
d = time.time() - last_save_time
if d >= config.MATRIX_NEXT_BATCH_DUMP_PERIOD:
_dump_next_batch(_next_batch)
last_save_time = time.time()
else:
print(f"[!] Sync error: {response}")
await asyncio.sleep(5)
except asyncio.CancelledError:
raise
except:
traceback.print_exc()
await asyncio.sleep(1)
#
# Public
#
async def start() -> bool:
"""Start the bot using data in config.py or stored data"""
global _client, _client_task, _next_batch
# do not continue if the client already exists
if _client is not None:
return False
# get data to use for login
login_data = _get_login_data()
if "access_token" not in login_data:
print(f"[I] Authenticating as {login_data['username']}...")
login_data["password"] = input(" Password: ")
# create the client
_client = AsyncClient(
config.MATRIX_HOMESERVER,
config.MATRIX_USERNAME
)
# add callbacks
_client.add_event_callback(_on_message, RoomMessageText)
# authenticate
if "password" in login_data:
login_response = await _client.login(login_data["password"])
if not isinstance(login_response, LoginResponse):
print(f"[!] Failed to authenticate: {login_response}")
_client = None
return False
_write_login_data(login_response)
print("[I] Authenticated using password")
# already authenticated
else:
_client.access_token = login_data["access_token"]
_client.user_id = login_data["username"]
print("[I] Authenticated using access token")
# work
_next_batch = _load_next_batch()
_client_task = asyncio.create_task(_sync_loop())
return True
async def stop() -> bool:
"""Stop the bot"""
global _client, _client_task
if _client is None:
return False
try:
_client_task.cancel()
await _client_task
except:
pass
_client_task = None
try:
await _client.close()
except:
traceback.print_exc()
_dump_next_batch(_next_batch)
return True

15
config.py.proto Normal file
View File

@@ -0,0 +1,15 @@
"""Configuration for the application"""
MATRIX_HOMESERVER = "https://matrix.server.su"
MATRIX_USERNAME = "@bot-username:matrix.server.su"
MATRIX_SESSION_JSON = "session.json"
MATRIX_STATE_JSON = "state.json"
MATRIX_NEXT_BATCH_DUMP_PERIOD = 60
OLLAMA_URL = "https://ai.server.su/ollama"
OLLAMA_HEADERS = {
"Authorization": "Bearer sk-gdsfgsfdgsfd95623dchdfhdfhfd"
}
SHEETS_SERVICE_ACCOUNT_FILE = "sheets_service_account.json"
SHEETS_URL = "https://docs.google.com/spreadsheets/d/gdfgdsfgdsfkbdigdsfgsfdgfsdgdfsgdsfgdsfzMA/edit"
SHEETS_CONFIG_SHEET_NAME = "AI"

27
main.py Normal file
View File

@@ -0,0 +1,27 @@
import asyncio
import os
import traceback
import signal
from typing import Callable
import bot
import config
import ai
from ai import Message, Role
async def main() -> None:
"""Application entry point"""
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
def shutdown_handler():
stop_event.set()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, shutdown_handler)
# get available tools
await bot.start()
await stop_event.wait()
await bot.stop()
if __name__ == "__main__":
asyncio.run(main())

48
requirements.txt Normal file
View File

@@ -0,0 +1,48 @@
aiofiles==24.1.0
aiohappyeyeballs==2.7.1
aiohttp==3.14.1
aiohttp_socks==0.11.0
aiosignal==1.4.0
annotated-types==0.7.0
anyio==4.14.2
attrs==26.1.0
certifi==2026.6.17
cffi==2.1.0
charset-normalizer==3.4.9
cryptography==49.0.0
frozenlist==1.8.0
google-auth==2.56.0
google-auth-oauthlib==1.4.0
gspread==6.0.2
gspread_asyncio==2.0.0
h11==0.16.0
h2==4.3.0
hpack==4.2.0
httpcore==1.0.9
httpx==0.28.1
hyperframe==6.1.0
idna==3.18
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
matrix-nio==0.25.2
multidict==6.7.1
oauthlib==3.3.1
ollama==0.6.2
propcache==0.5.2
pyasn1==0.6.4
pyasn1_modules==0.4.2
pycparser==3.0
pycryptodome==3.23.0
pydantic==2.13.4
pydantic_core==2.46.4
python-socks==2.8.2
referencing==0.37.0
requests==2.34.2
requests-oauthlib==2.0.0
rpds-py==2026.6.3
StrEnum==0.4.15
typing-inspection==0.4.2
typing_extensions==4.16.0
unpaddedbase64==2.1.0
urllib3==2.7.0
yarl==1.24.2

25
tools/builtin.py Normal file
View File

@@ -0,0 +1,25 @@
"""
This module implements some builtin tools.
"""
import asyncio
import sys
from typing import Literal
async def builtin_network_ping(host: str) -> str:
"""Ping `host` to check if it is reachable."""
try:
param = '-n' if sys.platform.lower() == 'windows' else '-c'
cmd = ['ping', param, '1', host]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
return f"{host} is reachable"
else:
return f"{host} is unreachable"
except Exception as e:
return f"Exception happened: {e}"

208
tools/slavery.py Normal file
View File

@@ -0,0 +1,208 @@
"""
This module implements work hours keeping.
"""
import asyncio
import sys
import random
import time
from datetime import datetime
import gspread_asyncio
from google.oauth2.service_account import Credentials
import config
#
# Internal functions
#
def _unix_time_to_readable_date(timestamp: int | float) -> str:
"""Convert UNIX time to readable date"""
dt = datetime.fromtimestamp(timestamp)
return dt.strftime("%d.%m.%Y, %H:%M")
def _g_get_creds():
creds = Credentials.from_service_account_file(config.SHEETS_SERVICE_ACCOUNT_FILE)
scoped = creds.with_scopes([
"https://www.googleapis.com/auth/spreadsheets",
])
return scoped
_g_manager = gspread_asyncio.AsyncioGspreadClientManager(_g_get_creds)
async def _g_get_ai_config() -> dict:
client = await _g_manager.authorize()
spreadsheet = await client.open_by_url(config.SHEETS_URL)
worksheet = await spreadsheet.worksheet(config.SHEETS_CONFIG_SHEET_NAME)
values = await worksheet.batch_get(["A1:B20"])
res = {}
for pair in values[0]:
res[pair[0]] = pair[1]
return res
async def _g_set_ai_config(conf: dict):
client = await _g_manager.authorize()
spreadsheet = await client.open_by_url(config.SHEETS_URL)
worksheet = await spreadsheet.worksheet(config.SHEETS_CONFIG_SHEET_NAME)
values = []
for c in conf:
values.append([c, conf[c]])
if len(values) < 20:
values += [None, None] * (20 - len(values))
await worksheet.batch_update([{
"range": "A1:B20",
"values": values
}])
#
# Tools for ollama
#
async def slavery_create_entry() -> str:
"""Create new `entry` in the database.
Returns:
Human-readable result of the operation.
"""
# get the AI config
ai_config = await _g_get_ai_config()
# allocate entry ID
new_entry_id = ai_config["entry_free_id"]
# increment free ID
ai_config["entry_free_id"] = str(int(ai_config["entry_free_id"]) + 1)
await _g_set_ai_config(ai_config)
# return the ID
return f"Unique ID of new entry is `{new_entry_id}`. You MUST set its properties using other tools!"
async def slavery_get_entry_data(entry_id: str) -> str:
"""Get information about the entry.
Args:
entry_id: Unique ID of the entry.
Returns:
Human-readable text containing all entry information (or error explanation).
"""
# try to get entry_id as number
try:
entry_id = int(entry_id)
except:
return f"`entry_id` must be an integer, `{entry_id}` is invalid value"
# get the AI config
ai_config = await _g_get_ai_config()
min_entry_id = int(ai_config["entry_first_id"])
max_entry_id = int(ai_config["entry_free_id"]) - 1
if entry_id < min_entry_id:
return f"`entry_id` of `{entry_id}` is invalid"
if entry_id > max_entry_id:
return f"`entry_id` of `{entry_id}` is invalid"
# get description
client = await _g_manager.authorize()
spreadsheet = await client.open_by_url(config.SHEETS_URL)
worksheet = await spreadsheet.worksheet(ai_config["hours_sheet_name"])
cells = [
ai_config["entry_start_col"] + str(entry_id),
ai_config["entry_end_col"] + str(entry_id),
ai_config["entry_description_col"] + str(entry_id)
]
response = await worksheet.batch_get(cells)
response = [(c if c else ["(not set)"]) for c in response]
response_text = f"Entry #`{entry_id}`"
response_text += f"\nStart: {response[0][0]}"
response_text += f"\nEnd: {response[1][0]}"
response_text += f"\n--- remaining text is description ---\n\n"
response_text += f"{response[2][0]}"
return response_text
async def slavery_set_entry_start(entry_id: str, date_and_time: str) -> str:
"""Set entry `start` date and time.
Args:
entry_id: Unique ID of the entry.
date_and_time: Date and time in format `%d.%m.%Y, %H:%M` (format is VERY important)
Returns:
Human-readable result of the operation.
"""
# try to get entry_id as number
try:
entry_id = int(entry_id)
except:
return f"`entry_id` must be an integer, `{entry_id}` is invalid value"
# get the AI config
ai_config = await _g_get_ai_config()
min_entry_id = int(ai_config["entry_first_id"])
max_entry_id = int(ai_config["entry_free_id"]) - 1
if entry_id < min_entry_id:
return f"`entry_id` of `{entry_id}` is invalid"
if entry_id > max_entry_id:
return f"`entry_id` of `{entry_id}` is invalid"
# set entry start
client = await _g_manager.authorize()
spreadsheet = await client.open_by_url(config.SHEETS_URL)
worksheet = await spreadsheet.worksheet(ai_config["hours_sheet_name"])
cell_id = ai_config["entry_start_col"] + str(entry_id)
await worksheet.update([[date_and_time]], cell_id, raw=False)
return f"Successfully set `start` datetime to {date_and_time} for entry #{entry_id}"
async def slavery_set_entry_end(entry_id: str, date_and_time: str) -> str:
"""Set entry `end` date and time.
Args:
entry_id: Unique ID of the entry.
date_and_time: Date and time in format `%d.%m.%Y, %H:%M` (format is VERY important)
Returns:
Human-readable result of the operation.
"""
# try to get entry_id as number
try:
entry_id = int(entry_id)
except:
return f"`entry_id` must be an integer, `{entry_id}` is invalid value"
# get the AI config
ai_config = await _g_get_ai_config()
min_entry_id = int(ai_config["entry_first_id"])
max_entry_id = int(ai_config["entry_free_id"]) - 1
if entry_id < min_entry_id:
return f"`entry_id` of `{entry_id}` is invalid"
if entry_id > max_entry_id:
return f"`entry_id` of `{entry_id}` is invalid"
# set entry start
client = await _g_manager.authorize()
spreadsheet = await client.open_by_url(config.SHEETS_URL)
worksheet = await spreadsheet.worksheet(ai_config["hours_sheet_name"])
cell_id = ai_config["entry_end_col"] + str(entry_id)
await worksheet.update([[date_and_time]], cell_id, raw=False)
return f"Successfully set `end` datetime to {date_and_time} for entry #{entry_id}"
async def slavery_set_entry_description(entry_id: str, description: str) -> str:
"""Set entry description.
Args:
entry_id: Unique ID of the entry.
description: Entry description in Russian language
Returns:
Human-readable result of the operation.
"""
# try to get entry_id as number
try:
entry_id = int(entry_id)
except:
return f"`entry_id` must be an integer, `{entry_id}` is invalid value"
# get the AI config
ai_config = await _g_get_ai_config()
min_entry_id = int(ai_config["entry_first_id"])
max_entry_id = int(ai_config["entry_free_id"]) - 1
if entry_id < min_entry_id:
return f"`entry_id` of `{entry_id}` is invalid"
if entry_id > max_entry_id:
return f"`entry_id` of `{entry_id}` is invalid"
# set entry start
client = await _g_manager.authorize()
spreadsheet = await client.open_by_url(config.SHEETS_URL)
worksheet = await spreadsheet.worksheet(ai_config["hours_sheet_name"])
cell_id = ai_config["entry_description_col"] + str(entry_id)
await worksheet.update([[description]], cell_id, raw=True)
return f"Successfully updated description of the `{entry_id}`"