Initial commit: Ajarbot with optimizations
Features: - Multi-platform bot (Slack, Telegram) - Memory system with SQLite FTS - Tool use capabilities (file ops, commands) - Scheduled tasks system - Dynamic model switching (/sonnet, /haiku) - Prompt caching for cost optimization Optimizations: - Default to Haiku 4.5 (12x cheaper) - Reduced context: 3 messages, 2 memory results - Optimized SOUL.md (48% smaller) - Automatic caching when using Sonnet (90% savings) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
21
adapters/__init__.py
Normal file
21
adapters/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Messaging platform adapters for ajarbot."""
|
||||
|
||||
from .base import (
|
||||
BaseAdapter,
|
||||
AdapterConfig,
|
||||
AdapterCapabilities,
|
||||
AdapterRegistry,
|
||||
InboundMessage,
|
||||
OutboundMessage,
|
||||
MessageType
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BaseAdapter",
|
||||
"AdapterConfig",
|
||||
"AdapterCapabilities",
|
||||
"AdapterRegistry",
|
||||
"InboundMessage",
|
||||
"OutboundMessage",
|
||||
"MessageType"
|
||||
]
|
||||
258
adapters/base.py
Normal file
258
adapters/base.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Base adapter interface for messaging platforms.
|
||||
|
||||
Inspired by OpenClaw's ChannelPlugin architecture but simplified
|
||||
for ajarbot's needs.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
"""Types of messages that can be sent or received."""
|
||||
TEXT = "text"
|
||||
MEDIA = "media"
|
||||
FILE = "file"
|
||||
REACTION = "reaction"
|
||||
|
||||
|
||||
@dataclass
|
||||
class InboundMessage:
|
||||
"""Represents a message received from a messaging platform."""
|
||||
platform: str
|
||||
user_id: str
|
||||
username: str
|
||||
text: str
|
||||
channel_id: str
|
||||
thread_id: Optional[str]
|
||||
reply_to_id: Optional[str]
|
||||
message_type: MessageType
|
||||
metadata: Dict[str, Any]
|
||||
raw: Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutboundMessage:
|
||||
"""Represents a message to be sent to a messaging platform."""
|
||||
platform: str
|
||||
channel_id: str
|
||||
text: str
|
||||
thread_id: Optional[str] = None
|
||||
reply_to_id: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdapterConfig:
|
||||
"""Configuration for an adapter instance."""
|
||||
platform: str
|
||||
enabled: bool = True
|
||||
credentials: Dict[str, Any] = field(default_factory=dict)
|
||||
settings: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdapterCapabilities:
|
||||
"""Describes what a messaging platform adapter can do."""
|
||||
supports_threads: bool = False
|
||||
supports_reactions: bool = False
|
||||
supports_media: bool = False
|
||||
supports_files: bool = False
|
||||
supports_markdown: bool = False
|
||||
max_message_length: int = 2000
|
||||
chunking_strategy: Optional[str] = None # "word", "markdown", "char"
|
||||
|
||||
|
||||
class BaseAdapter(ABC):
|
||||
"""
|
||||
Base adapter interface for messaging platforms.
|
||||
|
||||
Core aspects:
|
||||
- Config: Platform configuration and credentials
|
||||
- Gateway: Connection lifecycle management
|
||||
- Outbound: Sending messages
|
||||
- Inbound: Receiving and parsing messages
|
||||
- Status: Health checks and monitoring
|
||||
"""
|
||||
|
||||
def __init__(self, config: AdapterConfig) -> None:
|
||||
self.config = config
|
||||
self.is_running = False
|
||||
self._message_handlers: List[Callable[[InboundMessage], None]] = []
|
||||
|
||||
# --- Core Interface (Required) ---
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def platform_name(self) -> str:
|
||||
"""Platform identifier (e.g., 'slack', 'telegram')."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def capabilities(self) -> AdapterCapabilities:
|
||||
"""Describe platform capabilities."""
|
||||
|
||||
@abstractmethod
|
||||
async def start(self) -> None:
|
||||
"""Start the adapter connection."""
|
||||
|
||||
@abstractmethod
|
||||
async def stop(self) -> None:
|
||||
"""Stop the adapter connection."""
|
||||
|
||||
@abstractmethod
|
||||
async def send_message(
|
||||
self, message: OutboundMessage
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send a message to the platform.
|
||||
|
||||
Returns:
|
||||
Dict with at least {"success": bool, "message_id": str}
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def validate_config(self) -> bool:
|
||||
"""Validate that the adapter is properly configured."""
|
||||
|
||||
# --- Message Handler Registration ---
|
||||
|
||||
def register_message_handler(
|
||||
self, handler: Callable[[InboundMessage], None]
|
||||
) -> None:
|
||||
"""Register a function to be called when messages are received."""
|
||||
self._message_handlers.append(handler)
|
||||
|
||||
def _dispatch_message(self, message: InboundMessage) -> None:
|
||||
"""Internal: Dispatch incoming message to all registered handlers."""
|
||||
for handler in self._message_handlers:
|
||||
try:
|
||||
handler(message)
|
||||
except Exception as e:
|
||||
print(f"Error in message handler: {e}")
|
||||
|
||||
# --- Optional Features (Can be overridden) ---
|
||||
|
||||
async def send_reaction(
|
||||
self, channel_id: str, message_id: str, emoji: str
|
||||
) -> bool:
|
||||
"""Send a reaction/emoji to a message. Optional."""
|
||||
return False
|
||||
|
||||
async def send_typing_indicator(self, channel_id: str) -> None:
|
||||
"""Show typing indicator. Optional."""
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
"""Perform health check on the adapter."""
|
||||
return {
|
||||
"platform": self.platform_name,
|
||||
"running": self.is_running,
|
||||
"healthy": self.is_running and self.validate_config(),
|
||||
}
|
||||
|
||||
def chunk_text(self, text: str) -> List[str]:
|
||||
"""Split long text into chunks based on platform limits."""
|
||||
max_len = self.capabilities.max_message_length
|
||||
|
||||
if len(text) <= max_len:
|
||||
return [text]
|
||||
|
||||
strategy = self.capabilities.chunking_strategy or "word"
|
||||
|
||||
if strategy == "word":
|
||||
return self._chunk_by_words(text, max_len)
|
||||
elif strategy == "char":
|
||||
return self._chunk_by_chars(text, max_len)
|
||||
elif strategy == "markdown":
|
||||
return self._chunk_by_lines(text, max_len)
|
||||
|
||||
return [text]
|
||||
|
||||
@staticmethod
|
||||
def _chunk_by_words(text: str, max_len: int) -> List[str]:
|
||||
"""Split text on word boundaries."""
|
||||
words = text.split()
|
||||
chunks: List[str] = []
|
||||
current_chunk: List[str] = []
|
||||
current_length = 0
|
||||
|
||||
for word in words:
|
||||
word_length = len(word) + 1 # +1 for space
|
||||
if current_length + word_length > max_len:
|
||||
chunks.append(" ".join(current_chunk))
|
||||
current_chunk = [word]
|
||||
current_length = word_length
|
||||
else:
|
||||
current_chunk.append(word)
|
||||
current_length += word_length
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(" ".join(current_chunk))
|
||||
|
||||
return chunks
|
||||
|
||||
@staticmethod
|
||||
def _chunk_by_chars(text: str, max_len: int) -> List[str]:
|
||||
"""Split text at fixed character boundaries."""
|
||||
return [text[i:i + max_len] for i in range(0, len(text), max_len)]
|
||||
|
||||
@staticmethod
|
||||
def _chunk_by_lines(text: str, max_len: int) -> List[str]:
|
||||
"""Split text on line boundaries preserving markdown."""
|
||||
lines = text.split("\n")
|
||||
chunks: List[str] = []
|
||||
current_chunk: List[str] = []
|
||||
current_length = 0
|
||||
|
||||
for line in lines:
|
||||
line_length = len(line) + 1 # +1 for newline
|
||||
if current_length + line_length > max_len:
|
||||
chunks.append("\n".join(current_chunk))
|
||||
current_chunk = [line]
|
||||
current_length = line_length
|
||||
else:
|
||||
current_chunk.append(line)
|
||||
current_length += line_length
|
||||
|
||||
if current_chunk:
|
||||
chunks.append("\n".join(current_chunk))
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
class AdapterRegistry:
|
||||
"""Registry for managing multiple platform adapters."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._adapters: Dict[str, BaseAdapter] = {}
|
||||
|
||||
def register(self, adapter: BaseAdapter) -> None:
|
||||
"""Register an adapter instance."""
|
||||
self._adapters[adapter.platform_name] = adapter
|
||||
|
||||
def get(self, platform_name: str) -> Optional[BaseAdapter]:
|
||||
"""Get an adapter by platform name."""
|
||||
return self._adapters.get(platform_name)
|
||||
|
||||
def list_platforms(self) -> List[str]:
|
||||
"""List all registered platform names."""
|
||||
return list(self._adapters.keys())
|
||||
|
||||
def get_all(self) -> List[BaseAdapter]:
|
||||
"""Get all registered adapters."""
|
||||
return list(self._adapters.values())
|
||||
|
||||
async def start_all(self) -> None:
|
||||
"""Start all registered adapters."""
|
||||
for adapter in self._adapters.values():
|
||||
if adapter.config.enabled:
|
||||
await adapter.start()
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
"""Stop all registered adapters."""
|
||||
for adapter in self._adapters.values():
|
||||
if adapter.is_running:
|
||||
await adapter.stop()
|
||||
262
adapters/runtime.py
Normal file
262
adapters/runtime.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
Adapter runtime system for ajarbot.
|
||||
|
||||
Connects messaging platform adapters to the Agent instance.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import traceback
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from adapters.base import (
|
||||
AdapterRegistry,
|
||||
BaseAdapter,
|
||||
InboundMessage,
|
||||
OutboundMessage,
|
||||
)
|
||||
from agent import Agent
|
||||
|
||||
|
||||
class AdapterRuntime:
|
||||
"""
|
||||
Runtime system that connects adapters to the Agent.
|
||||
|
||||
Acts as the bridge between messaging platforms (Slack, Telegram, etc.)
|
||||
and the Agent (memory + LLM).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: Agent,
|
||||
registry: Optional[AdapterRegistry] = None,
|
||||
) -> None:
|
||||
self.agent = agent
|
||||
self.registry = registry or AdapterRegistry()
|
||||
self.message_loop_task: Optional[asyncio.Task] = None
|
||||
self._message_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._is_running = False
|
||||
|
||||
# User ID mapping: platform_user_id -> username
|
||||
self._user_mapping: Dict[str, str] = {}
|
||||
|
||||
self._preprocessors: List[
|
||||
Callable[[InboundMessage], InboundMessage]
|
||||
] = []
|
||||
self._postprocessors: List[
|
||||
Callable[[str, InboundMessage], str]
|
||||
] = []
|
||||
|
||||
def add_adapter(self, adapter: BaseAdapter) -> None:
|
||||
"""Add and configure an adapter."""
|
||||
self.registry.register(adapter)
|
||||
adapter.register_message_handler(self._on_message_received)
|
||||
|
||||
def map_user(self, platform_user_id: str, username: str) -> None:
|
||||
"""Map a platform user ID to an ajarbot username."""
|
||||
self._user_mapping[platform_user_id] = username
|
||||
|
||||
def get_username(
|
||||
self, platform: str, platform_user_id: str
|
||||
) -> str:
|
||||
"""
|
||||
Get ajarbot username for a platform user.
|
||||
|
||||
Falls back to platform_user_id format if no mapping exists.
|
||||
"""
|
||||
key = f"{platform}:{platform_user_id}"
|
||||
# Use underscore for fallback to match validation rules
|
||||
fallback = f"{platform}_{platform_user_id}"
|
||||
return self._user_mapping.get(key, fallback)
|
||||
|
||||
def add_preprocessor(
|
||||
self,
|
||||
preprocessor: Callable[[InboundMessage], InboundMessage],
|
||||
) -> None:
|
||||
"""Add a message preprocessor (e.g., for commands, filters)."""
|
||||
self._preprocessors.append(preprocessor)
|
||||
|
||||
def add_postprocessor(
|
||||
self,
|
||||
postprocessor: Callable[[str, InboundMessage], str],
|
||||
) -> None:
|
||||
"""Add a response postprocessor (e.g., for formatting)."""
|
||||
self._postprocessors.append(postprocessor)
|
||||
|
||||
def _on_message_received(self, message: InboundMessage) -> None:
|
||||
"""Handle incoming message from an adapter."""
|
||||
asyncio.create_task(self._message_queue.put(message))
|
||||
|
||||
async def _process_message_queue(self) -> None:
|
||||
"""Background task to process incoming messages."""
|
||||
print("[Runtime] Message processing loop started")
|
||||
|
||||
while self._is_running:
|
||||
try:
|
||||
message = await asyncio.wait_for(
|
||||
self._message_queue.get(), timeout=1.0
|
||||
)
|
||||
await self._process_message(message)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[Runtime] Error processing message: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
print("[Runtime] Message processing loop stopped")
|
||||
|
||||
async def _process_message(self, message: InboundMessage) -> None:
|
||||
"""Process a single message."""
|
||||
preview = message.text[:50]
|
||||
print(
|
||||
f"[{message.platform.upper()}] "
|
||||
f"Message from {message.username}: {preview}..."
|
||||
)
|
||||
|
||||
try:
|
||||
# Apply preprocessors
|
||||
processed_message = message
|
||||
for preprocessor in self._preprocessors:
|
||||
processed_message = preprocessor(processed_message)
|
||||
|
||||
username = self.get_username(
|
||||
message.platform, message.user_id
|
||||
)
|
||||
|
||||
adapter = self.registry.get(message.platform)
|
||||
if adapter:
|
||||
await adapter.send_typing_indicator(message.channel_id)
|
||||
|
||||
# Get response from agent (synchronous call in thread)
|
||||
response = await asyncio.to_thread(
|
||||
self.agent.chat,
|
||||
user_message=processed_message.text,
|
||||
username=username,
|
||||
)
|
||||
|
||||
# Apply postprocessors
|
||||
for postprocessor in self._postprocessors:
|
||||
response = postprocessor(response, processed_message)
|
||||
|
||||
# Send response back
|
||||
if adapter:
|
||||
reply_to = (
|
||||
message.metadata.get("ts")
|
||||
or message.metadata.get("message_id")
|
||||
)
|
||||
outbound = OutboundMessage(
|
||||
platform=message.platform,
|
||||
channel_id=message.channel_id,
|
||||
text=response,
|
||||
thread_id=message.thread_id,
|
||||
reply_to_id=reply_to,
|
||||
)
|
||||
|
||||
result = await adapter.send_message(outbound)
|
||||
platform_tag = message.platform.upper()
|
||||
|
||||
if result.get("success"):
|
||||
print(
|
||||
f"[{platform_tag}] Response sent "
|
||||
f"({len(response)} chars)"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"[{platform_tag}] Failed to send response: "
|
||||
f"{result.get('error')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Runtime] Error processing message: {e}")
|
||||
traceback.print_exc()
|
||||
await self._send_error_reply(message)
|
||||
|
||||
async def _send_error_reply(self, message: InboundMessage) -> None:
|
||||
"""Attempt to send an error message back to the user."""
|
||||
try:
|
||||
adapter = self.registry.get(message.platform)
|
||||
if adapter:
|
||||
error_msg = OutboundMessage(
|
||||
platform=message.platform,
|
||||
channel_id=message.channel_id,
|
||||
text=(
|
||||
"Sorry, I encountered an error processing "
|
||||
"your message. Please try again."
|
||||
),
|
||||
thread_id=message.thread_id,
|
||||
)
|
||||
await adapter.send_message(error_msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the runtime and all adapters."""
|
||||
print("[Runtime] Starting adapter runtime...")
|
||||
await self.registry.start_all()
|
||||
|
||||
self._is_running = True
|
||||
self.message_loop_task = asyncio.create_task(
|
||||
self._process_message_queue()
|
||||
)
|
||||
print("[Runtime] Runtime started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the runtime and all adapters."""
|
||||
print("[Runtime] Stopping adapter runtime...")
|
||||
|
||||
self._is_running = False
|
||||
if self.message_loop_task:
|
||||
await self.message_loop_task
|
||||
|
||||
await self.registry.stop_all()
|
||||
self.agent.shutdown()
|
||||
print("[Runtime] Runtime stopped")
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
"""Get health status of all adapters."""
|
||||
status: Dict[str, Any] = {
|
||||
"runtime_running": self._is_running,
|
||||
"adapters": {},
|
||||
}
|
||||
|
||||
for adapter in self.registry.get_all():
|
||||
adapter_health = await adapter.health_check()
|
||||
status["adapters"][adapter.platform_name] = adapter_health
|
||||
|
||||
return status
|
||||
|
||||
|
||||
# --- Example Preprocessors and Postprocessors ---
|
||||
|
||||
|
||||
def command_preprocessor(message: InboundMessage) -> InboundMessage:
|
||||
"""Example: Handle bot commands."""
|
||||
if not message.text.startswith("/"):
|
||||
return message
|
||||
|
||||
parts = message.text.split(maxsplit=1)
|
||||
command = parts[0]
|
||||
|
||||
if command == "/status":
|
||||
message.text = "What is your current status?"
|
||||
elif command == "/help":
|
||||
message.text = (
|
||||
"Please provide help information about what you can do."
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def markdown_postprocessor(
|
||||
response: str, original_message: InboundMessage
|
||||
) -> str:
|
||||
"""Example: Ensure markdown compatibility for Slack."""
|
||||
if original_message.platform != "slack":
|
||||
return response
|
||||
|
||||
# Convert standard markdown bold to Slack mrkdwn
|
||||
response = response.replace("**", "*")
|
||||
# Slack doesn't support ## headers
|
||||
response = re.sub(r"^#+\s+", "", response, flags=re.MULTILINE)
|
||||
|
||||
return response
|
||||
212
adapters/skill_integration.py
Normal file
212
adapters/skill_integration.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Integration layer for using Claude Code skills from within ajarbot adapters.
|
||||
|
||||
Allows the Agent to invoke local skills programmatically,
|
||||
enabling advanced automation and dynamic behavior.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
|
||||
class SkillInvoker:
|
||||
"""
|
||||
Invokes Claude Code skills programmatically.
|
||||
|
||||
Skills are local-only (no registry) and live in:
|
||||
- .claude/skills/<skill-name>/SKILL.md (project)
|
||||
- ~/.claude/skills/<skill-name>/SKILL.md (personal)
|
||||
"""
|
||||
|
||||
def __init__(self, project_root: Optional[str] = None) -> None:
|
||||
self.project_root = Path(project_root or Path.cwd())
|
||||
self.skills_dir = self.project_root / ".claude" / "skills"
|
||||
|
||||
def list_available_skills(self) -> List[str]:
|
||||
"""List all available local skills."""
|
||||
if not self.skills_dir.exists():
|
||||
return []
|
||||
|
||||
return [
|
||||
skill_dir.name
|
||||
for skill_dir in self.skills_dir.iterdir()
|
||||
if skill_dir.is_dir()
|
||||
and (skill_dir / "SKILL.md").exists()
|
||||
]
|
||||
|
||||
def get_skill_info(
|
||||
self, skill_name: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get information about a skill."""
|
||||
# Validate skill_name to prevent path traversal
|
||||
if not skill_name or not skill_name.replace("-", "").replace("_", "").isalnum():
|
||||
raise ValueError(
|
||||
"Invalid skill name: must contain only alphanumeric, "
|
||||
"hyphens, and underscores"
|
||||
)
|
||||
|
||||
skill_path = self.skills_dir / skill_name / "SKILL.md"
|
||||
|
||||
# Verify the resolved path is within skills_dir
|
||||
try:
|
||||
resolved = skill_path.resolve()
|
||||
if not resolved.is_relative_to(self.skills_dir.resolve()):
|
||||
raise ValueError("Path traversal detected in skill name")
|
||||
except (ValueError, OSError) as e:
|
||||
raise ValueError(f"Invalid skill path: {e}")
|
||||
|
||||
if not skill_path.exists():
|
||||
return None
|
||||
|
||||
with open(skill_path) as f:
|
||||
content = f.read()
|
||||
|
||||
if not content.startswith("---"):
|
||||
return None
|
||||
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
|
||||
frontmatter = parts[1].strip()
|
||||
body = parts[2].strip()
|
||||
|
||||
# Simple YAML parsing (key: value pairs)
|
||||
info: Dict[str, Any] = {}
|
||||
for line in frontmatter.split("\n"):
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
info[key.strip()] = value.strip()
|
||||
|
||||
info["body"] = body
|
||||
info["path"] = str(skill_path)
|
||||
return info
|
||||
|
||||
def invoke_skill_via_cli(
|
||||
self, skill_name: str, *args: str
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Invoke a skill via Claude Code CLI.
|
||||
|
||||
Requires claude-code CLI to be installed and in PATH.
|
||||
For production, integrate with the Agent's LLM directly.
|
||||
"""
|
||||
# Validate skill_name
|
||||
if not skill_name or not skill_name.replace("-", "").replace("_", "").isalnum():
|
||||
raise ValueError(
|
||||
"Invalid skill name: must contain only alphanumeric, "
|
||||
"hyphens, and underscores"
|
||||
)
|
||||
|
||||
# Validate arguments don't contain shell metacharacters
|
||||
for arg in args:
|
||||
if any(char in str(arg) for char in ['&', '|', ';', '$', '`', '\n', '\r']):
|
||||
raise ValueError(
|
||||
"Invalid argument: contains shell metacharacters"
|
||||
)
|
||||
|
||||
try:
|
||||
cmd = ["claude-code", f"/{skill_name}"] + list(args)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=self.project_root,
|
||||
timeout=60, # Add timeout to prevent hanging
|
||||
)
|
||||
return result.stdout if result.returncode == 0 else None
|
||||
except FileNotFoundError:
|
||||
print("[SkillInvoker] claude-code CLI not found")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"[SkillInvoker] Skill {skill_name} timed out")
|
||||
return None
|
||||
|
||||
def invoke_skill_via_agent(
|
||||
self, skill_name: str, agent: Any, *args: str
|
||||
) -> str:
|
||||
"""
|
||||
Invoke a skill by injecting it into the Agent's context.
|
||||
|
||||
This is the recommended approach - it uses the Agent's existing
|
||||
LLM connection without requiring the CLI.
|
||||
"""
|
||||
skill_info = self.get_skill_info(skill_name)
|
||||
|
||||
if not skill_info:
|
||||
return f"Skill '{skill_name}' not found"
|
||||
|
||||
# Validate and sanitize arguments to prevent prompt injection
|
||||
sanitized_args = []
|
||||
for arg in args:
|
||||
# Limit argument length
|
||||
arg_str = str(arg)[:1000]
|
||||
# Wrap in XML-like tags to clearly delimit user input
|
||||
sanitized_args.append(arg_str)
|
||||
|
||||
arguments = " ".join(sanitized_args)
|
||||
skill_instructions = skill_info.get("body", "")
|
||||
|
||||
# Replace argument placeholders with delimited user input
|
||||
# Wrap arguments in XML tags to prevent prompt injection
|
||||
safe_arguments = f"<user_input>{arguments}</user_input>"
|
||||
skill_instructions = skill_instructions.replace(
|
||||
"$ARGUMENTS", safe_arguments
|
||||
)
|
||||
for i, arg in enumerate(sanitized_args):
|
||||
safe_arg = f"<user_input>{arg}</user_input>"
|
||||
skill_instructions = skill_instructions.replace(
|
||||
f"${i}", safe_arg
|
||||
)
|
||||
|
||||
# Use actual username instead of privileged "skill-invoker"
|
||||
return agent.chat(
|
||||
user_message=skill_instructions,
|
||||
username="default", # Changed from "skill-invoker"
|
||||
)
|
||||
|
||||
|
||||
def skill_based_preprocessor(
|
||||
skill_invoker: SkillInvoker, agent: Any
|
||||
) -> Callable:
|
||||
"""
|
||||
Create a preprocessor that invokes skills based on message patterns.
|
||||
|
||||
Messages starting with /skill-name will trigger the corresponding skill.
|
||||
"""
|
||||
|
||||
def preprocessor(message):
|
||||
if not message.text.startswith("/"):
|
||||
return message
|
||||
|
||||
parts = message.text.split(maxsplit=1)
|
||||
skill_name = parts[0][1:] # Remove leading /
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if skill_name in skill_invoker.list_available_skills():
|
||||
result = skill_invoker.invoke_skill_via_agent(
|
||||
skill_name, agent, args
|
||||
)
|
||||
message.text = result
|
||||
|
||||
return message
|
||||
|
||||
return preprocessor
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
invoker = SkillInvoker()
|
||||
|
||||
print("Available skills:")
|
||||
for skill in invoker.list_available_skills():
|
||||
info = invoker.get_skill_info(skill)
|
||||
print(f" /{skill}")
|
||||
if info:
|
||||
print(
|
||||
f" Description: {info.get('description', 'N/A')}"
|
||||
)
|
||||
print(
|
||||
f" User-invocable: "
|
||||
f"{info.get('user-invocable', 'N/A')}"
|
||||
)
|
||||
5
adapters/slack/__init__.py
Normal file
5
adapters/slack/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Slack adapter for ajarbot."""
|
||||
|
||||
from .adapter import SlackAdapter
|
||||
|
||||
__all__ = ["SlackAdapter"]
|
||||
278
adapters/slack/adapter.py
Normal file
278
adapters/slack/adapter.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Slack Socket Mode adapter for ajarbot.
|
||||
|
||||
Uses Socket Mode for easy firewall-free integration without webhooks.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from slack_bolt.adapter.socket_mode.async_handler import (
|
||||
AsyncSocketModeHandler,
|
||||
)
|
||||
from slack_bolt.async_app import AsyncApp
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
from adapters.base import (
|
||||
AdapterCapabilities,
|
||||
AdapterConfig,
|
||||
BaseAdapter,
|
||||
InboundMessage,
|
||||
MessageType,
|
||||
OutboundMessage,
|
||||
)
|
||||
|
||||
|
||||
class SlackAdapter(BaseAdapter):
|
||||
"""
|
||||
Slack adapter using Socket Mode.
|
||||
|
||||
Socket Mode allows receiving events over WebSocket without exposing
|
||||
a public HTTP endpoint - perfect for development and simple deployments.
|
||||
|
||||
Configuration required:
|
||||
- bot_token: Bot User OAuth Token (xoxb-...)
|
||||
- app_token: App-Level Token (xapp-...)
|
||||
"""
|
||||
|
||||
def __init__(self, config: AdapterConfig) -> None:
|
||||
super().__init__(config)
|
||||
self.app: Optional[AsyncApp] = None
|
||||
self.handler: Optional[AsyncSocketModeHandler] = None
|
||||
|
||||
@property
|
||||
def platform_name(self) -> str:
|
||||
return "slack"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> AdapterCapabilities:
|
||||
return AdapterCapabilities(
|
||||
supports_threads=True,
|
||||
supports_reactions=True,
|
||||
supports_media=True,
|
||||
supports_files=True,
|
||||
supports_markdown=True,
|
||||
max_message_length=4000,
|
||||
chunking_strategy="word",
|
||||
)
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
"""Validate Slack configuration."""
|
||||
if not self.config.credentials:
|
||||
return False
|
||||
|
||||
bot_token = self.config.credentials.get("bot_token", "")
|
||||
app_token = self.config.credentials.get("app_token", "")
|
||||
|
||||
return (
|
||||
bool(bot_token and app_token)
|
||||
and bot_token.startswith("xoxb-")
|
||||
and app_token.startswith("xapp-")
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Slack Socket Mode connection."""
|
||||
if not self.validate_config():
|
||||
raise ValueError(
|
||||
"Invalid Slack configuration. "
|
||||
"Need bot_token (xoxb-...) and app_token (xapp-...)"
|
||||
)
|
||||
|
||||
bot_token = self.config.credentials["bot_token"]
|
||||
app_token = self.config.credentials["app_token"]
|
||||
|
||||
self.app = AsyncApp(token=bot_token)
|
||||
self._register_handlers()
|
||||
|
||||
self.handler = AsyncSocketModeHandler(self.app, app_token)
|
||||
|
||||
print("[Slack] Starting Socket Mode connection...")
|
||||
await self.handler.start_async()
|
||||
|
||||
self.is_running = True
|
||||
print("[Slack] Connected and listening for messages")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the Slack Socket Mode connection."""
|
||||
if self.handler:
|
||||
print("[Slack] Stopping Socket Mode connection...")
|
||||
await self.handler.close_async()
|
||||
self.is_running = False
|
||||
print("[Slack] Disconnected")
|
||||
|
||||
def _register_handlers(self) -> None:
|
||||
"""Register Slack event handlers."""
|
||||
|
||||
@self.app.event("message")
|
||||
async def handle_message_events(event, say):
|
||||
"""Handle incoming messages."""
|
||||
if event.get("subtype") in ["bot_message", "message_changed"]:
|
||||
return
|
||||
|
||||
user_id = event.get("user")
|
||||
text = event.get("text", "")
|
||||
channel = event.get("channel")
|
||||
thread_ts = event.get("thread_ts")
|
||||
ts = event.get("ts")
|
||||
|
||||
username = await self._get_username(user_id)
|
||||
|
||||
inbound_msg = InboundMessage(
|
||||
platform="slack",
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
text=text,
|
||||
channel_id=channel,
|
||||
thread_id=thread_ts,
|
||||
reply_to_id=None,
|
||||
message_type=MessageType.TEXT,
|
||||
metadata={
|
||||
"ts": ts,
|
||||
"team": event.get("team"),
|
||||
"channel_type": event.get("channel_type"),
|
||||
},
|
||||
raw=event,
|
||||
)
|
||||
|
||||
self._dispatch_message(inbound_msg)
|
||||
|
||||
@self.app.event("app_mention")
|
||||
async def handle_app_mentions(event, say):
|
||||
"""Handle @mentions of the bot."""
|
||||
user_id = event.get("user")
|
||||
text = self._strip_mention(event.get("text", ""))
|
||||
channel = event.get("channel")
|
||||
thread_ts = event.get("thread_ts")
|
||||
ts = event.get("ts")
|
||||
|
||||
username = await self._get_username(user_id)
|
||||
|
||||
inbound_msg = InboundMessage(
|
||||
platform="slack",
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
text=text,
|
||||
channel_id=channel,
|
||||
thread_id=thread_ts,
|
||||
reply_to_id=None,
|
||||
message_type=MessageType.TEXT,
|
||||
metadata={
|
||||
"ts": ts,
|
||||
"mentioned": True,
|
||||
"team": event.get("team"),
|
||||
},
|
||||
raw=event,
|
||||
)
|
||||
|
||||
self._dispatch_message(inbound_msg)
|
||||
|
||||
async def send_message(
|
||||
self, message: OutboundMessage
|
||||
) -> Dict[str, Any]:
|
||||
"""Send a message to Slack."""
|
||||
if not self.app:
|
||||
return {"success": False, "error": "Adapter not started"}
|
||||
|
||||
try:
|
||||
chunks = self.chunk_text(message.text)
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
thread_ts = (
|
||||
message.thread_id
|
||||
if i == 0
|
||||
else results[0].get("ts")
|
||||
)
|
||||
|
||||
result = await self.app.client.chat_postMessage(
|
||||
channel=message.channel_id,
|
||||
text=chunk,
|
||||
thread_ts=thread_ts,
|
||||
mrkdwn=True,
|
||||
)
|
||||
|
||||
results.append({
|
||||
"ts": result["ts"],
|
||||
"channel": result["channel"],
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": results[0]["ts"],
|
||||
"chunks_sent": len(chunks),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
except SlackApiError as e:
|
||||
error_msg = e.response["error"]
|
||||
print(f"[Slack] Error sending message: {error_msg}")
|
||||
return {"success": False, "error": error_msg}
|
||||
|
||||
async def send_reaction(
|
||||
self, channel_id: str, message_id: str, emoji: str
|
||||
) -> bool:
|
||||
"""Add a reaction to a message."""
|
||||
if not self.app:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.app.client.reactions_add(
|
||||
channel=channel_id,
|
||||
timestamp=message_id,
|
||||
name=emoji.strip(":"),
|
||||
)
|
||||
return True
|
||||
except SlackApiError as e:
|
||||
print(
|
||||
f"[Slack] Error adding reaction: {e.response['error']}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def send_typing_indicator(self, channel_id: str) -> None:
|
||||
"""Slack doesn't have a typing indicator API."""
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
"""Perform health check."""
|
||||
base_health = await super().health_check()
|
||||
|
||||
if not self.app:
|
||||
return {**base_health, "details": "App not initialized"}
|
||||
|
||||
try:
|
||||
response = await self.app.client.auth_test()
|
||||
return {
|
||||
**base_health,
|
||||
"bot_id": response.get("bot_id"),
|
||||
"team": response.get("team"),
|
||||
"user": response.get("user"),
|
||||
"connected": True,
|
||||
}
|
||||
except SlackApiError as e:
|
||||
return {
|
||||
**base_health,
|
||||
"healthy": False,
|
||||
"error": str(e.response.get("error")),
|
||||
}
|
||||
|
||||
async def _get_username(self, user_id: str) -> str:
|
||||
"""Get username from user ID."""
|
||||
if not self.app:
|
||||
return user_id
|
||||
|
||||
try:
|
||||
result = await self.app.client.users_info(user=user_id)
|
||||
user = result["user"]
|
||||
profile = user.get("profile", {})
|
||||
return (
|
||||
profile.get("display_name")
|
||||
or profile.get("real_name")
|
||||
or user.get("name")
|
||||
or user_id
|
||||
)
|
||||
except SlackApiError:
|
||||
return user_id
|
||||
|
||||
@staticmethod
|
||||
def _strip_mention(text: str) -> str:
|
||||
"""Remove bot mention from text (e.g., '<@U12345> hello' -> 'hello')."""
|
||||
return re.sub(r"<@[A-Z0-9]+>", "", text).strip()
|
||||
5
adapters/telegram/__init__.py
Normal file
5
adapters/telegram/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Telegram adapter for ajarbot."""
|
||||
|
||||
from .adapter import TelegramAdapter
|
||||
|
||||
__all__ = ["TelegramAdapter"]
|
||||
367
adapters/telegram/adapter.py
Normal file
367
adapters/telegram/adapter.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
Telegram adapter for ajarbot.
|
||||
|
||||
Uses python-telegram-bot library for async Telegram Bot API integration.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from telegram import Bot, Update
|
||||
from telegram.error import TelegramError
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
CommandHandler,
|
||||
ContextTypes,
|
||||
MessageHandler,
|
||||
filters,
|
||||
)
|
||||
|
||||
from adapters.base import (
|
||||
AdapterCapabilities,
|
||||
AdapterConfig,
|
||||
BaseAdapter,
|
||||
InboundMessage,
|
||||
MessageType,
|
||||
OutboundMessage,
|
||||
)
|
||||
|
||||
|
||||
class TelegramAdapter(BaseAdapter):
|
||||
"""
|
||||
Telegram adapter using python-telegram-bot.
|
||||
|
||||
Configuration required:
|
||||
- bot_token: Telegram Bot API Token (from @BotFather)
|
||||
|
||||
Optional settings:
|
||||
- allowed_users: List of user IDs allowed to interact (for privacy)
|
||||
- parse_mode: "HTML" or "Markdown" (default: "Markdown")
|
||||
"""
|
||||
|
||||
def __init__(self, config: AdapterConfig) -> None:
|
||||
super().__init__(config)
|
||||
self.application: Optional[Application] = None
|
||||
self.bot: Optional[Bot] = None
|
||||
|
||||
@property
|
||||
def platform_name(self) -> str:
|
||||
return "telegram"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> AdapterCapabilities:
|
||||
return AdapterCapabilities(
|
||||
supports_threads=False,
|
||||
supports_reactions=True,
|
||||
supports_media=True,
|
||||
supports_files=True,
|
||||
supports_markdown=True,
|
||||
max_message_length=4096,
|
||||
chunking_strategy="markdown",
|
||||
)
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
"""Validate Telegram configuration."""
|
||||
if not self.config.credentials:
|
||||
return False
|
||||
|
||||
bot_token = self.config.credentials.get("bot_token", "")
|
||||
return bool(bot_token and len(bot_token) > 20)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Telegram bot."""
|
||||
if not self.validate_config():
|
||||
raise ValueError(
|
||||
"Invalid Telegram configuration. Need bot_token"
|
||||
)
|
||||
|
||||
bot_token = self.config.credentials["bot_token"]
|
||||
|
||||
self.application = (
|
||||
Application.builder().token(bot_token).build()
|
||||
)
|
||||
self.bot = self.application.bot
|
||||
|
||||
self._register_handlers()
|
||||
|
||||
print("[Telegram] Starting bot...")
|
||||
await self.application.initialize()
|
||||
await self.application.start()
|
||||
await self.application.updater.start_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True,
|
||||
)
|
||||
|
||||
self.is_running = True
|
||||
|
||||
me = await self.bot.get_me()
|
||||
print(
|
||||
f"[Telegram] Bot started: @{me.username} ({me.first_name})"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the Telegram bot."""
|
||||
if self.application:
|
||||
print("[Telegram] Stopping bot...")
|
||||
await self.application.updater.stop()
|
||||
await self.application.stop()
|
||||
await self.application.shutdown()
|
||||
self.is_running = False
|
||||
print("[Telegram] Bot stopped")
|
||||
|
||||
def _register_handlers(self) -> None:
|
||||
"""Register Telegram message handlers."""
|
||||
|
||||
async def handle_message(
|
||||
update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
) -> None:
|
||||
"""Handle incoming text messages."""
|
||||
if not update.message or not update.message.text:
|
||||
return
|
||||
|
||||
if not self._is_user_allowed(update.effective_user.id):
|
||||
await update.message.reply_text(
|
||||
"Sorry, you are not authorized to use this bot."
|
||||
)
|
||||
return
|
||||
|
||||
user = update.effective_user
|
||||
message = update.message
|
||||
|
||||
reply_to_id = None
|
||||
if message.reply_to_message:
|
||||
reply_to_id = str(message.reply_to_message.message_id)
|
||||
|
||||
# Sanitize username: replace spaces/special chars with underscores
|
||||
raw_username = user.username or user.first_name or str(user.id)
|
||||
sanitized_username = "".join(
|
||||
c if c.isalnum() or c in "-_" else "_" for c in raw_username
|
||||
)
|
||||
|
||||
inbound_msg = InboundMessage(
|
||||
platform="telegram",
|
||||
user_id=str(user.id),
|
||||
username=sanitized_username,
|
||||
text=message.text,
|
||||
channel_id=str(message.chat.id),
|
||||
thread_id=None,
|
||||
reply_to_id=reply_to_id,
|
||||
message_type=MessageType.TEXT,
|
||||
metadata={
|
||||
"message_id": message.message_id,
|
||||
"chat_type": message.chat.type,
|
||||
"date": (
|
||||
message.date.isoformat()
|
||||
if message.date
|
||||
else None
|
||||
),
|
||||
"user_full_name": user.full_name,
|
||||
"is_bot": user.is_bot,
|
||||
},
|
||||
raw=update,
|
||||
)
|
||||
|
||||
self._dispatch_message(inbound_msg)
|
||||
|
||||
async def handle_start(
|
||||
update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
) -> None:
|
||||
"""Handle /start command."""
|
||||
await update.message.reply_text(
|
||||
"Hello! I'm an AI assistant bot.\n\n"
|
||||
"Just send me a message and I'll respond!"
|
||||
)
|
||||
|
||||
async def handle_help(
|
||||
update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
) -> None:
|
||||
"""Handle /help command."""
|
||||
await update.message.reply_text(
|
||||
"*Ajarbot Help*\n\n"
|
||||
"I'm an AI assistant. You can:\n"
|
||||
"- Send me messages and I'll respond\n"
|
||||
"- Have natural conversations\n"
|
||||
"- Ask me questions\n\n"
|
||||
"Commands:\n"
|
||||
"/start - Start the bot\n"
|
||||
"/help - Show this help message",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
|
||||
self.application.add_handler(
|
||||
CommandHandler("start", handle_start)
|
||||
)
|
||||
self.application.add_handler(
|
||||
CommandHandler("help", handle_help)
|
||||
)
|
||||
self.application.add_handler(
|
||||
MessageHandler(
|
||||
filters.TEXT & ~filters.COMMAND, handle_message
|
||||
)
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self, message: OutboundMessage
|
||||
) -> Dict[str, Any]:
|
||||
"""Send a message to Telegram."""
|
||||
if not self.bot:
|
||||
return {"success": False, "error": "Bot not started"}
|
||||
|
||||
try:
|
||||
chat_id = int(message.channel_id)
|
||||
parse_mode = "Markdown"
|
||||
if self.config.settings:
|
||||
parse_mode = self.config.settings.get(
|
||||
"parse_mode", "Markdown"
|
||||
)
|
||||
|
||||
chunks = self.chunk_text(message.text)
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
for chunk in chunks:
|
||||
reply_to_id = (
|
||||
int(message.reply_to_id)
|
||||
if message.reply_to_id
|
||||
else None
|
||||
)
|
||||
|
||||
sent_message = await self.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=chunk,
|
||||
parse_mode=parse_mode,
|
||||
reply_to_message_id=reply_to_id,
|
||||
)
|
||||
|
||||
results.append({
|
||||
"message_id": sent_message.message_id,
|
||||
"chat_id": sent_message.chat_id,
|
||||
"date": (
|
||||
sent_message.date.isoformat()
|
||||
if sent_message.date
|
||||
else None
|
||||
),
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": results[0]["message_id"],
|
||||
"chunks_sent": len(chunks),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
except TelegramError as e:
|
||||
print(f"[Telegram] Error sending message: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def send_reaction(
|
||||
self, channel_id: str, message_id: str, emoji: str
|
||||
) -> bool:
|
||||
"""Send a reaction to a message."""
|
||||
if not self.bot:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.bot.set_message_reaction(
|
||||
chat_id=int(channel_id),
|
||||
message_id=int(message_id),
|
||||
reaction=emoji,
|
||||
)
|
||||
return True
|
||||
except TelegramError as e:
|
||||
print(f"[Telegram] Error adding reaction: {e}")
|
||||
return False
|
||||
|
||||
async def send_typing_indicator(self, channel_id: str) -> None:
|
||||
"""Show typing indicator."""
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.bot.send_chat_action(
|
||||
chat_id=int(channel_id), action="typing"
|
||||
)
|
||||
except TelegramError as e:
|
||||
print(f"[Telegram] Error sending typing indicator: {e}")
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
"""Perform health check."""
|
||||
base_health = await super().health_check()
|
||||
|
||||
if not self.bot:
|
||||
return {**base_health, "details": "Bot not initialized"}
|
||||
|
||||
try:
|
||||
me = await self.bot.get_me()
|
||||
return {
|
||||
**base_health,
|
||||
"bot_id": me.id,
|
||||
"username": me.username,
|
||||
"first_name": me.first_name,
|
||||
"can_join_groups": me.can_join_groups,
|
||||
"can_read_all_group_messages": (
|
||||
me.can_read_all_group_messages
|
||||
),
|
||||
"connected": True,
|
||||
}
|
||||
except TelegramError as e:
|
||||
return {
|
||||
**base_health,
|
||||
"healthy": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def _is_user_allowed(self, user_id: int) -> bool:
|
||||
"""Check if user is allowed to interact with the bot."""
|
||||
if not self.config.settings:
|
||||
return True
|
||||
|
||||
allowed_users = self.config.settings.get("allowed_users", [])
|
||||
if not allowed_users:
|
||||
return True
|
||||
|
||||
return user_id in allowed_users
|
||||
|
||||
def chunk_text(self, text: str) -> List[str]:
|
||||
"""
|
||||
Override chunk_text for Telegram-specific markdown handling.
|
||||
|
||||
Preserves markdown code blocks and formatting.
|
||||
"""
|
||||
max_len = self.capabilities.max_message_length
|
||||
|
||||
if len(text) <= max_len:
|
||||
return [text]
|
||||
|
||||
chunks: List[str] = []
|
||||
current_chunk = ""
|
||||
|
||||
# Split by code blocks first to preserve them
|
||||
parts = text.split("```")
|
||||
in_code_block = False
|
||||
|
||||
for part in parts:
|
||||
if in_code_block:
|
||||
code_block = f"```{part}```"
|
||||
if len(current_chunk) + len(code_block) > max_len:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
chunks.append(code_block)
|
||||
current_chunk = ""
|
||||
else:
|
||||
current_chunk += code_block
|
||||
else:
|
||||
# Regular text - split by paragraphs
|
||||
paragraphs = part.split("\n\n")
|
||||
for para in paragraphs:
|
||||
if len(current_chunk) + len(para) + 2 > max_len:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
current_chunk = para + "\n\n"
|
||||
else:
|
||||
current_chunk += para + "\n\n"
|
||||
|
||||
in_code_block = not in_code_block
|
||||
|
||||
if current_chunk.strip():
|
||||
chunks.append(current_chunk.strip())
|
||||
|
||||
return chunks if chunks else [text]
|
||||
Reference in New Issue
Block a user