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:
2026-02-13 19:06:28 -07:00
commit a99799bf3d
58 changed files with 11434 additions and 0 deletions

21
adapters/__init__.py Normal file
View 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
View 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
View 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

View 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')}"
)

View File

@@ -0,0 +1,5 @@
"""Slack adapter for ajarbot."""
from .adapter import SlackAdapter
__all__ = ["SlackAdapter"]

278
adapters/slack/adapter.py Normal file
View 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()

View File

@@ -0,0 +1,5 @@
"""Telegram adapter for ajarbot."""
from .adapter import TelegramAdapter
__all__ = ["TelegramAdapter"]

View 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]