From 8f0b653a4cfc4f0de4cbdc18063d96ce68ae888b Mon Sep 17 00:00:00 2001 From: Bob Johnson <68530847+T3chC0wb0y@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:24:38 -0500 Subject: [PATCH] Fix MSTeams PR review follow-ups --- nanobot/channels/msteams.py | 46 +++-- pyproject.toml | 6 + tests/test_msteams.py | 367 ++++++++++-------------------------- 3 files changed, 136 insertions(+), 283 deletions(-) diff --git a/nanobot/channels/msteams.py b/nanobot/channels/msteams.py index 7a746c691..9a1efe9b7 100644 --- a/nanobot/channels/msteams.py +++ b/nanobot/channels/msteams.py @@ -13,16 +13,15 @@ from __future__ import annotations import asyncio import html +import importlib.util import json import re import threading from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import httpx -import jwt from loguru import logger from pydantic import Field @@ -32,6 +31,14 @@ from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_workspace_path from nanobot.config.schema import Base +MSTEAMS_AVAILABLE = importlib.util.find_spec("jwt") is not None + +if TYPE_CHECKING: + import jwt + +if MSTEAMS_AVAILABLE: + import jwt + class MSTeamsConfig(Base): """Microsoft Teams channel configuration.""" @@ -100,6 +107,10 @@ class MSTeamsChannel(BaseChannel): async def start(self) -> None: """Start the Teams webhook listener.""" + if not MSTEAMS_AVAILABLE: + logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]") + return + if not self.config.app_id or not self.config.app_password: logger.error("MSTeams app_id/app_password not configured") return @@ -194,22 +205,16 @@ class MSTeamsChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send a plain text reply into an existing Teams conversation.""" if not self._http: - logger.warning("MSTeams HTTP client not initialized") - return + raise RuntimeError("MSTeams HTTP client not initialized") ref = self._conversation_refs.get(str(msg.chat_id)) if not ref: - logger.warning("MSTeams conversation ref not found for chat_id={}", msg.chat_id) - return + raise RuntimeError(f"MSTeams conversation ref not found for chat_id={msg.chat_id}") token = await self._get_access_token() base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities" use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id) - url = ( - f"{base_url}/{ref.activity_id}" - if use_thread_reply - else base_url - ) + url = f"{base_url}/{ref.activity_id}" if use_thread_reply else base_url headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", @@ -227,6 +232,7 @@ class MSTeamsChannel(BaseChannel): logger.info("MSTeams message sent to {}", ref.conversation_id) except Exception as e: logger.error("MSTeams send failed: {}", e) + raise async def _handle_activity(self, activity: dict[str, Any]) -> None: """Handle inbound Teams/Bot Framework activity.""" @@ -240,7 +246,6 @@ class MSTeamsChannel(BaseChannel): sender_id = str(from_user.get("aadObjectId") or from_user.get("id") or "").strip() conversation_id = str(conversation.get("id") or "").strip() - text = str(activity.get("text") or "").strip() service_url = str(activity.get("serviceUrl") or "").strip() activity_id = str(activity.get("id") or "").strip() conversation_type = str(conversation.get("conversationType") or "").strip() @@ -256,9 +261,6 @@ class MSTeamsChannel(BaseChannel): logger.debug("MSTeams ignoring non-DM conversation {}", conversation_type) return - if not self.is_allowed(sender_id): - return - text = self._sanitize_inbound_text(activity) if not text: text = self.config.mention_only_response.strip() @@ -328,11 +330,17 @@ class MSTeamsChannel(BaseChannel): while lines and not lines[0]: lines.pop(0) + # Observed native Teams reply wrapper: + # Replying to Bob Smith + # actual reply text if len(lines) >= 2 and lines[0].lower().startswith("replying to "): quoted = lines[0][len("replying to ") :].strip(" :") reply = "\n".join(lines[1:]).strip() return self._format_reply_with_quote(quoted, reply) + # Observed FWDIOC relay wrapper where the quoted content is surfaced after a + # synthetic "FWDIOC-BOT" header, sometimes with a blank line separating quote + # and reply, and sometimes as a compact line-based fallback shape. if lines and lines[0].strip().startswith("FWDIOC-BOT"): body = normalized_newlines.split("\n", 1)[1] if "\n" in normalized_newlines else "" body = body.lstrip() @@ -350,6 +358,8 @@ class MSTeamsChannel(BaseChannel): if quoted and reply: return self._format_reply_with_quote(quoted, reply) + # Observed compact fallback where the relay flattens everything into one line + # and appends the literal reply text marker at the end. compact = re.sub(r"\s+", " ", normalized_newlines).strip() if compact.startswith("FWDIOC-BOT "): compact = compact[len("FWDIOC-BOT ") :].strip() @@ -374,6 +384,9 @@ class MSTeamsChannel(BaseChannel): async def _validate_inbound_auth(self, auth_header: str, activity: dict[str, Any]) -> None: """Validate inbound Bot Framework bearer token.""" + if not MSTEAMS_AVAILABLE: + raise RuntimeError("PyJWT not installed. Run: pip install nanobot-ai[msteams]") + if not auth_header.lower().startswith("bearer "): raise ValueError("missing bearer token") @@ -449,6 +462,7 @@ class MSTeamsChannel(BaseChannel): self._botframework_jwks = resp.json() self._botframework_jwks_expires_at = now + 3600 return self._botframework_jwks + def _load_refs(self) -> dict[str, ConversationRef]: """Load stored conversation references.""" if not self._refs_path.exists(): diff --git a/pyproject.toml b/pyproject.toml index ae87c7beb..b5095ff01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,10 @@ weixin = [ "qrcode[pil]>=8.0", "pycryptodome>=3.20.0", ] +msteams = [ + "PyJWT>=2.0,<3.0", + "cryptography>=41.0", +] matrix = [ "matrix-nio[e2e]>=0.25.2", @@ -81,6 +85,8 @@ dev = [ "aiohttp>=3.9.0,<4.0.0", "pytest-cov>=6.0.0,<7.0.0", "ruff>=0.1.0", + "PyJWT>=2.0,<3.0", + "cryptography>=41.0", ] [project.scripts] diff --git a/tests/test_msteams.py b/tests/test_msteams.py index 1ad38244f..a253558e3 100644 --- a/tests/test_msteams.py +++ b/tests/test_msteams.py @@ -4,6 +4,7 @@ import jwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa +import nanobot.channels.msteams as msteams_module from nanobot.bus.events import OutboundMessage from nanobot.channels.msteams import ConversationRef, MSTeamsChannel, MSTeamsConfig @@ -17,10 +18,13 @@ class DummyBus: class FakeResponse: - def __init__(self, payload): - self._payload = payload + def __init__(self, payload=None, *, should_raise=False): + self._payload = payload or {} + self._should_raise = should_raise def raise_for_status(self): + if self._should_raise: + raise RuntimeError("boom") return None def json(self): @@ -28,30 +32,37 @@ class FakeResponse: class FakeHttpClient: - def __init__(self, payload=None): + def __init__(self, payload=None, *, should_raise=False): self.payload = payload or {"access_token": "tok", "expires_in": 3600} + self.should_raise = should_raise self.calls = [] async def post(self, url, **kwargs): self.calls.append((url, kwargs)) - return FakeResponse(self.payload) + return FakeResponse(self.payload, should_raise=self.should_raise) -@pytest.mark.asyncio -async def test_handle_activity_personal_message_publishes_and_stores_ref(tmp_path, monkeypatch): +@pytest.fixture +def make_channel(tmp_path, monkeypatch): monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - bus = DummyBus() - ch = MSTeamsChannel( - { + def _make_channel(**config_overrides): + config = { "enabled": True, "appId": "app-id", "appPassword": "secret", "tenantId": "tenant-id", "allowFrom": ["*"], - }, - bus, - ) + } + config.update(config_overrides) + return MSTeamsChannel(config, DummyBus()) + + return _make_channel + + +@pytest.mark.asyncio +async def test_handle_activity_personal_message_publishes_and_stores_ref(make_channel, tmp_path): + ch = make_channel() activity = { "type": "message", @@ -78,8 +89,8 @@ async def test_handle_activity_personal_message_publishes_and_stores_ref(tmp_pat await ch._handle_activity(activity) - assert len(bus.inbound) == 1 - msg = bus.inbound[0] + assert len(ch.bus.inbound) == 1 + msg = ch.bus.inbound[0] assert msg.channel == "msteams" assert msg.sender_id == "aad-user-1" assert msg.chat_id == "conv-123" @@ -93,20 +104,8 @@ async def test_handle_activity_personal_message_publishes_and_stores_ref(tmp_pat @pytest.mark.asyncio -async def test_handle_activity_ignores_group_messages(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - }, - bus, - ) +async def test_handle_activity_ignores_group_messages(make_channel): + ch = make_channel() activity = { "type": "message", @@ -130,25 +129,13 @@ async def test_handle_activity_ignores_group_messages(tmp_path, monkeypatch): await ch._handle_activity(activity) - assert bus.inbound == [] + assert ch.bus.inbound == [] assert ch._conversation_refs == {} @pytest.mark.asyncio -async def test_handle_activity_mention_only_uses_default_response(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - }, - bus, - ) +async def test_handle_activity_mention_only_uses_default_response(make_channel): + ch = make_channel() activity = { "type": "message", @@ -172,27 +159,14 @@ async def test_handle_activity_mention_only_uses_default_response(tmp_path, monk await ch._handle_activity(activity) - assert len(bus.inbound) == 1 - assert bus.inbound[0].content == "Hi — what can I help with?" + assert len(ch.bus.inbound) == 1 + assert ch.bus.inbound[0].content == "Hi — what can I help with?" assert "conv-empty" in ch._conversation_refs @pytest.mark.asyncio -async def test_handle_activity_mention_only_ignores_when_response_disabled(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - "mentionOnlyResponse": " ", - }, - bus, - ) +async def test_handle_activity_mention_only_ignores_when_response_disabled(make_channel): + ch = make_channel(mentionOnlyResponse=" ") activity = { "type": "message", @@ -216,43 +190,19 @@ async def test_handle_activity_mention_only_ignores_when_response_disabled(tmp_p await ch._handle_activity(activity) - assert bus.inbound == [] + assert ch.bus.inbound == [] assert ch._conversation_refs == {} -def test_strip_possible_bot_mention_removes_generic_at_tags(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - }, - bus, - ) +def test_strip_possible_bot_mention_removes_generic_at_tags(make_channel): + ch = make_channel() assert ch._strip_possible_bot_mention("Nanobot hello") == "hello" assert ch._strip_possible_bot_mention("hi Some Bot there") == "hi there" -def test_sanitize_inbound_text_keeps_normal_inline_message(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - }, - bus, - ) +def test_sanitize_inbound_text_keeps_normal_inline_message(make_channel): + ch = make_channel() activity = { "text": "Nanobot normal inline message", @@ -262,20 +212,8 @@ def test_sanitize_inbound_text_keeps_normal_inline_message(tmp_path, monkeypatch assert ch._sanitize_inbound_text(activity) == "normal inline message" -def test_sanitize_inbound_text_normalizes_fwdioc_wrapper_without_reply_metadata(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - }, - bus, - ) +def test_sanitize_inbound_text_normalizes_fwdioc_wrapper_without_reply_metadata(make_channel): + ch = make_channel() activity = { "text": "FWDIOC-BOT \r\nQuoted prior message\r\n\r\nThis is a reply with quote test", @@ -288,20 +226,8 @@ def test_sanitize_inbound_text_normalizes_fwdioc_wrapper_without_reply_metadata( ) -def test_sanitize_inbound_text_structures_reply_quote_prefix(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - }, - bus, - ) +def test_sanitize_inbound_text_structures_reply_quote_prefix(make_channel): + ch = make_channel() activity = { "text": "Replying to Bob Smith\nactual reply text", @@ -312,20 +238,8 @@ def test_sanitize_inbound_text_structures_reply_quote_prefix(tmp_path, monkeypat assert ch._sanitize_inbound_text(activity) == "User is replying to: Bob Smith\nUser reply: actual reply text" -def test_sanitize_inbound_text_structures_live_fwdioc_quote_shape(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - }, - bus, - ) +def test_sanitize_inbound_text_structures_live_fwdioc_quote_shape(make_channel): + ch = make_channel() activity = { "text": "FWDIOC-BOT Got it. I’ll watch for the exact text reply with quote test and then inspect that turn specifically. Reply with quote test", @@ -339,20 +253,8 @@ def test_sanitize_inbound_text_structures_live_fwdioc_quote_shape(tmp_path, monk ) -def test_sanitize_inbound_text_structures_multiline_fwdioc_quote_shape(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - }, - bus, - ) +def test_sanitize_inbound_text_structures_multiline_fwdioc_quote_shape(make_channel): + ch = make_channel() activity = { "text": ( @@ -373,20 +275,8 @@ def test_sanitize_inbound_text_structures_multiline_fwdioc_quote_shape(tmp_path, ) -def test_sanitize_inbound_text_structures_exact_live_crlf_fwdioc_shape(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - }, - bus, - ) +def test_sanitize_inbound_text_structures_exact_live_crlf_fwdioc_shape(make_channel): + ch = make_channel() activity = { "text": ( @@ -408,21 +298,8 @@ def test_sanitize_inbound_text_structures_exact_live_crlf_fwdioc_shape(tmp_path, @pytest.mark.asyncio -async def test_get_access_token_uses_configured_tenant(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-123", - "allowFrom": ["*"], - }, - bus, - ) - +async def test_get_access_token_uses_configured_tenant(make_channel): + ch = make_channel(tenantId="tenant-123") fake_http = FakeHttpClient() ch._http = fake_http @@ -438,22 +315,8 @@ async def test_get_access_token_uses_configured_tenant(tmp_path, monkeypatch): @pytest.mark.asyncio -async def test_send_replies_to_activity_when_reply_in_thread_enabled(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - "replyInThread": True, - }, - bus, - ) - +async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channel): + ch = make_channel(replyInThread=True) fake_http = FakeHttpClient() ch._http = fake_http ch._token = "tok" @@ -475,22 +338,8 @@ async def test_send_replies_to_activity_when_reply_in_thread_enabled(tmp_path, m @pytest.mark.asyncio -async def test_send_posts_to_conversation_when_thread_reply_disabled(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - "replyInThread": False, - }, - bus, - ) - +async def test_send_posts_to_conversation_when_thread_reply_disabled(make_channel): + ch = make_channel(replyInThread=False) fake_http = FakeHttpClient() ch._http = fake_http ch._token = "tok" @@ -512,22 +361,8 @@ async def test_send_posts_to_conversation_when_thread_reply_disabled(tmp_path, m @pytest.mark.asyncio -async def test_send_posts_to_conversation_when_thread_reply_enabled_but_no_activity_id(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - "replyInThread": True, - }, - bus, - ) - +async def test_send_posts_to_conversation_when_thread_reply_enabled_but_no_activity_id(make_channel): + ch = make_channel(replyInThread=True) fake_http = FakeHttpClient() ch._http = fake_http ch._token = "tok" @@ -548,6 +383,31 @@ async def test_send_posts_to_conversation_when_thread_reply_enabled_but_no_activ assert "replyToId" not in kwargs["json"] +@pytest.mark.asyncio +async def test_send_raises_when_conversation_ref_missing(make_channel): + ch = make_channel() + ch._http = FakeHttpClient() + + with pytest.raises(RuntimeError, match="conversation ref not found"): + await ch.send(OutboundMessage(channel="msteams", chat_id="missing", content="Reply text")) + + +@pytest.mark.asyncio +async def test_send_raises_delivery_failures_for_retry(make_channel): + ch = make_channel() + ch._http = FakeHttpClient(should_raise=True) + ch._token = "tok" + ch._token_expires_at = 9999999999 + ch._conversation_refs["conv-123"] = ConversationRef( + service_url="https://smba.trafficmanager.net/amer/", + conversation_id="conv-123", + activity_id="activity-1", + ) + + with pytest.raises(RuntimeError, match="boom"): + await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text")) + + def _make_test_rsa_jwk(kid: str = "test-kid"): private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) public_key = private_key.public_key() @@ -560,21 +420,8 @@ def _make_test_rsa_jwk(kid: str = "test-kid"): @pytest.mark.asyncio -async def test_validate_inbound_auth_accepts_observed_botframework_shape(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - "validateInboundAuth": True, - }, - bus, - ) +async def test_validate_inbound_auth_accepts_observed_botframework_shape(make_channel): + ch = make_channel(validateInboundAuth=True) private_key, jwk = _make_test_rsa_jwk() ch._botframework_jwks = {"keys": [jwk]} @@ -601,21 +448,8 @@ async def test_validate_inbound_auth_accepts_observed_botframework_shape(tmp_pat @pytest.mark.asyncio -async def test_validate_inbound_auth_rejects_service_url_mismatch(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - "validateInboundAuth": True, - }, - bus, - ) +async def test_validate_inbound_auth_rejects_service_url_mismatch(make_channel): + ch = make_channel(validateInboundAuth=True) private_key, jwk = _make_test_rsa_jwk() ch._botframework_jwks = {"keys": [jwk]} @@ -642,26 +476,25 @@ async def test_validate_inbound_auth_rejects_service_url_mismatch(tmp_path, monk @pytest.mark.asyncio -async def test_validate_inbound_auth_rejects_missing_bearer_token(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) - - bus = DummyBus() - ch = MSTeamsChannel( - { - "enabled": True, - "appId": "app-id", - "appPassword": "secret", - "tenantId": "tenant-id", - "allowFrom": ["*"], - "validateInboundAuth": True, - }, - bus, - ) +async def test_validate_inbound_auth_rejects_missing_bearer_token(make_channel): + ch = make_channel(validateInboundAuth=True) with pytest.raises(ValueError, match="missing bearer token"): await ch._validate_inbound_auth("", {"serviceUrl": "https://smba.trafficmanager.net/amer/tenant/"}) +@pytest.mark.asyncio +async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypatch): + ch = make_channel() + errors = [] + monkeypatch.setattr(msteams_module, "MSTEAMS_AVAILABLE", False) + monkeypatch.setattr(msteams_module.logger, "error", lambda message, *args: errors.append(message.format(*args))) + + await ch.start() + + assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"] + + def test_msteams_default_config_includes_restart_notify_fields(): cfg = MSTeamsChannel.default_config()