Skip to content

Commit

Permalink
Add Teams integration (#1003)
Browse files Browse the repository at this point in the history
* add teams integration

* enhance feedback and add typing activity
  • Loading branch information
willydouhard authored Jun 1, 2024
1 parent b2e3399 commit ae00562
Show file tree
Hide file tree
Showing 6 changed files with 362 additions and 4 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

Nothing unreleased!
### Added

- Teams integration

## [1.1.300rc3] - 2024-05-28

Expand Down
19 changes: 18 additions & 1 deletion backend/chainlit/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,27 @@ def get_build_dir(local_target: str, packaged_target: str):
from chainlit.slack.app import slack_app_handler

@app.post("/slack/events")
async def endpoint(req: Request):
async def slack_endpoint(req: Request):
return await slack_app_handler.handle(req)


# -------------------------------------------------------------------------------
# TEAMS HANDLER
# -------------------------------------------------------------------------------

if os.environ.get("TEAMS_APP_ID") and os.environ.get("TEAMS_APP_PASSWORD"):
from botbuilder.schema import Activity
from chainlit.teams.app import adapter, bot

@app.post("/teams/events")
async def teams_endpoint(req: Request):
body = await req.json()
activity = Activity().deserialize(body)
auth_header = req.headers.get("Authorization", "")
response = await adapter.process_activity(activity, auth_header, bot.on_turn)
return response


# -------------------------------------------------------------------------------
# HTTP HANDLERS
# -------------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions backend/chainlit/teams/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
try:
import botbuilder
except ModuleNotFoundError:
raise ValueError(
"The botbuilder-core package is required to integrate Chainlit with a Slack app. Run `pip install botbuilder-core --upgrade`"
)
332 changes: 332 additions & 0 deletions backend/chainlit/teams/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
import asyncio
import base64
import mimetypes
import os
import uuid
from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union

import filetype

if TYPE_CHECKING:
from botbuilder.core import TurnContext
from botbuilder.schema import Activity

import httpx
from botbuilder.core import (
BotFrameworkAdapter,
BotFrameworkAdapterSettings,
MessageFactory,
TurnContext,
)
from botbuilder.schema import (
ActionTypes,
Activity,
ActivityTypes,
Attachment,
CardAction,
ChannelAccount,
HeroCard,
)
from chainlit.config import config
from chainlit.context import ChainlitContext, HTTPSession, context_var
from chainlit.data import get_data_layer
from chainlit.element import Element, ElementDict
from chainlit.emitter import BaseChainlitEmitter
from chainlit.logger import logger
from chainlit.message import Message, StepDict
from chainlit.telemetry import trace
from chainlit.types import Feedback
from chainlit.user import PersistedUser, User
from chainlit.user_session import user_session


class TeamsEmitter(BaseChainlitEmitter):
def __init__(self, session: HTTPSession, turn_context: TurnContext, enabled=False):
super().__init__(session)
self.turn_context = turn_context
self.enabled = enabled

async def send_element(self, element_dict: ElementDict):
if not self.enabled or element_dict.get("display") != "inline":
return

persisted_file = self.session.files.get(element_dict.get("chainlitKey") or "")
attachment: Optional[Attachment] = None
mime: Optional[str] = None

element_name: str = element_dict.get("name", "Untitled")

if mime:
file_extension = mimetypes.guess_extension(mime)
if file_extension:
element_name += file_extension

if persisted_file:
mime = element_dict.get("mime")
with open(persisted_file["path"], "rb") as file:
dencoded_string = base64.b64encode(file.read()).decode()
content_url = f"data:{mime};base64,{dencoded_string}"
attachment = Attachment(
content_type=mime, content_url=content_url, name=element_name
)

elif url := element_dict.get("url"):
attachment = Attachment(
content_type=mime, content_url=url, name=element_name
)

if not attachment:
return

await self.turn_context.send_activity(Activity(attachments=[attachment]))

async def send_step(self, step_dict: StepDict):
if not self.enabled:
return

step_type = step_dict.get("type")
is_message = step_type in [
"user_message",
"assistant_message",
]
is_chain_of_thought = bool(step_dict.get("parentId"))
is_empty_output = not step_dict.get("output")

if is_chain_of_thought or is_empty_output or not is_message:
return
else:
reply = MessageFactory.text(step_dict["output"])
enable_feedback = not step_dict.get("disableFeedback") and get_data_layer()
if enable_feedback:
like_button = CardAction(
type=ActionTypes.message_back,
title="👍",
text="like",
value={"feedback": "like", "step_id": step_dict["id"]},
)
dislike_button = CardAction(
type=ActionTypes.message_back,
title="👎",
text="dislike",
value={"feedback": "dislike", "step_id": step_dict["id"]},
)
card = HeroCard(buttons=[like_button, dislike_button])
attachment = Attachment(
content_type="application/vnd.microsoft.card.hero", content=card
)
reply.attachments = [attachment]

await self.turn_context.send_activity(reply)

async def update_step(self, step_dict: StepDict):
if not self.enabled:
return

await self.send_step(step_dict)


adapter_settings = BotFrameworkAdapterSettings(
app_id=os.environ.get("TEAMS_APP_ID"),
app_password=os.environ.get("TEAMS_APP_PASSWORD"),
)
adapter = BotFrameworkAdapter(adapter_settings)


@trace
def init_teams_context(
session: HTTPSession,
turn_context: TurnContext,
) -> ChainlitContext:
emitter = TeamsEmitter(session=session, turn_context=turn_context)
context = ChainlitContext(session=session, emitter=emitter)
context_var.set(context)
user_session.set("teams_turn_context", turn_context)
return context


users_by_teams_id: Dict[str, Union[User, PersistedUser]] = {}

USER_PREFIX = "teams_"


async def get_user(teams_user: ChannelAccount):
if teams_user.id in users_by_teams_id:
return users_by_teams_id[teams_user.id]

metadata = {
"name": teams_user.name,
"id": teams_user.id,
}
user = User(identifier=USER_PREFIX + str(teams_user.name), metadata=metadata)

users_by_teams_id[teams_user.id] = user

if data_layer := get_data_layer():
try:
persisted_user = await data_layer.create_user(user)
if persisted_user:
users_by_teams_id[teams_user.id] = persisted_user
except Exception as e:
logger.error(f"Error creating user: {e}")

return users_by_teams_id[teams_user.id]


async def download_teams_file(url: str):
async with httpx.AsyncClient() as client:
response = await client.get(url)
if response.status_code == 200:
return response.content
else:
return None


async def download_teams_files(
session: HTTPSession, attachments: Optional[List[Attachment]] = None
):
if not attachments:
return []

attachments = [
attachment for attachment in attachments if isinstance(attachment.content, dict)
]
download_coros = [
download_teams_file(attachment.content.get("downloadUrl"))
for attachment in attachments
]
file_bytes_list = await asyncio.gather(*download_coros)
file_refs = []
for idx, file_bytes in enumerate(file_bytes_list):
if file_bytes:
name = attachments[idx].name
mime_type = filetype.guess_mime(file_bytes) or "application/octet-stream"
file_ref = await session.persist_file(
name=name, mime=mime_type, content=file_bytes
)
file_refs.append(file_ref)

files_dicts = [
session.files[file["id"]] for file in file_refs if file["id"] in session.files
]

file_elements = [Element.from_dict(file_dict) for file_dict in files_dicts]

return file_elements


def clean_content(activity: Activity):
return activity.text.strip()


async def process_teams_message(
turn_context: TurnContext,
thread_name: str,
):
user = await get_user(turn_context.activity.from_property)

thread_id = str(
uuid.uuid5(uuid.NAMESPACE_DNS, str(turn_context.activity.conversation.id))
)

text = clean_content(turn_context.activity)
teams_files = turn_context.activity.attachments

session_id = str(uuid.uuid4())

session = HTTPSession(
id=session_id,
thread_id=thread_id,
user=user,
client_type="teams",
)

ctx = init_teams_context(
session=session,
turn_context=turn_context,
)

file_elements = await download_teams_files(session, teams_files)

msg = Message(
content=text,
elements=file_elements,
type="user_message",
author=user.metadata.get("name"),
)

await msg.send()

ctx.emitter.enabled = True

if on_chat_start := config.code.on_chat_start:
await on_chat_start()

if on_message := config.code.on_message:
await on_message(msg)

if on_chat_end := config.code.on_chat_end:
await on_chat_end()

if data_layer := get_data_layer():
if isinstance(user, PersistedUser):
try:
await data_layer.update_thread(
thread_id=thread_id,
name=thread_name,
metadata=ctx.session.to_persistable(),
user_id=user.id,
)
except Exception as e:
logger.error(f"Error updating thread: {e}")

ctx.session.delete()


async def handle_message(turn_context: TurnContext):
if turn_context.activity.type == ActivityTypes.message:
if (
turn_context.activity.text == "like"
or turn_context.activity.text == "dislike"
):
feedback_value: Literal[0, 1] = (
0 if turn_context.activity.text == "dislike" else 1
)
step_id = turn_context.activity.value.get("step_id")
if data_layer := get_data_layer():
await data_layer.upsert_feedback(
Feedback(forId=step_id, value=feedback_value)
)
updated_text = "👍" if turn_context.activity.text == "like" else "👎"
# Update the existing message to remove the buttons
updated_message = Activity(
type=ActivityTypes.message,
id=turn_context.activity.reply_to_id,
text=updated_text,
attachments=[],
)
await turn_context.update_activity(updated_message)
else:
# Send typing activity
typing_activity = Activity(
type=ActivityTypes.typing,
from_property=turn_context.activity.recipient,
recipient=turn_context.activity.from_property,
conversation=turn_context.activity.conversation,
)
await turn_context.send_activity(typing_activity)
thread_name = f"{turn_context.activity.from_property.name} Teams DM"
await process_teams_message(turn_context, thread_name)


async def on_turn(turn_context: TurnContext):
await handle_message(turn_context)


# Create the main bot class
class TeamsBot:
async def on_turn(self, turn_context: TurnContext):
await on_turn(turn_context)


# Create the bot instance
bot = TeamsBot()
2 changes: 1 addition & 1 deletion backend/chainlit/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,8 @@ class FeedbackDict(TypedDict):
@dataclass
class Feedback:
forId: str
threadId: Optional[str]
value: Literal[0, 1]
threadId: Optional[str] = None
id: Optional[str] = None
comment: Optional[str] = None

Expand Down
Loading

0 comments on commit ae00562

Please sign in to comment.