Skip to content
Closed
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
44 changes: 44 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class Platform(Enum):
DINGTALK = "dingtalk"
API_SERVER = "api_server"
WEBHOOK = "webhook"
MSGRAPH_WEBHOOK = "msgraph_webhook"
FEISHU = "feishu"
WECOM = "wecom"
WECOM_CALLBACK = "wecom_callback"
Expand Down Expand Up @@ -376,6 +377,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig":
Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")),
Platform.API_SERVER: lambda cfg: True,
Platform.WEBHOOK: lambda cfg: True,
Platform.MSGRAPH_WEBHOOK: lambda cfg: True,
Platform.FEISHU: lambda cfg: bool(cfg.extra.get("app_id")),
Platform.WECOM: lambda cfg: bool(cfg.extra.get("bot_id")),
Platform.WECOM_CALLBACK: lambda cfg: bool(
Expand Down Expand Up @@ -1366,6 +1368,48 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
if webhook_secret:
config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret

# Microsoft Graph webhook platform
msgraph_webhook_enabled = os.getenv("MSGRAPH_WEBHOOK_ENABLED", "").lower() in (
"true",
"1",
"yes",
)
msgraph_webhook_port = os.getenv("MSGRAPH_WEBHOOK_PORT")
msgraph_webhook_client_state = os.getenv("MSGRAPH_WEBHOOK_CLIENT_STATE", "")
msgraph_webhook_resources = os.getenv("MSGRAPH_WEBHOOK_ACCEPTED_RESOURCES", "")
if (
msgraph_webhook_enabled
or Platform.MSGRAPH_WEBHOOK in config.platforms
or msgraph_webhook_port
or msgraph_webhook_client_state
or msgraph_webhook_resources
):
if Platform.MSGRAPH_WEBHOOK not in config.platforms:
config.platforms[Platform.MSGRAPH_WEBHOOK] = PlatformConfig()
if msgraph_webhook_enabled:
config.platforms[Platform.MSGRAPH_WEBHOOK].enabled = True
if msgraph_webhook_port:
try:
config.platforms[Platform.MSGRAPH_WEBHOOK].extra["port"] = int(
msgraph_webhook_port
)
except ValueError:
pass
if msgraph_webhook_client_state:
config.platforms[Platform.MSGRAPH_WEBHOOK].extra["client_state"] = (
msgraph_webhook_client_state
)
if msgraph_webhook_resources:
resources = [
resource.strip()
for resource in msgraph_webhook_resources.split(",")
if resource.strip()
]
if resources:
config.platforms[Platform.MSGRAPH_WEBHOOK].extra[
"accepted_resources"
] = resources

# DingTalk
dingtalk_client_id = os.getenv("DINGTALK_CLIENT_ID")
dingtalk_client_secret = os.getenv("DINGTALK_CLIENT_SECRET")
Expand Down
299 changes: 299 additions & 0 deletions gateway/platforms/msgraph_webhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
"""Microsoft Graph webhook adapter for change-notification ingress."""

from __future__ import annotations

import asyncio
import json
import logging
from collections import deque
from hashlib import sha1
from typing import Any, Awaitable, Callable, Dict, Optional

try:
from aiohttp import web

AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
web = None # type: ignore[assignment]

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)

logger = logging.getLogger(__name__)

DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8646
DEFAULT_WEBHOOK_PATH = "/msgraph/webhook"
DEFAULT_MAX_SEEN_RECEIPTS = 5000
NotificationScheduler = Callable[[Dict[str, Any], MessageEvent], Awaitable[None] | None]


def check_msgraph_webhook_requirements() -> bool:
"""Return whether required webhook dependencies are available."""
return AIOHTTP_AVAILABLE


