mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 16:56:22 +02:00
add Teams list channels, read messages, send message tools
This commit is contained in:
parent
1de2517eae
commit
49f8d1abd4
6 changed files with 328 additions and 0 deletions
|
|
@ -0,0 +1,15 @@
|
||||||
|
from app.agents.new_chat.tools.teams.list_channels import (
|
||||||
|
create_list_teams_channels_tool,
|
||||||
|
)
|
||||||
|
from app.agents.new_chat.tools.teams.read_messages import (
|
||||||
|
create_read_teams_messages_tool,
|
||||||
|
)
|
||||||
|
from app.agents.new_chat.tools.teams.send_message import (
|
||||||
|
create_send_teams_message_tool,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_list_teams_channels_tool",
|
||||||
|
"create_read_teams_messages_tool",
|
||||||
|
"create_send_teams_message_tool",
|
||||||
|
]
|
||||||
43
surfsense_backend/app/agents/new_chat/tools/teams/_auth.py
Normal file
43
surfsense_backend/app/agents/new_chat/tools/teams/_auth.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Shared auth helper for Teams agent tools (Microsoft Graph REST API)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
from app.db import SearchSourceConnector, SearchSourceConnectorType
|
||||||
|
from app.utils.oauth_security import TokenEncryption
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
GRAPH_API = "https://graph.microsoft.com/v1.0"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_teams_connector(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: str,
|
||||||
|
) -> SearchSourceConnector | None:
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(SearchSourceConnector).filter(
|
||||||
|
SearchSourceConnector.search_space_id == search_space_id,
|
||||||
|
SearchSourceConnector.user_id == user_id,
|
||||||
|
SearchSourceConnector.connector_type == SearchSourceConnectorType.TEAMS_CONNECTOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_access_token(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
connector: SearchSourceConnector,
|
||||||
|
) -> str:
|
||||||
|
"""Get a valid Microsoft Graph access token, refreshing if expired."""
|
||||||
|
from app.connectors.teams_connector import TeamsConnector
|
||||||
|
|
||||||
|
tc = TeamsConnector(
|
||||||
|
session=db_session,
|
||||||
|
connector_id=connector.id,
|
||||||
|
)
|
||||||
|
return await tc._get_valid_token()
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ._auth import GRAPH_API, get_access_token, get_teams_connector
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_list_teams_channels_tool(
|
||||||
|
db_session: AsyncSession | None = None,
|
||||||
|
search_space_id: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
):
|
||||||
|
@tool
|
||||||
|
async def list_teams_channels() -> dict[str, Any]:
|
||||||
|
"""List all Microsoft Teams and their channels the user has access to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with status and a list of teams, each containing
|
||||||
|
team_id, team_name, and a list of channels (id, name).
|
||||||
|
"""
|
||||||
|
if db_session is None or search_space_id is None or user_id is None:
|
||||||
|
return {"status": "error", "message": "Teams tool not properly configured."}
|
||||||
|
|
||||||
|
try:
|
||||||
|
connector = await get_teams_connector(db_session, search_space_id, user_id)
|
||||||
|
if not connector:
|
||||||
|
return {"status": "error", "message": "No Teams connector found."}
|
||||||
|
|
||||||
|
token = await get_access_token(db_session, connector)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||||
|
teams_resp = await client.get(f"{GRAPH_API}/me/joinedTeams", headers=headers)
|
||||||
|
|
||||||
|
if teams_resp.status_code == 401:
|
||||||
|
return {"status": "auth_error", "message": "Teams token expired. Please re-authenticate.", "connector_type": "teams"}
|
||||||
|
if teams_resp.status_code != 200:
|
||||||
|
return {"status": "error", "message": f"Graph API error: {teams_resp.status_code}"}
|
||||||
|
|
||||||
|
teams_data = teams_resp.json().get("value", [])
|
||||||
|
result_teams = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||||
|
for team in teams_data:
|
||||||
|
team_id = team["id"]
|
||||||
|
ch_resp = await client.get(
|
||||||
|
f"{GRAPH_API}/teams/{team_id}/channels",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
channels = []
|
||||||
|
if ch_resp.status_code == 200:
|
||||||
|
channels = [
|
||||||
|
{"id": ch["id"], "name": ch.get("displayName", "")}
|
||||||
|
for ch in ch_resp.json().get("value", [])
|
||||||
|
]
|
||||||
|
result_teams.append({
|
||||||
|
"team_id": team_id,
|
||||||
|
"team_name": team.get("displayName", ""),
|
||||||
|
"channels": channels,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"status": "success", "teams": result_teams, "total_teams": len(result_teams)}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
from langgraph.errors import GraphInterrupt
|
||||||
|
|
||||||
|
if isinstance(e, GraphInterrupt):
|
||||||
|
raise
|
||||||
|
logger.error("Error listing Teams channels: %s", e, exc_info=True)
|
||||||
|
return {"status": "error", "message": "Failed to list Teams channels."}
|
||||||
|
|
||||||
|
return list_teams_channels
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ._auth import GRAPH_API, get_access_token, get_teams_connector
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_read_teams_messages_tool(
|
||||||
|
db_session: AsyncSession | None = None,
|
||||||
|
search_space_id: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
):
|
||||||
|
@tool
|
||||||
|
async def read_teams_messages(
|
||||||
|
team_id: str,
|
||||||
|
channel_id: str,
|
||||||
|
limit: int = 25,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read recent messages from a Microsoft Teams channel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: The team ID (from list_teams_channels).
|
||||||
|
channel_id: The channel ID (from list_teams_channels).
|
||||||
|
limit: Number of messages to fetch (default 25, max 50).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with status and a list of messages including
|
||||||
|
id, sender, content, timestamp.
|
||||||
|
"""
|
||||||
|
if db_session is None or search_space_id is None or user_id is None:
|
||||||
|
return {"status": "error", "message": "Teams tool not properly configured."}
|
||||||
|
|
||||||
|
limit = min(limit, 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
connector = await get_teams_connector(db_session, search_space_id, user_id)
|
||||||
|
if not connector:
|
||||||
|
return {"status": "error", "message": "No Teams connector found."}
|
||||||
|
|
||||||
|
token = await get_access_token(db_session, connector)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{GRAPH_API}/teams/{team_id}/channels/{channel_id}/messages",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
params={"$top": limit},
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
return {"status": "auth_error", "message": "Teams token expired. Please re-authenticate.", "connector_type": "teams"}
|
||||||
|
if resp.status_code == 403:
|
||||||
|
return {"status": "error", "message": "Insufficient permissions to read this channel."}
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return {"status": "error", "message": f"Graph API error: {resp.status_code}"}
|
||||||
|
|
||||||
|
raw_msgs = resp.json().get("value", [])
|
||||||
|
messages = []
|
||||||
|
for m in raw_msgs:
|
||||||
|
sender = m.get("from", {})
|
||||||
|
user_info = sender.get("user", {}) if sender else {}
|
||||||
|
body = m.get("body", {})
|
||||||
|
messages.append({
|
||||||
|
"id": m.get("id"),
|
||||||
|
"sender": user_info.get("displayName", "Unknown"),
|
||||||
|
"content": body.get("content", ""),
|
||||||
|
"content_type": body.get("contentType", "text"),
|
||||||
|
"timestamp": m.get("createdDateTime", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"team_id": team_id,
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"messages": messages,
|
||||||
|
"total": len(messages),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
from langgraph.errors import GraphInterrupt
|
||||||
|
|
||||||
|
if isinstance(e, GraphInterrupt):
|
||||||
|
raise
|
||||||
|
logger.error("Error reading Teams messages: %s", e, exc_info=True)
|
||||||
|
return {"status": "error", "message": "Failed to read Teams messages."}
|
||||||
|
|
||||||
|
return read_teams_messages
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.agents.new_chat.tools.hitl import request_approval
|
||||||
|
|
||||||
|
from ._auth import GRAPH_API, get_access_token, get_teams_connector
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_send_teams_message_tool(
|
||||||
|
db_session: AsyncSession | None = None,
|
||||||
|
search_space_id: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
):
|
||||||
|
@tool
|
||||||
|
async def send_teams_message(
|
||||||
|
team_id: str,
|
||||||
|
channel_id: str,
|
||||||
|
content: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send a message to a Microsoft Teams channel.
|
||||||
|
|
||||||
|
Requires the ChannelMessage.Send OAuth scope. If the user gets a
|
||||||
|
permission error, they may need to re-authenticate with updated scopes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: The team ID (from list_teams_channels).
|
||||||
|
channel_id: The channel ID (from list_teams_channels).
|
||||||
|
content: The message text (HTML supported).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with status, message_id on success.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- If status is "rejected", the user explicitly declined. Do NOT retry.
|
||||||
|
"""
|
||||||
|
if db_session is None or search_space_id is None or user_id is None:
|
||||||
|
return {"status": "error", "message": "Teams tool not properly configured."}
|
||||||
|
|
||||||
|
try:
|
||||||
|
connector = await get_teams_connector(db_session, search_space_id, user_id)
|
||||||
|
if not connector:
|
||||||
|
return {"status": "error", "message": "No Teams connector found."}
|
||||||
|
|
||||||
|
result = request_approval(
|
||||||
|
action_type="teams_send_message",
|
||||||
|
tool_name="send_teams_message",
|
||||||
|
params={"team_id": team_id, "channel_id": channel_id, "content": content},
|
||||||
|
context={"connector_id": connector.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.rejected:
|
||||||
|
return {"status": "rejected", "message": "User declined. Message was not sent."}
|
||||||
|
|
||||||
|
final_content = result.params.get("content", content)
|
||||||
|
final_team = result.params.get("team_id", team_id)
|
||||||
|
final_channel = result.params.get("channel_id", channel_id)
|
||||||
|
|
||||||
|
token = await get_access_token(db_session, connector)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{GRAPH_API}/teams/{final_team}/channels/{final_channel}/messages",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={"body": {"content": final_content}},
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
return {"status": "auth_error", "message": "Teams token expired. Please re-authenticate.", "connector_type": "teams"}
|
||||||
|
if resp.status_code == 403:
|
||||||
|
return {
|
||||||
|
"status": "insufficient_permissions",
|
||||||
|
"message": "Missing ChannelMessage.Send permission. Please re-authenticate with updated scopes.",
|
||||||
|
}
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
return {"status": "error", "message": f"Graph API error: {resp.status_code} — {resp.text[:200]}"}
|
||||||
|
|
||||||
|
msg_data = resp.json()
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message_id": msg_data.get("id"),
|
||||||
|
"message": f"Message sent to Teams channel.",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
from langgraph.errors import GraphInterrupt
|
||||||
|
|
||||||
|
if isinstance(e, GraphInterrupt):
|
||||||
|
raise
|
||||||
|
logger.error("Error sending Teams message: %s", e, exc_info=True)
|
||||||
|
return {"status": "error", "message": "Failed to send Teams message."}
|
||||||
|
|
||||||
|
return send_teams_message
|
||||||
|
|
@ -45,6 +45,7 @@ SCOPES = [
|
||||||
"Team.ReadBasic.All", # Read basic team information
|
"Team.ReadBasic.All", # Read basic team information
|
||||||
"Channel.ReadBasic.All", # Read basic channel information
|
"Channel.ReadBasic.All", # Read basic channel information
|
||||||
"ChannelMessage.Read.All", # Read messages in channels
|
"ChannelMessage.Read.All", # Read messages in channels
|
||||||
|
"ChannelMessage.Send", # Send messages in channels
|
||||||
]
|
]
|
||||||
|
|
||||||
# Initialize security utilities
|
# Initialize security utilities
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue