Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,19 @@ mcp = [
"mcp>=0.9",
]

slack = [
"slack_sdk>=3.0.0",
]

discord = [
"discord.py[voice]>=2.0.0",

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The discord extra depends on discord.py[voice], which pulls in additional heavy, platform-specific dependencies (e.g., voice/audio libs) that aren’t used by the Discord channel adapter here. Consider depending on plain discord.py unless voice support is explicitly required.

Suggested change
"discord.py[voice]>=2.0.0",
"discord.py>=2.0.0",

Copilot uses AI. Check for mistakes.
]

teams = [
"botbuilder-core>=4.0.0",
"botbuilder-schema>=4.0.0",
]

all = [
"aiui[memory]",
"aiui[knowledge]",
Expand All @@ -113,6 +126,9 @@ all = [
"aiui[llama-index]",
"aiui[semantic-kernel]",
"aiui[mcp]",
"aiui[slack]",
"aiui[discord]",
"aiui[teams]",
]

dev = [
Expand Down Expand Up @@ -163,6 +179,12 @@ select = ["E", "F", "I", "W"]
# E741 — ambiguous single-letter names in legacy migration helpers.
ignore = ["E501", "E402", "E731", "E741"]

[tool.ruff.lint.per-file-ignores]
# Test files routinely bind mock variables that are never referenced
# again (they exist purely to keep the context-manager alive or to
# document what is being mocked).
"tests/**/*.py" = ["F841"]

Comment on lines +182 to +187

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a global Ruff per-file ignore for F841 across all tests can hide legitimate unused-variable bugs. In these new tests the unused as mock_init bindings (and similar) can be removed or renamed to _ instead, avoiding the need for a repository-wide ignore.

Suggested change
[tool.ruff.lint.per-file-ignores]
# Test files routinely bind mock variables that are never referenced
# again (they exist purely to keep the context-manager alive or to
# document what is being mocked).
"tests/**/*.py" = ["F841"]

Copilot uses AI. Check for mistakes.
[tool.mypy]
python_version = "3.9"
strict = true
Expand Down
13 changes: 13 additions & 0 deletions src/praisonaiui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ def __getattr__(name: str):
"on_mcp_connect",
"on_mcp_disconnect",
}
_channel_attrs = {
"current_channel",
"current_user",
"on_slack_reaction_added",
}
_server_attrs = {
"register_agent",
"register_page",
Expand Down Expand Up @@ -210,6 +215,10 @@ def __getattr__(name: str):
from praisonaiui.features import mcp

return getattr(mcp, name)
if name in _channel_attrs:
from praisonaiui.features import platform_adapters

return getattr(platform_adapters, name)
Comment on lines +218 to +221

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description/acceptance criteria claim @aiui.reply handlers can receive messages from Slack/Discord/Teams, but the new platform adapters aren’t connected to the existing callback system (praisonaiui.callbacks.reply / server callback dispatch). Currently current_channel/current_user are exported, but nothing wires adapter inbound events into the reply callback pipeline. Either add the missing integration (e.g., register the reply callback(s) as adapter message handlers and start enabled adapters from config) or adjust the PR description/scope to match what’s actually implemented.

Copilot uses AI. Check for mistakes.
if name in _server_attrs:
from praisonaiui import server

Expand Down Expand Up @@ -351,6 +360,10 @@ def __getattr__(name: str):
"MCPServer",
"on_mcp_connect",
"on_mcp_disconnect",
# Channel platform adapters (Slack / Discord / Teams)
"current_channel",
"current_user",
"on_slack_reaction_added",
# Feature protocol
"BaseFeatureProtocol",
"register_feature",
Expand Down
48 changes: 48 additions & 0 deletions src/praisonaiui/features/platform_adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Platform channel adapters for Slack, Discord, and Teams.

This package provides protocol-driven channel adapters that integrate with
the existing PraisonAIUI message pipeline, allowing handlers to work across
all supported platforms seamlessly.
"""

from ._base import (
BaseChannelAdapter,
ChannelAdapterFactory,
ChannelAdapterProtocol,
channel_context,
current_channel,
current_user,
)

# Lazy imports for platform adapters to avoid heavy dependencies
__all__ = [
"ChannelAdapterProtocol",
"BaseChannelAdapter",
"ChannelAdapterFactory",
"current_channel",
"current_user",
"channel_context",
"on_slack_reaction_added",
]


def __getattr__(name: str):
"""Lazy import platform-specific functionality."""
if name == "on_slack_reaction_added":
from .slack import on_slack_reaction_added

return on_slack_reaction_added
elif name == "SlackChannelAdapter":
from .slack import SlackChannelAdapter

return SlackChannelAdapter
elif name == "DiscordChannelAdapter":
from .discord import DiscordChannelAdapter

return DiscordChannelAdapter
elif name == "TeamsChannelAdapter":
from .teams import TeamsChannelAdapter

return TeamsChannelAdapter

raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
195 changes: 195 additions & 0 deletions src/praisonaiui/features/platform_adapters/_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Base channel adapter protocol for platform connectors.

Architecture:
ChannelAdapterProtocol — protocol for platform adapters
ChannelAdapterFactory — lazy-loads enabled channels from config
"""

from __future__ import annotations

import logging
from contextlib import asynccontextmanager
from typing import Any, Dict, Optional, Protocol, runtime_checkable

logger = logging.getLogger(__name__)


@runtime_checkable
class ChannelAdapterProtocol(Protocol):
"""Protocol interface for platform channel adapters."""

platform: str

async def send_message(self, channel_id: str, content: str, **kwargs) -> None:
"""Send a message to the specified channel."""
...

async def stream_token(self, channel_id: str, message_id: str, token: str) -> None:
"""Stream a token to an existing message (for real-time updates)."""
...

async def start(self) -> None:
"""Start the channel adapter (connect to platform)."""
...

async def stop(self) -> None:
"""Stop the channel adapter (disconnect from platform)."""
...

def is_running(self) -> bool:
"""Check if the adapter is currently running."""
...


class BaseChannelAdapter:
"""Base implementation for channel adapters with common functionality."""

def __init__(self, platform: str, config: Dict[str, Any]):
self.platform = platform
self.config = config
self._running = False
self._message_handlers: list = []

def add_message_handler(self, handler) -> None:
"""Add a message handler callback."""
self._message_handlers.append(handler)

def remove_message_handler(self, handler) -> None:
"""Remove a message handler callback."""
if handler in self._message_handlers:
self._message_handlers.remove(handler)

async def _dispatch_message(self, message: Dict[str, Any]) -> None:
"""Dispatch incoming message to all registered handlers."""
for handler in self._message_handlers:
try:
await handler(message)
except Exception as e:
logger.error(f"Error in message handler: {e}")

def is_running(self) -> bool:
"""Check if the adapter is currently running."""
return self._running


class ChannelAdapterFactory:
"""Factory for loading and managing channel adapters based on config."""

_adapters: Dict[str, ChannelAdapterProtocol] = {}
_adapter_configs: Dict[str, Dict[str, Any]] = {}

@classmethod
def register_adapter_config(cls, platform: str, config: Dict[str, Any]) -> None:
"""Register configuration for a platform adapter."""
cls._adapter_configs[platform] = config

@classmethod
async def get_adapter(cls, platform: str) -> Optional[ChannelAdapterProtocol]:
"""Get or create an adapter for the specified platform."""
if platform in cls._adapters:
return cls._adapters[platform]

config = cls._adapter_configs.get(platform)
if not config or not config.get("enabled", False):
return None

adapter = await cls._create_adapter(platform, config)
if adapter:
cls._adapters[platform] = adapter

return adapter

@classmethod
async def _create_adapter(
cls, platform: str, config: Dict[str, Any]
) -> Optional[ChannelAdapterProtocol]:
"""Create a platform-specific adapter (lazy import)."""
try:
if platform == "slack":
from .slack import SlackChannelAdapter

return SlackChannelAdapter(config)
elif platform == "discord":
from .discord import DiscordChannelAdapter

return DiscordChannelAdapter(config)
elif platform == "teams":
from .teams import TeamsChannelAdapter

return TeamsChannelAdapter(config)
else:
logger.warning(f"Unknown platform: {platform}")
return None
except ImportError as e:
logger.warning(f"Failed to import {platform} adapter: {e}")
return None
except Exception as e:
logger.error(f"Failed to create {platform} adapter: {e}")
return None

@classmethod
async def start_all_enabled(cls) -> Dict[str, str]:
"""Start all enabled adapters. Returns dict of platform -> error (if any)."""
errors = {}

for platform, config in cls._adapter_configs.items():
if not config.get("enabled", False):
continue

try:
adapter = await cls.get_adapter(platform)
if adapter:
await adapter.start()
logger.info(f"Started {platform} channel adapter")
else:
errors[platform] = f"Failed to create {platform} adapter"
except Exception as e:
error_msg = str(e)
errors[platform] = error_msg
logger.error(f"Failed to start {platform} adapter: {error_msg}")

return errors

@classmethod
async def stop_all(cls) -> None:
"""Stop all running adapters."""
for platform, adapter in cls._adapters.items():
try:
if adapter.is_running():
await adapter.stop()
logger.info(f"Stopped {platform} channel adapter")
except Exception as e:
logger.error(f"Error stopping {platform} adapter: {e}")

cls._adapters.clear()


# Context variables for tracking current channel/user
from contextvars import ContextVar

_current_channel: ContextVar[Optional[Dict[str, Any]]] = ContextVar("current_channel", default=None)
_current_user: ContextVar[Optional[Dict[str, Any]]] = ContextVar("current_user", default=None)


def current_channel() -> Optional[Dict[str, Any]]:
"""Get the current channel context."""
return _current_channel.get()


def current_user() -> Optional[Dict[str, Any]]:
"""Get the current user context."""
return _current_user.get()


@asynccontextmanager
async def channel_context(channel: Dict[str, Any], user: Optional[Dict[str, Any]] = None):
"""Context manager for setting current channel and user."""
channel_token = _current_channel.set(channel)
user_token = _current_user.set(user) if user else None

try:
yield
finally:
_current_channel.reset(channel_token)
if user_token:
_current_user.reset(user_token)
Loading
Loading