youclaw 4.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- youclaw/__init__.py +24 -0
- youclaw/bot.py +185 -0
- youclaw/cli.py +469 -0
- youclaw/commands.py +151 -0
- youclaw/config.py +170 -0
- youclaw/core_skills.py +210 -0
- youclaw/dashboard.py +1347 -0
- youclaw/discord_handler.py +187 -0
- youclaw/env_manager.py +61 -0
- youclaw/main.py +273 -0
- youclaw/memory_manager.py +440 -0
- youclaw/ollama_client.py +486 -0
- youclaw/personality_manager.py +42 -0
- youclaw/scheduler_manager.py +226 -0
- youclaw/search_client.py +66 -0
- youclaw/skills_manager.py +127 -0
- youclaw/telegram_handler.py +181 -0
- youclaw/vector_manager.py +94 -0
- youclaw-4.6.0.dist-info/LICENSE +21 -0
- youclaw-4.6.0.dist-info/METADATA +128 -0
- youclaw-4.6.0.dist-info/RECORD +24 -0
- youclaw-4.6.0.dist-info/WHEEL +5 -0
- youclaw-4.6.0.dist-info/entry_points.txt +2 -0
- youclaw-4.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YouClaw Scheduler Manager
|
|
3
|
+
Handles background tasks, heartbeats, and proactive notifications.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
8
|
+
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
|
9
|
+
from typing import Dict, Any, Callable, Optional
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
class SchedulerManager:
|
|
16
|
+
"""Manages proactive bot tasks and cron jobs"""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.scheduler = AsyncIOScheduler()
|
|
20
|
+
self.bot_instance = None
|
|
21
|
+
|
|
22
|
+
def initialize(self, bot_instance, db_path: str):
|
|
23
|
+
"""Initialize the scheduler with persistence"""
|
|
24
|
+
self.bot_instance = bot_instance
|
|
25
|
+
|
|
26
|
+
# Configure job store
|
|
27
|
+
job_stores = {
|
|
28
|
+
'default': SQLAlchemyJobStore(url=f'sqlite:///{db_path}')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
self.scheduler.configure(jobstores=job_stores)
|
|
32
|
+
self.scheduler.start()
|
|
33
|
+
logger.info("Scheduler initialized and started")
|
|
34
|
+
|
|
35
|
+
async def add_notification_job(
|
|
36
|
+
self,
|
|
37
|
+
platform: str,
|
|
38
|
+
user_id: str,
|
|
39
|
+
message: str,
|
|
40
|
+
run_date: datetime,
|
|
41
|
+
job_id: Optional[str] = None
|
|
42
|
+
):
|
|
43
|
+
"""Schedule a one-time notification message"""
|
|
44
|
+
self.scheduler.add_job(
|
|
45
|
+
self.send_notification,
|
|
46
|
+
'date',
|
|
47
|
+
run_date=run_date,
|
|
48
|
+
args=[platform, user_id, message],
|
|
49
|
+
id=job_id,
|
|
50
|
+
replace_existing=True
|
|
51
|
+
)
|
|
52
|
+
logger.info(f"Scheduled notification for {user_id} on {platform} at {run_date}")
|
|
53
|
+
|
|
54
|
+
async def add_cron_job(
|
|
55
|
+
self,
|
|
56
|
+
platform: str,
|
|
57
|
+
user_id: str,
|
|
58
|
+
message_func: Callable,
|
|
59
|
+
cron_expression: str,
|
|
60
|
+
job_id: str
|
|
61
|
+
):
|
|
62
|
+
"""Schedule a recurring tasks using cron expression"""
|
|
63
|
+
# message_func should be an async function that returns a string to send
|
|
64
|
+
self.scheduler.add_job(
|
|
65
|
+
self.run_cron_task,
|
|
66
|
+
'cron',
|
|
67
|
+
args=[platform, user_id, message_func],
|
|
68
|
+
id=job_id,
|
|
69
|
+
replace_existing=True,
|
|
70
|
+
**self._parse_cron(cron_expression)
|
|
71
|
+
)
|
|
72
|
+
logger.info(f"Scheduled cron job {job_id} for {user_id}")
|
|
73
|
+
|
|
74
|
+
def _parse_cron(self, expr: str) -> Dict:
|
|
75
|
+
"""Simple parser for cron expressions (placeholder for robust parser)"""
|
|
76
|
+
# For now, expect a simple format or use default
|
|
77
|
+
# Format: "minute hour day month day_of_week"
|
|
78
|
+
parts = expr.split()
|
|
79
|
+
if len(parts) == 5:
|
|
80
|
+
return {
|
|
81
|
+
'minute': parts[0],
|
|
82
|
+
'hour': parts[1],
|
|
83
|
+
'day': parts[2],
|
|
84
|
+
'month': parts[3],
|
|
85
|
+
'day_of_week': parts[4]
|
|
86
|
+
}
|
|
87
|
+
return {'hour': 8} # Default 8 AM daily
|
|
88
|
+
|
|
89
|
+
async def send_notification(self, platform: str, user_id: str, message: str):
|
|
90
|
+
"""Actual delivery of the message via the bot handlers"""
|
|
91
|
+
await send_notification_task(platform, user_id, message)
|
|
92
|
+
|
|
93
|
+
async def add_ai_cron_job(self, platform: str, user_id: str, prompt: str, cron_expr: str, job_id: str):
|
|
94
|
+
"""Schedule a recurring AI reasoning task"""
|
|
95
|
+
self.scheduler.add_job(
|
|
96
|
+
run_ai_job_task,
|
|
97
|
+
'cron',
|
|
98
|
+
args=[platform, user_id, prompt],
|
|
99
|
+
id=job_id,
|
|
100
|
+
replace_existing=True,
|
|
101
|
+
**self._parse_cron(cron_expr)
|
|
102
|
+
)
|
|
103
|
+
logger.info(f"Scheduled AI Pulse job {job_id} for {user_id}: {prompt[:30]}...")
|
|
104
|
+
|
|
105
|
+
async def add_watcher_job(self, platform: str, user_id: str, target_url: str, interval_minutes: int, job_id: str):
|
|
106
|
+
"""Schedule a background URL monitoring task"""
|
|
107
|
+
self.scheduler.add_job(
|
|
108
|
+
run_watcher_task,
|
|
109
|
+
'interval',
|
|
110
|
+
minutes=interval_minutes,
|
|
111
|
+
args=[platform, user_id, target_url],
|
|
112
|
+
id=job_id,
|
|
113
|
+
replace_existing=True
|
|
114
|
+
)
|
|
115
|
+
logger.info(f"Scheduled Watcher job {job_id} for {user_id}: {target_url}")
|
|
116
|
+
|
|
117
|
+
# --- Top-Level Job Tasks (Moved outside class for serialization) ---
|
|
118
|
+
|
|
119
|
+
async def send_notification_task(platform: str, user_id: str, message: str):
|
|
120
|
+
"""Actual delivery of the message via the bot handlers or history"""
|
|
121
|
+
from .memory_manager import memory_manager
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
if platform == "telegram":
|
|
125
|
+
from .telegram_handler import telegram_handler
|
|
126
|
+
if telegram_handler.app:
|
|
127
|
+
await telegram_handler.app.bot.send_message(chat_id=user_id, text=message)
|
|
128
|
+
else:
|
|
129
|
+
logger.error("Telegram App not initialized")
|
|
130
|
+
elif platform == "discord":
|
|
131
|
+
from .discord_handler import discord_handler
|
|
132
|
+
if discord_handler.bot:
|
|
133
|
+
user = await discord_handler.bot.fetch_user(int(user_id))
|
|
134
|
+
if user:
|
|
135
|
+
await user.send(message)
|
|
136
|
+
else:
|
|
137
|
+
logger.error(f"Discord user {user_id} not found")
|
|
138
|
+
if platform == "dashboard" or True: # Always save to DB for history
|
|
139
|
+
await memory_manager.add_message(
|
|
140
|
+
platform=platform,
|
|
141
|
+
user_id=user_id,
|
|
142
|
+
role="assistant",
|
|
143
|
+
content=message,
|
|
144
|
+
metadata={"source": "scheduler"}
|
|
145
|
+
)
|
|
146
|
+
logger.info(f"Proactive message sent to {user_id} on {platform}")
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.error(f"Failed to send proactive message: {e}")
|
|
149
|
+
|
|
150
|
+
async def run_cron_task_worker(platform: str, user_id: str, task_func: Callable):
|
|
151
|
+
"""Run a recurring task and send its output"""
|
|
152
|
+
try:
|
|
153
|
+
message = await task_func()
|
|
154
|
+
if message:
|
|
155
|
+
await send_notification_task(platform, user_id, message)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.error(f"Error in cron task execution: {e}")
|
|
158
|
+
|
|
159
|
+
async def run_ai_job_task(platform: str, user_id: str, prompt: str):
|
|
160
|
+
"""Executes a full AI reasoning loop and sends the result to the user"""
|
|
161
|
+
from .memory_manager import memory_manager
|
|
162
|
+
from .ollama_client import ollama_client
|
|
163
|
+
from datetime import datetime
|
|
164
|
+
|
|
165
|
+
logger.info(f"Running AI Cron Job for {user_id} on {platform}: {prompt[:50]}")
|
|
166
|
+
|
|
167
|
+
# Fetch recent history to avoid repetition
|
|
168
|
+
history = await memory_manager.get_conversation_history(platform, user_id, limit=5)
|
|
169
|
+
|
|
170
|
+
# Build context for the AI
|
|
171
|
+
context = {
|
|
172
|
+
"platform": platform,
|
|
173
|
+
"user_id": user_id,
|
|
174
|
+
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# Enhance prompt with variation instructions
|
|
178
|
+
enhanced_prompt = (
|
|
179
|
+
f"USER REQUEST: {prompt}\n\n"
|
|
180
|
+
f"CRITICAL: Do not repeat yourself. If you see in the history that you already said something, "
|
|
181
|
+
f"try a completely different approach or topic. Be creative!\n"
|
|
182
|
+
f"Current system time: {context['current_time']}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
# Trigger full reasoning loop with history
|
|
187
|
+
response = await ollama_client.chat_with_tools(
|
|
188
|
+
messages=history + [{"role": "user", "content": enhanced_prompt}],
|
|
189
|
+
context=context
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if response:
|
|
193
|
+
logger.info(f"AI Cron Job for {user_id} produced response (length: {len(response)}). Sending...")
|
|
194
|
+
await send_notification_task(platform, user_id, f"### ⚡ **Cron Job Update**\n\n{response}")
|
|
195
|
+
logger.info(f"AI Cron Job for {user_id} delivered.")
|
|
196
|
+
else:
|
|
197
|
+
logger.warning(f"AI Cron Job for {user_id} produced empty response.")
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(f"Error in AI Cron Job execution: {e}", exc_info=True)
|
|
200
|
+
|
|
201
|
+
async def run_watcher_task(platform: str, user_id: str, target_url: str):
|
|
202
|
+
"""Monitors a URL and notifies the user of status changes"""
|
|
203
|
+
import aiohttp
|
|
204
|
+
|
|
205
|
+
logger.info(f"Running Watcher for {user_id} on {platform}: {target_url}")
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
async with aiohttp.ClientSession() as session:
|
|
209
|
+
async with session.get(target_url, timeout=10) as response:
|
|
210
|
+
status = response.status
|
|
211
|
+
if status != 200:
|
|
212
|
+
await send_notification_task(
|
|
213
|
+
platform,
|
|
214
|
+
user_id,
|
|
215
|
+
f"⚠️ **Watchdog Alert!**\n\nTarget `{target_url}` is reporting status: `{status}`."
|
|
216
|
+
)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.error(f"Watcher failed for {target_url}: {e}")
|
|
219
|
+
await send_notification_task(
|
|
220
|
+
platform,
|
|
221
|
+
user_id,
|
|
222
|
+
f"🚨 **Watchdog Failure!**\n\nI couldn't reach `{target_url}`. Error: `{str(e)}`"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Global scheduler instance
|
|
226
|
+
scheduler_manager = SchedulerManager()
|
youclaw/search_client.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import aiohttp
|
|
2
|
+
import logging
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import List, Dict, Optional
|
|
5
|
+
from bs4 import BeautifulSoup
|
|
6
|
+
from .config import config
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class SearchClient:
|
|
11
|
+
"""Client for performing searches via the self-hosted SearXNG engine"""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.headers = {
|
|
15
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async def search(self, query: str) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Perform a search on SearXNG and return a synthesized summary of findings.
|
|
21
|
+
"""
|
|
22
|
+
url = config.search_url
|
|
23
|
+
logger.info(f"🛰️ Neural Search Initiated on {url}: {query}")
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
async with aiohttp.ClientSession(headers=self.headers) as session:
|
|
27
|
+
async with session.get(url, params={"q": query}) as response:
|
|
28
|
+
if response.status != 200:
|
|
29
|
+
logger.error(f"Search engine returned status {response.status}")
|
|
30
|
+
return f"Search engine offline (Status {response.status})"
|
|
31
|
+
|
|
32
|
+
html = await response.text()
|
|
33
|
+
soup = BeautifulSoup(html, 'html.parser')
|
|
34
|
+
|
|
35
|
+
results = []
|
|
36
|
+
# SearXNG default theme (simple) result container
|
|
37
|
+
articles = soup.select('article.result') or soup.select('.result')
|
|
38
|
+
|
|
39
|
+
for i, article in enumerate(articles[:4]):
|
|
40
|
+
title_tag = article.select_one('h3 a, .title a')
|
|
41
|
+
snippet_tag = article.select_one('.content, .snippet')
|
|
42
|
+
|
|
43
|
+
if title_tag:
|
|
44
|
+
title = title_tag.get_text(strip=True)
|
|
45
|
+
link = title_tag.get('href', '')
|
|
46
|
+
snippet = snippet_tag.get_text(strip=True) if snippet_tag else "No details available."
|
|
47
|
+
|
|
48
|
+
# Clean up links (SearXNG sometimes wraps them)
|
|
49
|
+
if link.startswith('/'):
|
|
50
|
+
link = f"{url.split('/search')[0]}{link}"
|
|
51
|
+
|
|
52
|
+
results.append(f"SOURCE [{i+1}]: {title}\nURL: {link}\nSUMMARY: {snippet}")
|
|
53
|
+
|
|
54
|
+
if not results:
|
|
55
|
+
logger.warning("Search returned 0 results.")
|
|
56
|
+
return "No real-time data found in the neural streams."
|
|
57
|
+
|
|
58
|
+
logger.info(f"✅ Search complete. Found {len(results)} sources.")
|
|
59
|
+
return "\n\n".join(results)
|
|
60
|
+
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.error(f"Search Execution Fault: {e}")
|
|
63
|
+
return f"Neural Search Error: {str(e)}"
|
|
64
|
+
|
|
65
|
+
# Global search client instance
|
|
66
|
+
search_client = SearchClient()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YouClaw Skill Manager
|
|
3
|
+
A system to register and execute bot capabilities (skills).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import inspect
|
|
8
|
+
import functools
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import importlib.util
|
|
12
|
+
from typing import Dict, Any, Callable, List, Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
class SkillManager:
|
|
17
|
+
"""Manages bot skills and tool registration"""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.skills: Dict[str, Dict[str, Any]] = {}
|
|
21
|
+
self.dynamic_dir = "dynamic_skills"
|
|
22
|
+
if not os.path.exists(self.dynamic_dir):
|
|
23
|
+
os.makedirs(self.dynamic_dir)
|
|
24
|
+
|
|
25
|
+
def skill(self, name: str = None, description: str = None, admin_only: bool = False):
|
|
26
|
+
"""
|
|
27
|
+
Decorator to register a function as a skill.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
name: Optional custom name for the skill (defaults to function name)
|
|
31
|
+
description: Optional description (defaults to function docstring)
|
|
32
|
+
"""
|
|
33
|
+
def decorator(func: Callable):
|
|
34
|
+
skill_name = name or func.__name__
|
|
35
|
+
skill_description = description or (func.__doc__.strip() if func.__doc__ else "No description available")
|
|
36
|
+
|
|
37
|
+
# Extract parameters from signature
|
|
38
|
+
sig = inspect.signature(func)
|
|
39
|
+
parameters = {}
|
|
40
|
+
for param_name, param in sig.parameters.items():
|
|
41
|
+
parameters[param_name] = {
|
|
42
|
+
"type": str(param.annotation) if param.annotation != inspect.Parameter.empty else "string",
|
|
43
|
+
"default": param.default if param.default != inspect.Parameter.empty else None,
|
|
44
|
+
"required": param.default == inspect.Parameter.empty
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
self.skills[skill_name] = {
|
|
48
|
+
"name": skill_name,
|
|
49
|
+
"description": skill_description,
|
|
50
|
+
"func": func,
|
|
51
|
+
"parameters": parameters,
|
|
52
|
+
"is_async": inspect.iscoroutinefunction(func),
|
|
53
|
+
"admin_only": admin_only # Added admin_only flag
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
logger.info(f"registered skill: {skill_name} (admin_only: {admin_only})")
|
|
57
|
+
|
|
58
|
+
@functools.wraps(func)
|
|
59
|
+
def wrapper(*args, **kwargs):
|
|
60
|
+
return func(*args, **kwargs)
|
|
61
|
+
return wrapper
|
|
62
|
+
|
|
63
|
+
return decorator
|
|
64
|
+
|
|
65
|
+
async def get_skills_doc(self) -> str:
|
|
66
|
+
"""Get tool definitions as a string for LLM prompting"""
|
|
67
|
+
tools_list = []
|
|
68
|
+
for name, info in self.skills.items():
|
|
69
|
+
params_str = ", ".join([f"{k} ({v['type']}{', required' if v['required'] else ''})" for k, v in info['parameters'].items()])
|
|
70
|
+
tools_list.append(f"- {name}: {info['description']}\n Params: {params_str}")
|
|
71
|
+
|
|
72
|
+
return "\n".join(tools_list)
|
|
73
|
+
|
|
74
|
+
def get_tool_definitions(self) -> str:
|
|
75
|
+
# Legacy support
|
|
76
|
+
return self.get_skills_doc()
|
|
77
|
+
|
|
78
|
+
async def execute_skill(self, name: str, arguments: Dict[str, Any]) -> Any:
|
|
79
|
+
"""Execute a registered skill by name"""
|
|
80
|
+
if name not in self.skills:
|
|
81
|
+
return f"Error: Skill '{name}' not found"
|
|
82
|
+
|
|
83
|
+
skill = self.skills[name]
|
|
84
|
+
|
|
85
|
+
# Security: Admin-only skill gating
|
|
86
|
+
if skill.get('admin_only'):
|
|
87
|
+
platform = arguments.get('platform')
|
|
88
|
+
user_id = arguments.get('user_id')
|
|
89
|
+
|
|
90
|
+
# For now, we'll use a simple check. We could also check the DB users table.
|
|
91
|
+
from .config import config
|
|
92
|
+
admin_id = config.bot.admin_user_identity # e.g. "telegram:123456"
|
|
93
|
+
current_id = f"{platform}:{user_id}"
|
|
94
|
+
|
|
95
|
+
if current_id != admin_id:
|
|
96
|
+
logger.warning(f"🛑 Security Alert: Unauthorized access attempt to '{name}' by {current_id}")
|
|
97
|
+
return f"Permission Denied: Skill '{name}' is reserved for the bot administrator."
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
logger.info(f"Executing skill '{name}' with args: {arguments}")
|
|
101
|
+
if skill['is_async']:
|
|
102
|
+
result = await skill['func'](**arguments)
|
|
103
|
+
else:
|
|
104
|
+
result = skill['func'](**arguments)
|
|
105
|
+
return result
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f"Error executing skill '{name}': {e}", exc_info=True)
|
|
108
|
+
return f"Error: {str(e)}"
|
|
109
|
+
|
|
110
|
+
def load_dynamic_skills(self):
|
|
111
|
+
"""Scan dynamic_skills folder and import them"""
|
|
112
|
+
logger.info(f"Scanning for dynamic skills in {self.dynamic_dir}...")
|
|
113
|
+
for filename in os.listdir(self.dynamic_dir):
|
|
114
|
+
if filename.endswith(".py") and not filename.startswith("__"):
|
|
115
|
+
skill_name = filename[:-3]
|
|
116
|
+
path = os.path.join(self.dynamic_dir, filename)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
spec = importlib.util.spec_from_file_location(skill_name, path)
|
|
120
|
+
module = importlib.util.module_from_spec(spec)
|
|
121
|
+
spec.loader.exec_module(module)
|
|
122
|
+
logger.info(f"Loaded dynamic skill: {skill_name}")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.error(f"Failed to load dynamic skill {skill_name}: {e}")
|
|
125
|
+
|
|
126
|
+
# Global skill manager instance
|
|
127
|
+
skill_manager = SkillManager()
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YouClaw Telegram Handler
|
|
3
|
+
Handles Telegram-specific message processing and bot interactions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from telegram import Update
|
|
7
|
+
from telegram.ext import Application, MessageHandler, filters, ContextTypes
|
|
8
|
+
import logging
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from .config import config
|
|
12
|
+
from .ollama_client import ollama_client
|
|
13
|
+
from .memory_manager import memory_manager
|
|
14
|
+
from .search_client import search_client
|
|
15
|
+
from .commands import command_handler
|
|
16
|
+
import base64
|
|
17
|
+
import io
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TelegramHandler:
|
|
23
|
+
"""Handles Telegram platform integration"""
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self.app: Optional[Application] = None
|
|
27
|
+
|
|
28
|
+
async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
29
|
+
"""Handle incoming Telegram messages"""
|
|
30
|
+
if not update.message or not update.message.text:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
# Get user info
|
|
34
|
+
user_id = str(update.effective_user.id)
|
|
35
|
+
chat_id = str(update.effective_chat.id)
|
|
36
|
+
content = update.message.text
|
|
37
|
+
username = update.effective_user.username or update.effective_user.first_name
|
|
38
|
+
|
|
39
|
+
# Send typing action
|
|
40
|
+
await update.message.chat.send_action("typing")
|
|
41
|
+
|
|
42
|
+
# Check if it's a command
|
|
43
|
+
command_response = await command_handler.handle_command(
|
|
44
|
+
platform="telegram",
|
|
45
|
+
user_id=user_id,
|
|
46
|
+
message=content,
|
|
47
|
+
channel_id=chat_id
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if command_response:
|
|
51
|
+
await self.send_message(update, command_response)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# Get user profile and onboarding status
|
|
55
|
+
profile = await memory_manager.get_user_profile(platform="telegram", user_id=user_id)
|
|
56
|
+
|
|
57
|
+
# Get conversation history
|
|
58
|
+
history = await memory_manager.get_conversation_history(
|
|
59
|
+
platform="telegram",
|
|
60
|
+
user_id=user_id,
|
|
61
|
+
channel_id=chat_id
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Add current message to history
|
|
65
|
+
await memory_manager.add_message(
|
|
66
|
+
platform="telegram",
|
|
67
|
+
user_id=user_id,
|
|
68
|
+
role="user",
|
|
69
|
+
content=content,
|
|
70
|
+
channel_id=chat_id,
|
|
71
|
+
metadata={"username": username}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Build messages for LLM
|
|
75
|
+
messages = history + [{"role": "user", "content": content}]
|
|
76
|
+
|
|
77
|
+
# Get AI response
|
|
78
|
+
logger.info(f"Starting generic chat for user {user_id}...")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
# Use autonomous reasoning loop with tools (Reminders, etc.)
|
|
82
|
+
response = await ollama_client.chat_with_tools(
|
|
83
|
+
messages=messages,
|
|
84
|
+
user_profile=profile,
|
|
85
|
+
context={"platform": "telegram", "user_id": user_id}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if response and response.strip():
|
|
89
|
+
await self.send_message(update, response)
|
|
90
|
+
else:
|
|
91
|
+
await update.message.reply_text("Hmm, I'm not sure what to say.")
|
|
92
|
+
|
|
93
|
+
logger.info(f"Chat complete for user {user_id}")
|
|
94
|
+
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.error(f"Error during reasoning: {e}")
|
|
97
|
+
error_msg = "I'm sorry, I hit a snag while thinking. Can you try again?"
|
|
98
|
+
await update.message.reply_text(error_msg)
|
|
99
|
+
response = error_msg
|
|
100
|
+
|
|
101
|
+
# Simple heuristic to 'complete' onboarding if they gave personal info
|
|
102
|
+
if not profile['onboarding_completed']:
|
|
103
|
+
if len(history) > 2:
|
|
104
|
+
await memory_manager.update_user_profile(
|
|
105
|
+
platform="telegram",
|
|
106
|
+
user_id=user_id,
|
|
107
|
+
onboarding_completed=True
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Save AI response to memory
|
|
111
|
+
await memory_manager.add_message(
|
|
112
|
+
platform="telegram",
|
|
113
|
+
user_id=user_id,
|
|
114
|
+
role="assistant",
|
|
115
|
+
content=response,
|
|
116
|
+
channel_id=chat_id
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def send_message(self, update: Update, content: str):
|
|
120
|
+
"""Send a message, handling Telegram's 4096 char limit"""
|
|
121
|
+
if len(content) <= 4096:
|
|
122
|
+
await update.message.reply_text(content)
|
|
123
|
+
else:
|
|
124
|
+
# Split into chunks
|
|
125
|
+
chunks = [content[i:i+4096] for i in range(0, len(content), 4096)]
|
|
126
|
+
for chunk in chunks:
|
|
127
|
+
await update.message.reply_text(chunk)
|
|
128
|
+
|
|
129
|
+
async def start(self):
|
|
130
|
+
"""Start the Telegram bot"""
|
|
131
|
+
if not config.telegram.enabled:
|
|
132
|
+
logger.info("Telegram is disabled in config")
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
logger.info("Starting Telegram bot...")
|
|
136
|
+
|
|
137
|
+
# Create application
|
|
138
|
+
self.app = Application.builder().token(config.telegram.token).build()
|
|
139
|
+
|
|
140
|
+
# Add message handler
|
|
141
|
+
self.app.add_handler(
|
|
142
|
+
MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_message)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Add command handler for /start
|
|
146
|
+
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
147
|
+
await update.message.reply_text(
|
|
148
|
+
"🦞 **YouClaw - Your Personal AI Assistant**\n\n"
|
|
149
|
+
"Hello! I'm YouClaw, powered by local AI (Ollama).\n\n"
|
|
150
|
+
"Just send me a message and I'll respond intelligently. "
|
|
151
|
+
"I remember our conversations!\n\n"
|
|
152
|
+
"Type /help to see available commands."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
from telegram.ext import CommandHandler
|
|
156
|
+
self.app.add_handler(CommandHandler("start", start_command))
|
|
157
|
+
|
|
158
|
+
# Start polling
|
|
159
|
+
await self.app.initialize()
|
|
160
|
+
await self.app.start()
|
|
161
|
+
await self.app.updater.start_polling()
|
|
162
|
+
|
|
163
|
+
logger.info("Telegram bot started")
|
|
164
|
+
|
|
165
|
+
# Keep alive until cancelled
|
|
166
|
+
try:
|
|
167
|
+
await asyncio.Event().wait()
|
|
168
|
+
except asyncio.CancelledError:
|
|
169
|
+
logger.info("Telegram handler task cancelled")
|
|
170
|
+
|
|
171
|
+
async def stop(self):
|
|
172
|
+
"""Stop the Telegram bot"""
|
|
173
|
+
if self.app:
|
|
174
|
+
await self.app.updater.stop()
|
|
175
|
+
await self.app.stop()
|
|
176
|
+
await self.app.shutdown()
|
|
177
|
+
logger.info("Telegram bot stopped")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Global Telegram handler instance
|
|
181
|
+
telegram_handler = TelegramHandler()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YouClaw Vector Manager
|
|
3
|
+
Handles semantic search and embedding storage for long-term memory.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import json
|
|
8
|
+
import numpy as np
|
|
9
|
+
from typing import List, Dict, Any, Optional
|
|
10
|
+
import aiosqlite
|
|
11
|
+
import base64
|
|
12
|
+
from ollama_client import ollama_client
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
class VectorManager:
|
|
17
|
+
"""Manages semantic memory using vector embeddings"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, db_path: str):
|
|
20
|
+
self.db_path = db_path
|
|
21
|
+
self.db: Optional[aiosqlite.Connection] = None
|
|
22
|
+
|
|
23
|
+
async def initialize(self):
|
|
24
|
+
"""Initialize the vector database table"""
|
|
25
|
+
self.db = await aiosqlite.connect(self.db_path)
|
|
26
|
+
await self.db.execute("""
|
|
27
|
+
CREATE TABLE IF NOT EXISTS vector_memory (
|
|
28
|
+
message_id INTEGER PRIMARY KEY,
|
|
29
|
+
embedding BLOB NOT NULL,
|
|
30
|
+
FOREIGN KEY (message_id) REFERENCES conversations (id)
|
|
31
|
+
)
|
|
32
|
+
""")
|
|
33
|
+
await self.db.commit()
|
|
34
|
+
logger.info("Vector manager initialized")
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _encode_vector(vec: List[float]) -> bytes:
|
|
38
|
+
"""Encode list of floats to binary for storage"""
|
|
39
|
+
return np.array(vec, dtype=np.float32).tobytes()
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _decode_vector(bin_vec: bytes) -> np.ndarray:
|
|
43
|
+
"""Decode binary back to numpy array"""
|
|
44
|
+
return np.frombuffer(bin_vec, dtype=np.float32)
|
|
45
|
+
|
|
46
|
+
async def save_embedding(self, message_id: int, text: str):
|
|
47
|
+
"""Generate and save embedding for a message"""
|
|
48
|
+
embedding = await ollama_client.get_embeddings(text)
|
|
49
|
+
if not embedding:
|
|
50
|
+
logger.warning(f"Failed to generate embedding for message {message_id}")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
bin_vec = self._encode_vector(embedding)
|
|
54
|
+
await self.db.execute(
|
|
55
|
+
"INSERT OR REPLACE INTO vector_memory (message_id, embedding) VALUES (?, ?)",
|
|
56
|
+
(message_id, bin_vec)
|
|
57
|
+
)
|
|
58
|
+
await self.db.commit()
|
|
59
|
+
|
|
60
|
+
async def search_semantic(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
|
|
61
|
+
"""Search for semantically similar messages"""
|
|
62
|
+
query_vec = await ollama_client.get_embeddings(query)
|
|
63
|
+
if not query_vec:
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
query_np = np.array(query_vec, dtype=np.float32)
|
|
67
|
+
|
|
68
|
+
# This is a brute-force search (fine for small local DBs)
|
|
69
|
+
# For larger DBs, we'd use HNSW or Faiss
|
|
70
|
+
results = []
|
|
71
|
+
async with self.db.execute("""
|
|
72
|
+
SELECT vm.message_id, vm.embedding, c.content, c.role, c.timestamp
|
|
73
|
+
FROM vector_memory vm
|
|
74
|
+
JOIN conversations c ON vm.message_id = c.id
|
|
75
|
+
""") as cursor:
|
|
76
|
+
async for msg_id, bin_vec, content, role, ts in cursor:
|
|
77
|
+
msg_vec = self._decode_vector(bin_vec)
|
|
78
|
+
|
|
79
|
+
# Cosine similarity
|
|
80
|
+
similarity = np.dot(query_np, msg_vec) / (np.linalg.norm(query_np) * np.linalg.norm(msg_vec))
|
|
81
|
+
|
|
82
|
+
results.append({
|
|
83
|
+
"id": msg_id,
|
|
84
|
+
"content": content,
|
|
85
|
+
"role": role,
|
|
86
|
+
"timestamp": ts,
|
|
87
|
+
"similarity": float(similarity)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
# Sort by similarity and return top N
|
|
91
|
+
results.sort(key=lambda x: x['similarity'], reverse=True)
|
|
92
|
+
return results[:limit]
|
|
93
|
+
|
|
94
|
+
# The instance will be managed by memory_manager to avoid circular imports
|