Files
agent-desktop/plugins/channel/azure_bot/channel.py
T

1657 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# pylint: disable=too-many-instance-attributes
"""Azure Bot Service channel: Bot Framework webhook + REST API."""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import mimetypes
import os
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib.parse import unquote
import aiohttp
from aiohttp import web
from pineagents.app.channels.renderer import ChannelDisplayConfig
from pineagents.app.channels.base import (
BaseChannel,
AudioContent,
ContentType,
FileContent,
ImageContent,
OnReplySent,
ProcessHandler,
TextContent,
VideoContent,
)
from .auth import AzureBotTokenValidator
from .constants import (
AZURE_BOT_DEFAULT_PORT,
AZURE_BOT_FRAMEWORK_SCOPE,
AZURE_BOT_WATCHDOG_INTERVAL_S,
)
logger = logging.getLogger(__name__)
# Type alias for config
AzureBotChannelConfig = Any
class AzureBotChannel(BaseChannel):
"""Azure Bot Service channel via Bot Framework webhook.
Runs an independent aiohttp HTTP server on a dedicated port
(default 3978) to receive Activity payloads from Azure Bot
Service. Sends replies via the Bot Framework REST API.
Supports Teams, Slack, Web Chat, and other Azure Bot channels.
"""
channel = "azure_bot"
uses_manager_queue = True
# Max file size for Upload API (bytes). The API enforces
# 262144 bytes on the JSON body; base64 + JSON overhead
# makes the safe raw file limit ~180KB.
_UPLOAD_MAX_FILE_SIZE = 180_000
_FILE_TOO_LARGE_I18N = {
"zh": ("⚠️ 文件「{name}」({size_kb}KB" "超过附件大小限制(180KB),无法发送。"),
"en": (
'⚠️ File "{name}" ({size_kb}KB) exceeds '
"the attachment size limit (180KB) "
"and cannot be sent."
),
"id": (
'⚠️ File "{name}" ({size_kb}KB) melebihi '
"batas ukuran lampiran (180KB) "
"dan tidak dapat dikirim."
),
"ru": ("⚠️ Файл «{name}» ({size_kb}KB) " "превышает лимит (180KB)."),
}
def _file_too_large_msg(
self,
name: str,
size_bytes: int,
) -> str:
"""Build i18n file-too-large message."""
lang = self._language
if lang.startswith("zh"):
lang = "zh"
if lang not in self._FILE_TOO_LARGE_I18N:
lang = "en"
size_kb = round(size_bytes / 1024)
return self._FILE_TOO_LARGE_I18N[lang].format(
name=name,
size_kb=size_kb,
)
def __init__(
self,
process: ProcessHandler,
enabled: bool,
app_id: str,
app_password: str,
tenant_id: str,
http_host: str = "0.0.0.0",
http_port: int = AZURE_BOT_DEFAULT_PORT,
bot_prefix: str = "",
media_dir: str = "",
workspace_dir: Optional[Path] = None,
on_reply_sent: OnReplySent = None,
display_config: ChannelDisplayConfig | None = None,
no_text_debounce: bool = True,
dm_policy: str = "open",
group_policy: str = "open",
allow_from: Optional[List[str]] = None,
deny_message: str = "",
require_mention: bool = False,
share_session_in_group: bool = False,
access_control_dm: bool = False,
access_control_group: bool = False,
):
super().__init__(
process,
on_reply_sent=on_reply_sent,
display_config=display_config,
no_text_debounce=no_text_debounce,
dm_policy=dm_policy,
group_policy=group_policy,
allow_from=allow_from,
deny_message=deny_message,
require_mention=require_mention,
access_control_dm=access_control_dm,
access_control_group=access_control_group,
)
self.enabled = enabled
self.bot_prefix = bot_prefix
self._app_id = app_id
self._app_password = app_password
self._tenant_id = tenant_id
self._http_host = http_host
self._http_port = http_port
self._share_session_in_group = share_session_in_group
# HTTP server state (aiohttp, like OneBot)
self._app: Optional[web.Application] = None
self._runner: Optional[web.AppRunner] = None
self._site: Optional[web.TCPSite] = None
# JWT validator for inbound requests
self._token_validator = AzureBotTokenValidator(
app_id=app_id,
tenant_id=tenant_id,
)
# Outbound HTTP session for REST API calls
self._http_session: Optional[aiohttp.ClientSession] = None
# MSAL token cache for outbound calls
self._msal_app: Any = None
self._bot_token: Optional[str] = None
self._bot_token_expires_at: float = 0.0
# Conversation references for proactive messaging
self._conversation_refs: Dict[str, Dict[str, Any]] = {}
# Bot's own channel ID (learned from incoming activities)
self._bot_channel_id: Optional[str] = None
# Workspace dir for persistence
self._workspace_dir: Optional[Path] = (
Path(workspace_dir) if workspace_dir else None
)
# Media directory for downloaded attachments
if media_dir:
self._media_dir = Path(media_dir).expanduser()
elif self._workspace_dir:
self._media_dir = self._workspace_dir / "media"
else:
from pineagents.constant import DEFAULT_MEDIA_DIR
self._media_dir = DEFAULT_MEDIA_DIR
# Watchdog
self._watchdog_task: Optional[asyncio.Task] = None
self._stopping: bool = False
# Background refs persistence (non-blocking, serialized writes)
self._save_lock: asyncio.Lock = asyncio.Lock()
self._save_tasks: set = set()
# ------------------------------------------------------------------
# Factory methods
# ------------------------------------------------------------------
@classmethod
def from_env(
cls,
process: ProcessHandler,
on_reply_sent: OnReplySent = None,
) -> "AzureBotChannel":
allow_from_env = os.getenv(
"AZURE_BOT_ALLOW_FROM",
"",
)
allow_from = (
[s.strip() for s in allow_from_env.split(",") if s.strip()]
if allow_from_env
else []
)
return cls(
process=process,
enabled=(
os.getenv(
"AZURE_BOT_CHANNEL_ENABLED",
"0",
)
== "1"
),
app_id=os.getenv("AZURE_BOT_APP_ID", ""),
app_password=os.getenv(
"AZURE_BOT_APP_PASSWORD",
"",
),
tenant_id=os.getenv(
"AZURE_BOT_TENANT_ID",
"",
),
http_host=os.getenv(
"AZURE_BOT_HTTP_HOST",
"0.0.0.0",
),
http_port=int(
os.getenv(
"AZURE_BOT_HTTP_PORT",
str(AZURE_BOT_DEFAULT_PORT),
),
),
bot_prefix=os.getenv(
"AZURE_BOT_BOT_PREFIX",
"",
),
on_reply_sent=on_reply_sent,
dm_policy=os.getenv(
"AZURE_BOT_DM_POLICY",
"open",
),
group_policy=os.getenv(
"AZURE_BOT_GROUP_POLICY",
"open",
),
allow_from=allow_from,
deny_message=os.getenv(
"AZURE_BOT_DENY_MESSAGE",
"",
),
require_mention=(
os.getenv(
"AZURE_BOT_REQUIRE_MENTION",
"0",
)
== "1"
),
share_session_in_group=(
os.getenv(
"AZURE_BOT_SHARE_SESSION_IN_GROUP",
"0",
)
== "1"
),
)
@classmethod
def from_config(
cls,
process: ProcessHandler,
config: AzureBotChannelConfig,
on_reply_sent: OnReplySent = None,
display_config: ChannelDisplayConfig | None = None,
no_text_debounce: bool = True,
workspace_dir: Optional[Path] = None,
) -> "AzureBotChannel":
return cls(
process=process,
enabled=getattr(config, "enabled", False),
app_id=getattr(config, "app_id", "") or "",
app_password=getattr(config, "app_password", "") or "",
tenant_id=getattr(config, "tenant_id", "") or "",
http_host=getattr(config, "http_host", "") or "0.0.0.0",
http_port=(
getattr(config, "http_port", None) or AZURE_BOT_DEFAULT_PORT
),
bot_prefix=getattr(config, "bot_prefix", "") or "",
media_dir=getattr(config, "media_dir", "") or "",
workspace_dir=workspace_dir,
on_reply_sent=on_reply_sent,
display_config=display_config
or ChannelDisplayConfig.from_config(config),
no_text_debounce=no_text_debounce,
dm_policy=getattr(config, "dm_policy", "") or "open",
group_policy=getattr(config, "group_policy", "") or "open",
allow_from=getattr(config, "allow_from", None) or [],
deny_message=getattr(config, "deny_message", "") or "",
require_mention=getattr(config, "require_mention", False),
share_session_in_group=bool(
getattr(
config,
"share_session_in_group",
False,
),
),
access_control_dm=bool(
getattr(
config,
"access_control_dm",
False,
),
),
access_control_group=bool(
getattr(
config,
"access_control_group",
False,
),
),
)
# ------------------------------------------------------------------
# Lifecycle: start / stop
# ------------------------------------------------------------------
async def start(self) -> None:
if not self.enabled:
logger.debug("azure_bot channel disabled")
return
if not self._app_id or not self._app_password:
logger.warning(
"azure_bot: app_id or app_password not "
"configured, channel will not start",
)
return
self._stopping = False
self._http_session = aiohttp.ClientSession()
await asyncio.to_thread(self._load_refs_from_disk)
await self._start_http_server()
self._watchdog_task = asyncio.create_task(
self._watchdog_loop(),
)
logger.info(
"azure_bot channel started on %s:%s",
self._http_host,
self._http_port,
)
async def stop(self) -> None:
if not self.enabled:
return
self._stopping = True
if self._watchdog_task and not self._watchdog_task.done():
self._watchdog_task.cancel()
try:
await self._watchdog_task
except asyncio.CancelledError:
pass
self._watchdog_task = None
await self._stop_http_server()
if self._http_session:
await self._http_session.close()
self._http_session = None
# Drain pending background refs saves so the last write is
# not lost on shutdown. A generous timeout is a safety valve so a
# pathological disk stall cannot hang shutdown indefinitely.
if self._save_tasks:
try:
await asyncio.wait_for(
asyncio.gather(
*list(self._save_tasks),
return_exceptions=True,
),
timeout=5.0,
)
except asyncio.TimeoutError:
logger.warning(
"azure_bot: pending refs save timed out on stop",
)
logger.info("azure_bot channel stopped")
# ------------------------------------------------------------------
# HTTP server management (like OneBot pattern)
# ------------------------------------------------------------------
async def _start_http_server(self) -> None:
"""Create and start the aiohttp HTTP server."""
self._app = web.Application()
self._app.router.add_post(
"/api/messages",
self._handle_incoming,
)
self._runner = web.AppRunner(self._app)
await self._runner.setup()
self._site = web.TCPSite(
self._runner,
self._http_host,
self._http_port,
)
try:
await self._site.start()
logger.info(
"azure_bot: HTTP server listening on %s:%s/api/messages",
self._http_host,
self._http_port,
)
except OSError:
logger.warning(
"azure_bot: port %s:%s in use, watchdog will retry",
self._http_host,
self._http_port,
)
self._site = None
try:
await self._runner.cleanup()
except Exception:
pass
self._runner = None
self._app = None
async def _stop_http_server(self) -> None:
"""Tear down the HTTP server."""
if self._site:
try:
await self._site.stop()
except Exception:
pass
self._site = None
if self._runner:
try:
await self._runner.cleanup()
except Exception:
pass
self._runner = None
self._app = None
async def _watchdog_loop(self) -> None:
"""Periodically check server health."""
while not self._stopping:
await asyncio.sleep(
AZURE_BOT_WATCHDOG_INTERVAL_S,
)
if self._stopping:
break
if not await self._is_server_healthy():
logger.warning(
"azure_bot: watchdog detected server "
"not healthy, restarting...",
)
try:
await self._stop_http_server()
await self._start_http_server()
logger.info(
"azure_bot: watchdog restarted server OK",
)
except Exception:
logger.exception(
"azure_bot: watchdog failed to restart",
)
async def _is_server_healthy(self) -> bool:
"""Check if HTTP server is accepting connections."""
if self._site is None:
return False
probe_host = (
"127.0.0.1" if self._http_host == "0.0.0.0" else self._http_host
)
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(
probe_host,
self._http_port,
),
timeout=3.0,
)
writer.close()
await writer.wait_closed()
return True
except (OSError, asyncio.TimeoutError):
return False
# ------------------------------------------------------------------
# Inbound: handle POST /api/messages
# ------------------------------------------------------------------
async def _handle_incoming(
self,
request: web.Request,
) -> web.Response:
"""Handle incoming Activity from Azure Bot Service."""
# 1. Validate JWT *before* reading the request body, so an
# unauthenticated request is rejected without buffering its
# (potentially large) payload into memory. The auth header is
# available without touching the body stream.
auth_header = request.headers.get(
"Authorization",
"",
)
if not await self._token_validator.validate_auth_header(
auth_header,
):
logger.warning(
"azure_bot: JWT validation failed",
)
return web.Response(
status=401,
text="Unauthorized",
)
# 2. Read and parse activity (only for authenticated requests)
try:
raw_body = await request.read()
except Exception:
raw_body = b""
try:
activity = json.loads(raw_body)
except Exception:
return web.Response(
status=400,
text="Bad Request",
)
activity_type = activity.get("type", "")
# 3. Store conversation reference
self._store_conversation_reference(activity)
# 4. Dispatch by activity type
if activity_type == "message":
await self._on_message(activity)
elif activity_type == "conversationUpdate":
await self._on_conversation_update(activity)
elif activity_type == "invoke":
result = await self._on_invoke(activity)
return web.json_response(
result,
status=200,
)
else:
logger.debug(
"azure_bot: ignoring activity type=%s",
activity_type,
)
return web.Response(status=200)
# ------------------------------------------------------------------
# Message handling
# ------------------------------------------------------------------
# Audio MIME types that should be treated as voice
# (sent to speech-to-text pipeline via AudioContent).
_VOICE_MIME_PREFIXES = (
"audio/ogg",
"audio/wav",
"audio/webm",
"audio/amr",
)
async def _on_message(
self,
activity: dict,
) -> None:
"""Handle incoming message activity."""
text = activity.get("text", "") or ""
sender = activity.get("from", {})
sender_id = sender.get(
"aadObjectId",
) or sender.get("id", "")
sender_name = sender.get("name", "")
conversation = activity.get("conversation", {})
conversation_id = conversation.get("id", "")
# Detect group: Teams uses conversationType,
# standard Bot Framework uses isGroup,
# Slack uses channelData.event.channel_type.
conv_type = conversation.get(
"conversationType",
"",
)
is_group = (
conv_type in ("channel", "groupChat")
or conversation.get("isGroup") is True
)
if not is_group:
channel_data = activity.get(
"channelData",
{},
)
slack_msg = channel_data.get(
"SlackMessage",
{},
)
slack_event = slack_msg.get("event", {})
if slack_event.get("channel_type") in (
"channel",
"group",
):
is_group = True
service_url = activity.get("serviceUrl", "")
# Strip bot @mention from text if present
text = self._strip_bot_mention(text, activity)
# Check group mention requirement
if is_group and self.require_mention:
if not self._is_bot_mentioned(activity):
return
# Build content_parts
content_parts: list = []
if text.strip():
content_parts.append(
TextContent(
type=ContentType.TEXT,
text=text.strip(),
),
)
# Handle attachments
attachments = activity.get("attachments", [])
if attachments:
token = await self._get_bot_token() or ""
for att in attachments:
part = await self._parse_attachment_async(
att,
token,
)
if part is not None:
content_parts.append(part)
if not content_parts:
return
# Resolve bot_channel_id from this activity's
# recipient field (per-channel, not global)
recipient = activity.get("recipient", {})
activity_bot_id = recipient.get("id", "")
meta = {
"user_id": sender_id,
"user_name": sender_name,
"conversation_id": conversation_id,
"azure_channel_id": activity.get(
"channelId",
"bot",
),
"is_group": is_group,
"is_dm": not is_group,
"service_url": service_url,
"activity_id": activity.get("id", ""),
"bot_channel_id": activity_bot_id,
}
# Display sender: name#last6 or channelId#last6
suffix = sender_id[-6:] if len(sender_id) >= 6 else sender_id
azure_channel_id = meta.get(
"azure_channel_id",
"bot",
)
display_sender = (
f"{sender_name}#{suffix}"
if sender_name
else f"{azure_channel_id}#{suffix}"
)
native = {
"channel_id": self.channel,
"sender_id": (
"group"
if (is_group and self._share_session_in_group)
else display_sender
),
"acl_sender_id": sender_id,
"content_parts": content_parts,
"meta": meta,
}
if self._enqueue is not None:
self._enqueue(native)
async def _download_attachment(
self,
url: str,
token: str,
filename: str,
) -> Optional[str]:
"""Download attachment to local media_dir.
Bot Framework contentUrl requires Bearer auth.
Returns local file path on success, None on failure.
"""
if not url or not self._http_session:
return None
try:
headers = {"Authorization": f"Bearer {token}"}
async with self._http_session.get(
url,
headers=headers,
) as resp:
if resp.status != 200:
logger.warning(
"azure_bot download attachment "
"failed: status=%d url=%s",
resp.status,
url[:120],
)
return None
data = await resp.read()
if not data:
return None
self._media_dir.mkdir(
parents=True,
exist_ok=True,
)
safe_name = (
"".join(c for c in filename if c.isalnum() or c in "-_.")
or "file"
)
path = self._media_dir / safe_name
# Avoid name collision
if path.exists():
stem = path.stem
suffix = path.suffix
path = self._media_dir / (f"{stem}_{int(time.time())}{suffix}")
await asyncio.to_thread(
path.write_bytes,
data,
)
return str(path)
except Exception:
logger.exception(
"azure_bot _download_attachment failed",
)
return None
async def _parse_attachment_async(
self,
att: dict,
token: str,
) -> Any:
"""Parse and download a Bot Framework attachment.
Downloads to local storage, returns content part
(ImageContent, AudioContent, FileContent) or None.
"""
content_type = att.get("contentType", "")
content_url = att.get("contentUrl", "")
name = att.get("name", "") or "file"
if not content_url:
return None
# Download to local
local_path = await self._download_attachment(
content_url,
token,
name,
)
if not local_path:
# Fallback: pass URL directly (may not work
# for agent but better than dropping)
local_path = content_url
# Image
if content_type.startswith("image/"):
return ImageContent(
type=ContentType.IMAGE,
image_url=local_path,
)
# Video
if content_type.startswith("video/"):
return VideoContent(
type=ContentType.VIDEO,
video_url=local_path,
)
# Audio: voice-like formats -> AudioContent
if content_type.startswith("audio/"):
if any(
content_type.startswith(p) for p in self._VOICE_MIME_PREFIXES
):
return AudioContent(
type=ContentType.AUDIO,
data=local_path,
format=content_type.split("/")[-1],
)
return FileContent(
type=ContentType.FILE,
file_url=local_path,
filename=name or None,
)
# Everything else -> file
return FileContent(
type=ContentType.FILE,
file_url=local_path,
filename=name or None,
)
async def _on_conversation_update(
self,
activity: dict, # pylint: disable=unused-argument
) -> None:
"""Handle conversationUpdate (e.g. bot added to chat)."""
async def _on_invoke(
self,
activity: dict, # pylint: disable=unused-argument
) -> dict:
"""Handle invoke activities (e.g. adaptive card actions)."""
return {"status": 200}
# ------------------------------------------------------------------
# AgentRequest construction
# ------------------------------------------------------------------
def build_agent_request_from_native(
self,
native_payload: Any,
) -> Any:
"""Build AgentRequest from native dict."""
payload = native_payload if isinstance(native_payload, dict) else {}
channel_id = payload.get("channel_id") or self.channel
sender_id = payload.get("sender_id") or ""
content_parts = payload.get("content_parts") or []
meta = dict(payload.get("meta") or {})
session_id = self.resolve_session_id(
sender_id,
meta,
)
request = self.build_agent_request_from_user_content(
channel_id=channel_id,
sender_id=sender_id,
session_id=session_id,
content_parts=content_parts,
channel_meta=meta,
)
setattr(request, "channel_meta", meta)
return request
def resolve_session_id(
self,
sender_id: str,
channel_meta: Optional[Dict[str, Any]] = None,
) -> str:
"""Map sender to session_id.
Format: azure_{channelId}#{conv_id_last10}
Same for both DM and group (group shared via
sender_id="group", not session_id).
"""
meta = channel_meta or {}
azure_channel = meta.get(
"azure_channel_id",
"bot",
)
conv_id = meta.get("conversation_id", "")
conv_suffix = conv_id[-10:] if len(conv_id) >= 10 else conv_id
return f"azure_{azure_channel}#{conv_suffix}"
# ------------------------------------------------------------------
# Outbound: send messages via Bot Framework REST API
# ------------------------------------------------------------------
async def send(
self,
to_handle: str,
text: str,
meta: Optional[Dict[str, Any]] = None,
) -> None:
"""Send a text message via Bot Framework REST."""
if not self.enabled:
return
meta = meta or {}
service_url = meta.get("service_url", "")
conversation_id = meta.get(
"conversation_id",
"",
)
# Resolve bot_channel_id: meta > ref > global > app_id
bot_id = meta.get("bot_channel_id", "")
if not service_url or not conversation_id:
ref = self._find_ref(to_handle, meta)
if ref:
service_url = ref.get(
"service_url",
"",
)
conversation_id = ref.get(
"conversation_id",
"",
)
if not bot_id:
bot_id = ref.get(
"bot_channel_id",
"",
)
if not bot_id:
bot_id = self._bot_channel_id or self._app_id
if not service_url or not conversation_id:
logger.warning(
"azure_bot send: no service_url or "
"conversation_id for to_handle=%s",
to_handle,
)
return
token = await self._get_bot_token()
if not token:
logger.warning(
"azure_bot send: failed to get bot token",
)
return
url = (
f"{service_url.rstrip('/')}"
f"/v3/conversations/"
f"{conversation_id}/activities"
)
payload = {
"type": "message",
"from": {
"id": bot_id,
},
"text": text,
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
try:
if self._http_session is None or self._http_session.closed:
self._http_session = aiohttp.ClientSession()
async with self._http_session.post(
url,
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status not in (200, 201, 202):
body = await resp.text()
logger.warning(
"azure_bot send: failed status=%d body=%s",
resp.status,
body[:200],
)
except Exception:
logger.exception(
"azure_bot send: error sending message",
)
async def _resolve_attachment_for_part(
self,
part: Any,
part_type: Any,
service_url: str,
conversation_id: str,
token: str,
) -> Optional[Dict[str, Any]]:
"""Resolve attachment dict from a content part."""
if part_type == ContentType.IMAGE:
url = getattr(part, "image_url", None)
if url:
return await self._upload_and_get_attachment(
url,
service_url,
conversation_id,
token,
"image/png",
"image.png",
)
elif part_type == ContentType.VIDEO:
url = getattr(part, "video_url", None)
if url:
return await self._upload_and_get_attachment(
url,
service_url,
conversation_id,
token,
"video/mp4",
"video.mp4",
)
elif part_type == ContentType.AUDIO:
url = getattr(part, "data", None)
if url:
return await self._upload_and_get_attachment(
url,
service_url,
conversation_id,
token,
"audio/mpeg",
"audio.mp3",
)
elif part_type == ContentType.FILE:
url = getattr(part, "file_url", None)
name = getattr(part, "filename", None) or "file"
if url:
return await self._upload_and_get_attachment(
url,
service_url,
conversation_id,
token,
"application/octet-stream",
name,
)
return None
# ------------------------------------------------------------------
# Media sending via Bot Framework REST API
# ------------------------------------------------------------------
async def send_media(
self,
to_handle: str,
part: Any,
meta: Optional[Dict[str, Any]] = None,
) -> None:
"""Send a media attachment via Bot Framework."""
if not self.enabled:
return
meta = meta or {}
service_url = meta.get("service_url", "")
conversation_id = meta.get(
"conversation_id",
"",
)
# Resolve bot_channel_id: meta > ref > global > app_id
bot_id = meta.get("bot_channel_id", "")
if not service_url or not conversation_id:
ref = self._find_ref(to_handle, meta)
if ref:
service_url = ref.get(
"service_url",
"",
)
conversation_id = ref.get(
"conversation_id",
"",
)
if not bot_id:
bot_id = ref.get(
"bot_channel_id",
"",
)
if not bot_id:
bot_id = self._bot_channel_id or self._app_id
if not service_url or not conversation_id:
logger.warning(
"azure_bot send_media: no service_url "
"or conversation_id, to_handle=%s",
to_handle,
)
return
token = await self._get_bot_token()
if not token:
return
part_type = getattr(part, "type", None)
attachment = await self._resolve_attachment_for_part(
part,
part_type,
service_url,
conversation_id,
token,
)
if not attachment:
logger.warning(
"azure_bot send_media: no attachment resolved, part_type=%s",
part_type,
)
return
# Handle file-too-large: send text fallback
if attachment.get("_too_large"):
msg = self._file_too_large_msg(
attachment["name"],
attachment["size"],
)
await self.send(
to_handle,
msg,
meta,
)
return
url = (
f"{service_url.rstrip('/')}"
f"/v3/conversations/"
f"{conversation_id}/activities"
)
payload = {
"type": "message",
"from": {
"id": bot_id,
},
"attachments": [attachment],
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
try:
if self._http_session is None or self._http_session.closed:
self._http_session = aiohttp.ClientSession()
async with self._http_session.post(
url,
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status not in (200, 201, 202):
body = await resp.text()
logger.warning(
"azure_bot send_media: failed status=%d body=%s",
resp.status,
body[:200],
)
except Exception:
logger.exception(
"azure_bot send_media: error",
)
def _make_attachment(
self,
url_or_path: str,
default_content_type: str,
default_name: str,
) -> Optional[Dict[str, Any]]:
# Strip file:// URI scheme if present
actual = url_or_path
if actual.startswith("file:///"):
actual = unquote(actual[7:])
elif actual.startswith("file://"):
actual = unquote(actual[7:])
path = Path(actual)
if path.is_file():
try:
mime = (
mimetypes.guess_type(str(path))[0] or default_content_type
)
with open(path, "rb") as f:
raw = f.read()
data = base64.b64encode(raw).decode()
return {
"contentType": mime,
"contentUrl": (f"data:{mime};base64,{data}"),
"name": path.name,
}
except Exception:
logger.warning(
"azure_bot: failed to read local file %s",
url_or_path,
)
return None
# Otherwise treat as URL
return {
"contentType": default_content_type,
"contentUrl": url_or_path,
"name": default_name,
}
async def _upload_and_get_attachment(
self,
url_or_path: str,
service_url: str,
conversation_id: str,
token: str,
default_content_type: str,
default_name: str,
) -> Optional[Dict[str, Any]]:
"""Upload local file via Bot Framework Upload API.
Returns attachment dict with public contentUrl.
Falls back to _make_attachment (base64) on failure.
"""
# Strip file:// URI scheme if present
actual = url_or_path
if actual.startswith("file:///"):
actual = unquote(actual[7:])
elif actual.startswith("file://"):
actual = unquote(actual[7:])
path = Path(actual)
if not path.is_file():
# It's a URL, use directly
return {
"contentType": default_content_type,
"contentUrl": url_or_path,
"name": default_name,
}
mime = mimetypes.guess_type(str(path))[0] or default_content_type
try:
raw = await asyncio.to_thread(path.read_bytes)
except Exception:
logger.warning(
"azure_bot: failed to read file %s",
url_or_path,
)
return None
# Check file size limit
if len(raw) > self._UPLOAD_MAX_FILE_SIZE:
logger.info(
"azure_bot: file %s too large "
"(%d bytes), sending text fallback",
path.name,
len(raw),
)
# Return a special marker so send_media
# can send a text message instead
return {
"_too_large": True,
"name": path.name,
"size": len(raw),
}
# Upload via Bot Framework API
upload_url = (
f"{service_url.rstrip('/')}"
f"/v3/conversations/"
f"{conversation_id}/attachments"
)
upload_body = {
"type": mime,
"name": path.name,
"originalBase64": base64.b64encode(
raw,
).decode(),
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
try:
if self._http_session is None or self._http_session.closed:
self._http_session = aiohttp.ClientSession()
async with self._http_session.post(
upload_url,
json=upload_body,
headers=headers,
timeout=aiohttp.ClientTimeout(total=60),
) as resp:
if resp.status in (200, 201, 202):
data = await resp.json()
att_id = data.get("id", "")
if att_id:
content_url = (
f"{service_url.rstrip('/')}"
f"/v3/attachments/"
f"{att_id}/views/original"
)
return {
"contentType": mime,
"contentUrl": content_url,
"name": path.name,
}
else:
body = await resp.text()
logger.warning(
"azure_bot upload: failed status=%d body=%s",
resp.status,
body[:200],
)
except Exception:
logger.warning(
"azure_bot upload: error, falling back to base64",
exc_info=True,
)
# Fallback to base64 inline (offload sync file read to a thread)
return await asyncio.to_thread(
self._make_attachment,
url_or_path,
default_content_type,
default_name,
)
# ------------------------------------------------------------------
# Token management (MSAL client credentials)
# ------------------------------------------------------------------
async def _get_bot_token(self) -> Optional[str]:
"""Get a valid bot token for outbound calls."""
now = time.time()
if self._bot_token and self._bot_token_expires_at > now + 60:
return self._bot_token
try:
import msal # type: ignore
if self._msal_app is None:
authority = (
"https://login.microsoftonline.com/" f"{self._tenant_id}"
)
self._msal_app = msal.ConfidentialClientApplication(
self._app_id,
authority=authority,
client_credential=(self._app_password),
)
result = self._msal_app.acquire_token_for_client(
scopes=[AZURE_BOT_FRAMEWORK_SCOPE],
)
if result and "access_token" in result:
self._bot_token = result["access_token"]
expires_in = result.get(
"expires_in",
3600,
)
self._bot_token_expires_at = now + expires_in
return self._bot_token
else:
# Log only the error code at warning level. The full
# error_description may carry tenant/client/config
# details, so keep it at debug level only.
error_code = result.get(
"error",
"unknown_error",
)
logger.warning(
"azure_bot: MSAL token acquisition failed: %s",
error_code,
)
logger.debug(
"azure_bot: MSAL error detail: %s",
result.get("error_description", ""),
)
return None
except Exception:
logger.exception(
"azure_bot: error acquiring bot token",
)
return None
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _find_ref(
self,
to_handle: str,
meta: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""Find conversation ref by to_handle + meta.
Group: key = session_id (shared).
DM: key = session_id:user_id (per-user).
Tries exact match, then session_id-only fallback
(for group isolated sessions where to_handle =
session_id:user_id but ref stored as session_id),
then build from meta, then partial match.
"""
# 1. Direct key match (DM: session_id:user_id,
# group shared: session_id)
ref = self._conversation_refs.get(to_handle)
if ref:
return ref
# 2. Group fallback: try session_id part only
# (to_handle may be session_id:user_id but
# group ref stored as session_id)
if ":" in to_handle:
sid_part = to_handle.split(":", 1)[0]
ref = self._conversation_refs.get(sid_part)
if ref:
return ref
# 3. Build key from meta
m = meta or {}
conv_id = m.get("conversation_id", "")
azure_ch = m.get("azure_channel_id", "bot")
if conv_id:
conv_suffix = conv_id[-10:] if len(conv_id) >= 10 else conv_id
session_id = f"azure_{azure_ch}#{conv_suffix}"
# Try group key first (session_id only)
ref = self._conversation_refs.get(session_id)
if ref:
return ref
# Try DM key (session_id:to_handle)
key = f"{session_id}:{to_handle}"
ref = self._conversation_refs.get(key)
if ref:
return ref
# 4. Partial match: find any ref containing
# to_handle or session_id part in key
sid_part = (
to_handle.split(":", 1)[0] if ":" in to_handle else to_handle
)
for k, v in self._conversation_refs.items():
if to_handle in k or sid_part in k:
return v
return None
def _store_conversation_reference(
self,
activity: dict,
) -> None:
"""Store conversation reference and persist.
Group chat: key = session_id (shared, one entry per
conversation since all users share service_url /
conversation_id / bot_channel_id).
DM: key = session_id:user_id (per-user, avoids
collision when different conversations share suffix).
"""
sender = activity.get("from", {})
sender_id = sender.get(
"aadObjectId",
) or sender.get("id", "")
conversation = activity.get("conversation", {})
conversation_id = conversation.get("id", "")
service_url = activity.get("serviceUrl", "")
azure_channel_id = activity.get(
"channelId",
"bot",
)
# Learn bot's own channel ID from recipient field
recipient = activity.get("recipient", {})
if recipient.get("id"):
self._bot_channel_id = recipient["id"]
if sender_id and conversation_id and service_url:
# Detect group: same logic as _on_message
conv_type = conversation.get(
"conversationType",
"",
)
is_group = (
conv_type in ("channel", "groupChat")
or conversation.get("isGroup") is True
)
if not is_group:
channel_data = activity.get(
"channelData",
{},
)
slack_msg = channel_data.get(
"SlackMessage",
{},
)
slack_event = slack_msg.get(
"event",
{},
)
if slack_event.get(
"channel_type",
) in ("channel", "group"):
is_group = True
conv_suffix = (
conversation_id[-10:]
if len(conversation_id) >= 10
else conversation_id
)
prefix = f"azure_{azure_channel_id}"
session_id = f"{prefix}#{conv_suffix}"
# Group: key = session_id only (shared)
# DM: key = session_id:user_id (per-user)
if is_group:
key = session_id
else:
sender_suffix = (
sender_id[-6:] if len(sender_id) >= 6 else sender_id
)
sender_name_val = sender.get("name", "")
display_user = (
f"{sender_name_val}#{sender_suffix}"
if sender_name_val
else f"{azure_channel_id}#{sender_suffix}"
)
key = f"{session_id}:{display_user}"
self._conversation_refs[key] = {
"service_url": service_url,
"conversation_id": conversation_id,
"azure_channel_id": azure_channel_id,
"bot_channel_id": self._bot_channel_id,
"is_group": is_group,
}
self._schedule_save_refs()
def _refs_store_path(self) -> Path:
"""Path to persist conversation refs."""
if self._workspace_dir:
return self._workspace_dir / "azure_bot_refs.json"
from pineagents.constant import WORKING_DIR
return WORKING_DIR / "azure_bot_refs.json"
def _load_refs_from_disk(self) -> None:
"""Load conversation refs from disk."""
path = self._refs_store_path()
if not path.is_file():
return
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
for k, v in data.items():
if isinstance(v, dict):
self._conversation_refs[k] = v
# Restore bot_channel_id
if not self._bot_channel_id and v.get(
"bot_channel_id",
):
self._bot_channel_id = v["bot_channel_id"]
except Exception:
logger.debug(
"azure_bot: load refs from %s failed",
path,
exc_info=True,
)
def _save_refs_to_disk(self) -> None:
"""Persist conversation refs to disk."""
path = self._refs_store_path()
try:
path.parent.mkdir(
parents=True,
exist_ok=True,
)
with open(path, "w", encoding="utf-8") as f:
json.dump(
self._conversation_refs,
f,
indent=2,
ensure_ascii=False,
)
except Exception:
logger.debug(
"azure_bot: save refs to %s failed",
path,
exc_info=True,
)
def _schedule_save_refs(self) -> None:
"""Schedule a non-blocking, serialized refs persistence.
The sync disk write runs in a worker thread so the event loop
is never blocked, and writes are serialized via a lock to avoid
concurrent writers corrupting the JSON file. Falls back to a
direct sync write when no event loop is running (e.g. tests).
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
self._save_refs_to_disk()
return
task = loop.create_task(self._save_refs_async())
self._save_tasks.add(task)
task.add_done_callback(self._save_tasks.discard)
async def _save_refs_async(self) -> None:
"""Persist refs off the event loop, serialized by a lock."""
async with self._save_lock:
await asyncio.to_thread(self._save_refs_to_disk)
def _is_bot_mentioned(
self,
activity: dict,
) -> bool:
"""Check if bot is mentioned in the activity."""
entities = activity.get("entities", [])
for entity in entities:
if entity.get("type") != "mention":
continue
mentioned = entity.get("mentioned", {})
mentioned_id = mentioned.get("id", "")
if mentioned_id in (
self._app_id,
self._bot_channel_id,
):
return True
return False
def _strip_bot_mention(
self,
text: str,
activity: dict,
) -> str:
"""Remove bot @mention tags from text."""
entities = activity.get("entities", [])
for entity in entities:
if entity.get("type") != "mention":
continue
mentioned = entity.get("mentioned", {})
mid = mentioned.get("id", "")
# Match by app_id or bot_channel_id
if mid in (
self._app_id,
self._bot_channel_id,
):
mention_text = entity.get("text", "")
if mention_text and mention_text in text:
text = text.replace(
mention_text,
"",
).strip()
return text
def to_handle_from_target(
self,
*,
user_id: str,
session_id: str,
) -> str:
"""Map cron target to to_handle.
Group (user_id == "group"): returns session_id
only, matching the shared group ref key.
DM / isolated: returns session_id:user_id,
matching the per-user ref key. _find_ref will
fallback to session_id if group ref exists.
"""
if user_id == "group":
return session_id
return f"{session_id}:{user_id}"
async def health_check(self) -> Dict[str, Any]:
"""Return health status for this channel."""
healthy = await self._is_server_healthy()
return {
"channel": self.channel,
"status": ("healthy" if healthy else "unhealthy"),
"detail": (
f"HTTP server on port {self._http_port}"
if healthy
else "HTTP server not responding"
),
}