class MSGraphWebhookAdapter(BasePlatformAdapter):
"""Receive Microsoft Graph change notifications and surface them internally."""

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.MSGRAPH_WEBHOOK)
extra = config.extra or {}
self._host: str = str(extra.get("host", DEFAULT_HOST))
self._port: int = int(extra.get("port", DEFAULT_PORT))
self._webhook_path: str = self._normalize_path(
extra.get("webhook_path", DEFAULT_WEBHOOK_PATH)
)
self._health_path: str = self._normalize_path(extra.get("health_path", "/health"))
self._accepted_resources: list[str] = [
str(value).strip()
for value in (extra.get("accepted_resources") or [])
if str(value).strip()
]
self._client_state: Optional[str] = self._string_or_none(extra.get("client_state"))
self._max_seen_receipts = max(
1, int(extra.get("max_seen_receipts", DEFAULT_MAX_SEEN_RECEIPTS))
)
self._runner = None
self._notification_scheduler: Optional[NotificationScheduler] = None
self._seen_receipts: set[str] = set()
self._seen_receipt_order: deque[str] = deque()
self._accepted_count = 0
self._duplicate_count = 0

@staticmethod
def _string_or_none(value: Any) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
return text or None

@staticmethod
def _normalize_path(path: Any) -> str:
raw = str(path or "").strip() or "/"
return raw if raw.startswith("/") else f"/{raw}"

@staticmethod
def _build_receipt_key(notification: Dict[str, Any]) -> str:
explicit_id = str(notification.get("id") or "").strip()
if explicit_id:
return f"id:{explicit_id}"
payload = "|".join(
[
str(notification.get("subscriptionId") or ""),
str(notification.get("changeType") or ""),
str(notification.get("resource") or ""),
json.dumps(notification.get("resourceData") or {}, sort_keys=True),
]
)
return f"sha1:{sha1(payload.encode('utf-8')).hexdigest()}"
Comment on lines +83 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop deduping when notification IDs are missing

_build_receipt_key() falls back to hashing only subscriptionId, changeType, resource, and resourceData when notification.id is absent. Because Graph marks id as optional, repeated legitimate updates for the same resource (for example multiple updated events with the same resource path/resourceData) produce the same key and are dropped as duplicates, which silently loses real events until eviction from the 5,000-entry window.

Useful? React with 👍 / 👎.


def set_notification_scheduler(self, scheduler: Optional[NotificationScheduler]) -> None:
self._notification_scheduler = scheduler

async def connect(self) -> bool:
app = web.Application()
app.router.add_get(self._health_path, self._handle_health)
app.router.add_get(self._webhook_path, self._handle_notification)
app.router.add_post(self._webhook_path, self._handle_notification)

self._runner = web.AppRunner(app)
await self._runner.setup()
site = web.TCPSite(self._runner, self._host, self._port)
await site.start()
self._mark_connected()
logger.info(
"[msgraph_webhook] Listening on %s:%d%s",
self._host,
self._port,
self._webhook_path,
)
return True

async def disconnect(self) -> None:
if self._runner is not None:
await self._runner.cleanup()
self._runner = None
self._mark_disconnected()

async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
logger.info("[msgraph_webhook] Response for %s: %s", chat_id, content[:200])
return SendResult(success=True)

async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": chat_id, "type": "webhook"}

async def _handle_health(self, request: "web.Request") -> "web.Response":
return web.json_response(
{
"status": "ok",
"platform": self.platform.value,
"webhook_path": self._webhook_path,
"accepted": self._accepted_count,
"duplicates": self._duplicate_count,
}
)

async def _handle_notification(self, request: "web.Request") -> "web.Response":
validation_token = request.query.get("validationToken", "")
if validation_token:
return web.Response(text=validation_token, content_type="text/plain")

try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON body"}, status=400)

notifications = body.get("value")
if not isinstance(notifications, list):
return web.json_response({"error": "Missing notification batch"}, status=400)

accepted = 0
duplicates = 0
rejected = 0
scheduled = 0

for raw_notification in notifications:
if not isinstance(raw_notification, dict):
rejected += 1
continue
notification = dict(raw_notification)
if not self._resource_accepted(str(notification.get("resource") or "")):
rejected += 1
continue
if not self._verify_client_state(notification):
rejected += 1
continue

receipt_key = self._build_receipt_key(notification)
if self._has_seen_receipt(receipt_key):
duplicates += 1
continue
self._remember_receipt(receipt_key)

accepted += 1
scheduled += 1
self._accepted_count += 1
event = self._build_message_event(notification, receipt_key)
self._schedule_notification(notification, event)

