Initial commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__
|
||||
venv
|
||||
*.swp
|
||||
*.swo
|
||||
*.json
|
||||
config.py
|
||||
14
README.md
Normal file
14
README.md
Normal 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
150
ai.py
Normal 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
318
bot.py
Normal 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
15
config.py.proto
Normal 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
27
main.py
Normal 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
48
requirements.txt
Normal 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
25
tools/builtin.py
Normal 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
208
tools/slavery.py
Normal 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}`"
|
||||
Reference in New Issue
Block a user