self._duplicate_count += duplicates
status = 202 if accepted or duplicates else 403
return web.json_response(
{
"status": "accepted" if accepted or duplicates else "rejected",
"accepted": accepted,
"duplicates": duplicates,
"rejected": rejected,
"scheduled": scheduled,
},
status=status,
)

def _resource_accepted(self, resource: str) -> bool:
if not self._accepted_resources:
return True
for pattern in self._accepted_resources:
if pattern.endswith("*") and resource.startswith(pattern[:-1]):
return True
if resource == pattern or resource.startswith(f"{pattern}/"):
return True
Comment on lines +211 to +212

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize resource paths before resource filter matching

Resource filtering compares raw strings, but Graph subscription resources are commonly configured with a leading / while delivered notification resource values are relative paths without it. With accepted_resources set from subscription-style values (for example /communications/...), _resource_accepted() rejects every valid notification and the adapter returns no accepted work.

Useful? React with 👍 / 👎.

return False

def _verify_client_state(self, notification: Dict[str, Any]) -> bool:
expected = self._client_state
if expected is None:
return True
provided = self._string_or_none(notification.get("clientState"))
return provided == expected

def _has_seen_receipt(self, receipt_key: str) -> bool:
return receipt_key in self._seen_receipts

def _remember_receipt(self, receipt_key: str) -> None:
self._seen_receipts.add(receipt_key)
self._seen_receipt_order.append(receipt_key)
while len(self._seen_receipt_order) > self._max_seen_receipts:
oldest = self._seen_receipt_order.popleft()
self._seen_receipts.discard(oldest)

def _build_message_event(
self,
notification: Dict[str, Any],
receipt_key: str,
) -> MessageEvent:
source = self.build_source(
chat_id=f"msgraph:{notification.get('subscriptionId', 'unknown')}",
chat_name="msgraph/webhook",
chat_type="webhook",
user_id="msgraph",
user_name="Microsoft Graph",
)
return MessageEvent(
text=self._render_prompt(notification),
message_type=MessageType.TEXT,
source=source,
raw_message=notification,
message_id=receipt_key,
internal=True,
)

def _render_prompt(self, notification: Dict[str, Any]) -> str:
template = self.config.extra.get("prompt", "")
if template:
payload = {
"notification": notification,
"resource": notification.get("resource", ""),
"change_type": notification.get("changeType", ""),
"subscription_id": notification.get("subscriptionId", ""),
}
return self._render_template(template, payload)
rendered = json.dumps(notification, indent=2, sort_keys=True)[:4000]
return f"Microsoft Graph change notification:\n\n```json\n{rendered}\n```"

def _render_template(self, template: str, payload: Dict[str, Any]) -> str:
import re

def _resolve(match: "re.Match[str]") -> str:
key = match.group(1)
value: Any = payload
for part in key.split("."):
if isinstance(value, dict):
value = value.get(part, f"{{{key}}}")
else:
return f"{{{key}}}"
if isinstance(value, (dict, list)):
return json.dumps(value, sort_keys=True)[:2000]
return str(value)

return re.sub(r"\{([a-zA-Z0-9_.]+)\}", _resolve, template)

def _schedule_notification(
self,
notification: Dict[str, Any],
event: MessageEvent,
) -> None:
scheduler = self._notification_scheduler
if scheduler is not None:
result = scheduler(notification, event)
if asyncio.iscoroutine(result):
task = asyncio.create_task(result)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return

task = asyncio.create_task(self.handle_message(event))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
10 changes: 10 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4529,6 +4529,16 @@ def _create_adapter(
adapter.gateway_runner = self # For cross-platform delivery
return adapter

elif platform == Platform.MSGRAPH_WEBHOOK:
from gateway.platforms.msgraph_webhook import (
MSGraphWebhookAdapter,
check_msgraph_webhook_requirements,
)
if not check_msgraph_webhook_requirements():
logger.warning("MSGraph webhook: aiohttp not installed")
return None
return MSGraphWebhookAdapter(config)

elif platform == Platform.BLUEBUBBLES:
from gateway.platforms.bluebubbles import BlueBubblesAdapter, check_bluebubbles_requirements
if not check_bluebubbles_requirements():
Expand Down
Loading
Loading