diff --git a/.gitignore b/.gitignore index 40b15b6..097639d 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,8 @@ env/ .ruff_cache/ *.egg-info/ *.egg + +# auto-leave-spaces — local runtime config and token cache (see playbooks/auto-leave-spaces/src) +playbooks/auto-leave-spaces/src/auto_leave.yml +playbooks/auto-leave-spaces/src/auto_leave_tokens.yml +playbooks/auto-leave-spaces/src/auto_leave_rest.log diff --git a/playbooks/auto-leave-spaces/APPHUB.yaml b/playbooks/auto-leave-spaces/APPHUB.yaml new file mode 100644 index 0000000..69993ec --- /dev/null +++ b/playbooks/auto-leave-spaces/APPHUB.yaml @@ -0,0 +1,127 @@ +# APPHUB.yaml — Playbook metadata for Webex App Hub +# Copy this file into your Playbook folder under playbooks// +# Fill in all required fields. See CONTRIBUTING.md for field rules. + +# ----------------------------------------------------------------------------- +# friendly_id — Unique identifier. Must end with -playbook (e.g. epic-ehr-playbook) +# ----------------------------------------------------------------------------- +friendly_id: "auto-leave-spaces-playbook" + +# ----------------------------------------------------------------------------- +# title — Display name for the Playbook (matches ContentStack field) +# ----------------------------------------------------------------------------- +title: "Auto Leave Unwanted Webex Spaces" + +# ----------------------------------------------------------------------------- +# tag_line — Short tagline for App Hub detail page (required, max 128 chars) +# ----------------------------------------------------------------------------- +tag_line: "Automatically hide or leave unwanted Webex spaces using a real-time WebSocket block list" + +# ----------------------------------------------------------------------------- +# description — App Hub supports Markdown. Use a block scalar (description: |) with +# blank lines between sections: opening paragraph (bold key terms), **Why use this +# playbook** (3–5 outcome bullets), **What it does** (concrete behaviors/endpoints). +# Do not put upstream repo URLs or install-only instructions here—use README and +# src/README.md for reference playbooks. See docs/commands/import_playbook.md. +# ----------------------------------------------------------------------------- +description: | + A **Python** sample that uses **WDM**, a **WebSocket** event stream, and documented + **Webex REST** APIs (**Rooms**, **Memberships**) to **hide** or **leave** messaging + **spaces** whose titles match a configurable **regex block list**, when **`hide_direct`** + or **`leave_group_spaces`** is enabled in YAML—useful for noisy bot or notification spaces in the + Webex app. + + **Why use this playbook** + + - **Faster delivery:** Start from working **asyncio** + **wxc-sdk** patterns instead + of rediscovering device registration and WebSocket auth. + - **Clear behavior:** Shows how **conversation.activity** events map to **Rooms** and + **Memberships** updates for **direct** vs **group** spaces. + - **Secrets via env:** **Client ID**, **client secret**, and **scopes** load from + **environment variables** (see `src/env.template`). + - **Ops-friendly toggles:** YAML config separates **hide_direct**, + **leave_group_spaces**, and **blocked** regexes so you can start read-only-safe. + + **What it does** + + - Registers or reuses a **WDM** desktop device and opens the **WebSocket** URL returned + by the service. + - Parses **conversation.activity** payloads (verbs **post**, or **add** when you are + added), resolves the **space** title, and compares it to anchored full-string + **regex** entries; ignores activities you authored yourself. + - Calls **Memberships** APIs to **hide** 1:1 spaces or **leave** group spaces only when + **`hide_direct`** or **`leave_group_spaces`** is **true** in YAML (both default + **false** in the sample). + - Persists **OAuth** tokens to a local **YAML** cache file and runs a refresh loop so + the WebSocket stays authorized. + +# ----------------------------------------------------------------------------- +# product_types — Where this Playbook appears. Pick one or more. +# Valid: teams | meetings | calling | rooms | contact_center +# ----------------------------------------------------------------------------- +product_types: + - "teams" + +# ----------------------------------------------------------------------------- +# categories — App Hub category slugs. Pick one or more. +# Verticals: healthcare | financial-services | retail-ecommerce +# App categories (use kebab-case slugs): +# ai-agent-testing-observability | agent-supervisor-tools | analytics | +# calendar-scheduling | collaboration-management | customer-relations | +# customer-support | developer-tools | doc-management | education | +# finance | government | healthcare | human-resources | internet-of-things | +# marketing-sales | orchestration | platform | productivity | +# project-management | recording-transcriptions | security-compliance | +# self-service-bots | social-and-fun | strategy-team-planning | +# workflow-automation | workforce-optimization | other +# ----------------------------------------------------------------------------- +categories: + - "productivity" + - "developer-tools" + - "collaboration-management" + +# ----------------------------------------------------------------------------- +# company_name — Your company or team name +# ----------------------------------------------------------------------------- +company_name: "Webex for Developers" + +# ----------------------------------------------------------------------------- +# company_url — Your company or project URL +# ----------------------------------------------------------------------------- +company_url: "https://developer.webex.com" + +# ----------------------------------------------------------------------------- +# support_url — Issues or support link (e.g. GitHub issues) +# ----------------------------------------------------------------------------- +support_url: "https://github.com/webex/webexplaybooks/issues" + +# ----------------------------------------------------------------------------- +# product_url — Link to this Playbook in the repo (required) +# ----------------------------------------------------------------------------- +product_url: "https://github.com/webex/WebexPlaybooks/tree/main/playbooks/auto-leave-spaces" + +# ----------------------------------------------------------------------------- +# logo — (Optional) URL to your logo image. If not provided, defaults to the +# standard Webex Playbook logo. +# ----------------------------------------------------------------------------- +logo: "https://images.contentstack.io/v3/assets/bltd14fd2a03236233f/blta2de9daa773c6604/60f71f81e2de935fc7e35dbe/download" + +# ----------------------------------------------------------------------------- +# estimated_implementation_time — e.g. "2-4 hours", "1 day" +# ----------------------------------------------------------------------------- +estimated_implementation_time: "2-4 hours" + +# ----------------------------------------------------------------------------- +# third_party_tool — (Optional) The tool being integrated (e.g. Salesforce, Epic) +# Omit for generic playbooks (e.g. "any CMS") +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# privacy_url — Privacy policy URL (required; use Cisco default for Webex-authored) +# ----------------------------------------------------------------------------- +privacy_url: "https://www.cisco.com/c/en/us/about/legal/privacy-full.html" + +# ----------------------------------------------------------------------------- +# submission_date — (Optional) ISO date (e.g. 2025-03-01) +# ----------------------------------------------------------------------------- +submission_date: "2026-04-02" diff --git a/playbooks/auto-leave-spaces/README.md b/playbooks/auto-leave-spaces/README.md new file mode 100644 index 0000000..761619d --- /dev/null +++ b/playbooks/auto-leave-spaces/README.md @@ -0,0 +1,70 @@ +# Auto Leave Unwanted Webex Spaces + +This Playbook is adapted from the [auto_leave](https://github.com/jeokrohn/auto_leave) sample on GitHub. + +## Use Case Overview + +Administrators and power users in organizations that rely on **Webex messaging** sometimes get flooded with **spaces** created by bots or automations (alerts, incident feeds, or app notifications). Those spaces still appear in the messaging experience until someone manually hides or leaves them. + +This playbook shows a **Python** utility that listens for **real-time conversation events**, checks the **space title** against a **block list** of regular expressions, and—when you enable it in `auto_leave.yml`—**hides** direct (1:1) spaces and/or **leaves** group spaces. With the sample defaults (`hide_direct` and `leave_group_spaces` both `false`), a matching title only produces log lines; set at least one of those flags to `true` before the app will call the Memberships API to change your membership. + +**Target persona:** Developer or IT admin comfortable with Python, OAuth integrations, and running a small long-lived process (desktop, VM, or container). + +**Estimated time to implement:** 2–4 hours for integration setup, first successful auth, and tuning block-list patterns. + +## Architecture + +The sample runs a single **async** process (`auto_leave.py`). It uses **wxc-sdk** to complete **OAuth** for a Webex integration and caches **access** (and refresh) tokens in a local YAML file. It registers a **desktop-style device** with **Webex Device Manager (WDM)** and opens the **WebSocket** URL returned for that device. Incoming frames that describe **conversation.activity** events are deserialized; the handler only continues for verb **`post`** or **`add`** (when **`add`** is about you being added to the space), and skips events where **you** are the actor. It then loads the **space** via the **Rooms** API and tests its title against the configured regex list. When a title matches the block list, for **direct** spaces the sample updates membership **`is_room_hidden`** only if **`hide_direct: true`**; for **group** spaces it deletes your membership only if **`leave_group_spaces: true`**. Both default to **`false`** in code and in the sample YAML. + +Authentication happens during **OAuth** (browser redirect to **localhost:6001** by default) and on **token refresh** in the background. The WebSocket and **WDM** registration use infrastructure aimed at **desktop clients** (see Known Limitations); **Rooms** and **Memberships** updates use **webexapis.com** REST. + +See the sequence diagram in [diagrams/architecture-diagram.md](diagrams/architecture-diagram.md). + +## Prerequisites + +- **Webex** + - A user account in a Webex org with access to **messaging** and **spaces**. + - Ability to create an **integration** at [Webex for Developers](https://developer.webex.com) (admin consent may be required for some orgs). + - Redirect URI `http://localhost:6001/redirect` registered on the integration (matches the sample code). + - OAuth scopes sufficient for the sample: typical patterns include **memberships** (read/write), **rooms**, **people** (read), and **devices** (read/write) for WDM registration—mirror what the upstream sample documents for your build of **wxc-sdk**. +- **Developer machine or host** + - **Python 3.9+** and **pip**, or **Docker** / **Docker Compose** if you use the bundled files under `src/`. Dependencies are pinned in `src/requirements.txt` and have been **`pip install` tested on Python 3.13**; on older Pythons, run the same command locally to confirm your environment resolves cleanly. + - Outbound **HTTPS** to Webex APIs and the WDM host used in code (`https://wdm-a.wbx2.com`). + - **Port 6001** reachable locally for the OAuth redirect handler when you first authorize. +- **Network** + - No inbound public URL is required beyond **localhost** for OAuth during setup; the WebSocket is **outbound** from your host. + +## Code Scaffold + +| Path | Purpose | +|------|---------| +| [src/auto_leave.py](src/auto_leave.py) | Main **asyncio** app: WDM device lifecycle, WebSocket loop, block-list logic, **Memberships** / **Rooms** calls. | +| [src/auto_leave.yml.sample](src/auto_leave.yml.sample) | Example **YAML** config: `hide_direct`, `leave_group_spaces`, logging flags, and `blocked` regex strings. Copy to `auto_leave.yml` next to the script when running. | +| [src/env.template](src/env.template) | **Environment variable** reference for **OAuth** (`INTEGRATION_*`). Copy to `.env` or export in your shell. | +| [src/requirements.txt](src/requirements.txt) | Pinned Python dependencies including **wxc-sdk**. | +| [src/Dockerfile](src/Dockerfile) | Optional container build using `requirements.txt`. | +| [src/docker-compose.yml](src/docker-compose.yml) | Optional **bind mount** of `./` into `/app` and **port 6001** published for OAuth. | + +The code does **not** implement enterprise secret storage, high availability, or detailed audit logging. **Group** space leaving is **off** by default because it is **irreversible** without a new invitation. + +## Deployment Guide + +1. **Create a Webex integration** at [developer.webex.com](https://developer.webex.com): note the **Client ID** and **Client Secret**, set **Redirect URI** to `http://localhost:6001/redirect`, and assign the scopes your org allows (align with the sample and **wxc-sdk** expectations). +2. **Clone this repository** (or copy `playbooks/auto-leave-spaces/src/` to your workspace). +3. **Create Python environment** (optional but recommended): `cd playbooks/auto-leave-spaces/src && python3 -m venv .venv && source .venv/bin/activate` (on Windows use `.venv\Scripts\activate`). +4. **Install dependencies:** `pip install -r requirements.txt` +5. **Optional smoke test (no Webex credentials):** copy `auto_leave.yml.sample` to `auto_leave.yml` and run `python auto_leave.py` without a `.env` file. The process should exit with code **1** after printing a short message on **stderr** starting with `Error: missing integration credentials` and listing the three `INTEGRATION_*` variables—no Python traceback. That confirms imports, dependency resolution, and config load all ran before OAuth. +6. **Configure secrets:** copy `env.template` to `.env` and set `INTEGRATION_CLIENT_ID`, `INTEGRATION_CLIENT_SECRET`, and `INTEGRATION_SCOPES` (space-separated scope list matching your integration—not placeholder secrets). +7. **Configure block list:** if you have not already, ensure `auto_leave.yml` exists in the **same working directory** you will run from (copy from `auto_leave.yml.sample`); edit `blocked` regexes and set **`hide_direct`** and/or **`leave_group_spaces`** as needed (both **`false`** means log-only on a match). Keep `leave_group_spaces: false` until you understand the impact of leaving group spaces. +8. **First run (interactive OAuth):** from `src/`, run `python auto_leave.py`. When prompted, open the logged **authorization URL**, sign in, and approve; tokens are written to `auto_leave_tokens.yml` beside the script. +9. **Verify behavior:** ensure `hide_direct` and/or `leave_group_spaces` is `true` as appropriate, then trigger a **`post`** (or an **`add`** that adds you) in a test space whose title matches a block pattern; confirm the app **hides** the direct space or **leaves** the group space when that flag is on. +10. **Docker alternative:** from `src/`, `docker compose up --build`. Complete OAuth in a browser on the same host so **localhost:6001** reaches the container-published port. Ensure `auto_leave.yml`, `.env`, and token/cache files either live in the mounted directory or are added before start. + +## Known Limitations + +- **WDM and WebSocket behavior** are relied on for parity with desktop client event delivery; this pathway may change and is **not** marketed as a supported public integration surface—treat as a **reference** and monitor behavior after platform updates. +- **OAuth tokens** are stored in a **local YAML** file; protect the host filesystem and prefer a proper secret store for any production derivative. +- **Rate limits** and **org policies** may block device registration or membership updates; retry and backoff in the sample are best-effort only. +- **Leaving group spaces** cannot be undone automatically; you must be re-invited. +- **License:** This playbook vendors sample code derived from an upstream project; your obligations depend on that project's license—see the original repository. This repository's distribution terms are under [LICENSE](../../LICENSE). +- This Playbook is provided as a starting point. Webex does not guarantee the functional accuracy of the source code. Test thoroughly before use in a production environment. diff --git a/playbooks/auto-leave-spaces/diagrams/architecture-diagram.md b/playbooks/auto-leave-spaces/diagrams/architecture-diagram.md new file mode 100644 index 0000000..9479202 --- /dev/null +++ b/playbooks/auto-leave-spaces/diagrams/architecture-diagram.md @@ -0,0 +1,34 @@ +# Architecture + +Real-time **conversation.activity** events can drive **Rooms** lookups and **Memberships** changes after a title matches the block list and **`hide_direct`** or **`leave_group_spaces`** is enabled in YAML. + +```mermaid +sequenceDiagram + participant Script as "auto_leave.py" + participant WDM as "Webex WDM API" + participant WS as "Webex WebSocket" + participant RoomsAPI as "Webex Rooms API" + participant MembershipsAPI as "Webex Memberships API" + + Script->>WDM: List or create device + WDM-->>Script: webSocketUrl + Script->>WS: TLS connect + Script->>WS: Send Bearer token + loop Conversation events + WS-->>Script: conversation.activity + Note over Script: post or add when you join, skip your own activity + Script->>RoomsAPI: GET room details + alt Title matches block list regex + opt hide_direct is true and space is direct + Script->>MembershipsAPI: Update membership to hide + end + opt leave_group_spaces is true and space is group + Script->>MembershipsAPI: Delete membership to leave + end + end + end +``` + +OAuth tokens are obtained via the **wxc-sdk** integration helper (browser flow when not in Docker, or printed auth URL in Docker). Tokens refresh on a timer and the WebSocket is re-authorized after refresh. + +If both YAML flags are **`false`**, a matching title only produces logs (no **Memberships** calls). The **`opt`** blocks in the diagram are the only branches where the app mutates membership. diff --git a/playbooks/auto-leave-spaces/src/Dockerfile b/playbooks/auto-leave-spaces/src/Dockerfile new file mode 100644 index 0000000..4349639 --- /dev/null +++ b/playbooks/auto-leave-spaces/src/Dockerfile @@ -0,0 +1,17 @@ +# Build context: this src/ directory (see playbook README). +FROM python:3.9-slim-bookworm + +WORKDIR /app + +RUN pip install --no-cache-dir --upgrade pip + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY auto_leave.py ./ +# Default config for first run; override with a bind-mounted auto_leave.yml +COPY auto_leave.yml.sample ./auto_leave.yml + +ENV PYTHONUNBUFFERED=1 + +CMD ["python", "auto_leave.py"] diff --git a/playbooks/auto-leave-spaces/src/auto_leave.py b/playbooks/auto-leave-spaces/src/auto_leave.py new file mode 100755 index 0000000..a133752 --- /dev/null +++ b/playbooks/auto-leave-spaces/src/auto_leave.py @@ -0,0 +1,552 @@ +#!/usr/bin/env python +""" +Auto-hide or leave unwanted Webex messaging spaces using a block list and WDM WebSocket events. + +Adapted from https://github.com/jeokrohn/auto_leave for this playbook. + +WHAT THIS DOES +- Loads OAuth integration settings from environment variables and persists tokens to a + local YAML file next to this script (default: auto_leave_tokens.yml). +- Registers (or reuses) a Webex Device Manager (WDM) device and connects to the + real-time WebSocket stream. +- On conversation activity (verbs post or add where you are added), if the space + title matches a configured regex list and the actor is not you, may hide 1:1 spaces + or leave group spaces per hide_direct / leave_group_spaces in auto_leave.yml + (both default false, so enable at least one for any membership change). + +WHAT IT DOES NOT DO +- It is not production-hardened: minimal error surfaces, token cache on disk, no + centralized logging or secret management. +- It does not validate that WDM / WebSocket behavior is stable or publicly supported; + treat as implementation reference only. + +REQUIRED ENVIRONMENT VARIABLES +- INTEGRATION_CLIENT_ID — OAuth integration Client ID from developer.webex.com +- INTEGRATION_CLIENT_SECRET — OAuth integration Client Secret +- INTEGRATION_SCOPES — OAuth scopes as a space-separated list (same scopes as your + integration; do not paste a full authorize URL unless your SDK build expects it) + +OPTIONAL +- Copy env.template to .env in the working directory; python-dotenv loads it on start. + +CONFIG +- Copy auto_leave.yml.sample to auto_leave.yml in the working directory before running. +""" +import asyncio +import json +import logging +import os +import re +import socket +import ssl +import sys +import time +import uuid +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Optional + +import backoff +import websockets +import yaml +from aiohttp import ClientConnectorError +from dotenv import load_dotenv +from pydantic import Extra, parse_obj_as, BaseModel, Field +from wxc_sdk.as_api import AsWebexSimpleApi +from wxc_sdk.as_rest import AsRestError as RestError +from wxc_sdk.base import ApiModel +from wxc_sdk.common import RoomType +from wxc_sdk.integration import Integration +from wxc_sdk.memberships import Membership +from wxc_sdk.people import Person +from wxc_sdk.rooms import Room +from wxc_sdk.scopes import parse_scopes +from wxc_sdk.tokens import Tokens +from yaml import safe_load + +log = logging.getLogger(__name__) +ws_log = logging.getLogger(f'{__name__}.websocket') + +DEFAULT_DEVICE_URL = "https://wdm-a.wbx2.com/wdm/api/v1" + +DEVICE_NAME = 'auto_leave' + +CREATE_DEVICE = { + "deviceName": DEVICE_NAME, + "deviceType": "DESKTOP", + "localizedModel": "python", + "model": "python", + "name": DEVICE_NAME, + "systemName": "python-client", + "systemVersion": "0.1" +} + + +class Device(ApiModel): + class Config: + extra = Extra.ignore + + device_type: str + name: str + model: str + url: str + web_socket_url: str + localized_model: str + system_name: str + system_version: str + creation_time: datetime + modification_time: datetime + country_code: Optional[str] + region_code: Optional[str] + user_id: str + org_id: str + org_name: str + + +class WebexObject(ApiModel): + class Config: + extra = Extra.ignore + + id: Optional[str] + object_type: str + global_id: Optional[str] + email_address: Optional[str] + + @property + def space_id(self) -> Optional[str]: + return self.object_type == 'conversation' and self.global_id or None + + +class Activity(ApiModel): + """ + Model to deserialize activities received on the websocket + """ + + class Config: + extra = Extra.ignore + + object_type: str + url: str + published: datetime + verb: str + actor: Optional[WebexObject] + object: WebexObject + target: Optional[WebexObject] + + @property + def space_id(self) -> Optional[str]: + return self.object.space_id or self.target and self.target.space_id + + @property + def actor_email(self) -> Optional[str]: + return self.actor and self.actor.email_address + + +class Config(BaseModel): + """ + Model to parse config or + """ + + class Config: + extra = Extra.forbid + + hide_direct: bool = Field(default=False) + leave_group_spaces: bool = Field(default=False) + debug_logging: bool = Field(default=True) + rest_logging: bool = Field(default=True) + blocked: list[str] = Field(default_factory=list) + + +def start_auth_flow(auth_url: str): + log.info(f'Please open this url in your browser to obtain tokens: {auth_url}') + + +def build_integration() -> Integration: + """ + read integration parameters from environment variables and create an integration + + :return: :class:`wxc_sdk.integration.Integration` instance + """ + + def is_docker(): + cgroup = Path("/proc/self/cgroup") + return Path('/.dockerenv').is_file() or cgroup.is_file() and cgroup.read_text().find("docker") > -1 + + client_id = os.getenv('INTEGRATION_CLIENT_ID') + client_secret = os.getenv('INTEGRATION_CLIENT_SECRET') + scopes = parse_scopes(os.getenv('INTEGRATION_SCOPES')) + redirect_url = 'http://localhost:6001/redirect' + if not all((client_id, client_secret, scopes)): + raise ValueError('failed to get integration parameters from environment') + + if is_docker(): + auth = start_auth_flow + else: + auth = None + return Integration(client_id=client_id, client_secret=client_secret, scopes=scopes, + redirect_url=redirect_url, + initiate_flow_callback=auth) + + +@dataclass(init=False) +class SpaceMonitor: + """ + * get/register a device from/with WDM + * set up websocket + * handle conversation.activity events: if the space title matches the block list, + optionally hide direct spaces or leave group spaces (per YAML flags) + """ + # the Space Monitor config + config: Config + # list of regular expressions to check space names against + block_list: list[re.Pattern] + tokens: Tokens + integration: Integration + api: AsWebexSimpleApi + device: Device + me: Person + # set to keep references to scheduled tasks. + # see: https://docs.python.org/3/library/asyncio-task.html#creating-tasks + tasks: set + space_cache: dict[str, Room] + + def __init__(self): + """ + Set up the space monitor instance + """ + self.api = None + self.tasks = set() + self.websocket = None + self.space_cache = dict() + + async def close(self): + if self.api: + await self.api.close() + + @staticmethod + def token_yml_path() -> str: + """ + determine path of YML file to persist tokens + """ + return os.path.join(os.getcwd(), f'{os.path.splitext(os.path.basename(__file__))[0]}_tokens.yml') + + @staticmethod + def config_yml_path() -> str: + """ + determine path of YML config file + """ + return os.path.join(os.getcwd(), f'{os.path.splitext(os.path.basename(__file__))[0]}.yml') + + def write_tokens(self, tokens_to_cache: Tokens): + """ + Write tokens to YML file + """ + with open(self.token_yml_path(), mode='w') as f: + yaml.safe_dump(json.loads(tokens_to_cache.json()), f) + return + + def read_tokens(self) -> Optional[Tokens]: + """ + Read tokens from YML file + """ + try: + with open(self.token_yml_path(), mode='r') as f: + data = yaml.safe_load(f) + tokens_read = Tokens.parse_obj(data) + except Exception as e: + log.info(f'failed to read tokens from file: {e}') + tokens_read = None + return tokens_read + + async def _get_devices(self) -> list[Device]: + """ + Get list of current devices from WDM + """ + r = await self.api.session.rest_get(f'{DEFAULT_DEVICE_URL}/devices') + devices = parse_obj_as(list[Device], r['devices']) + return devices + + async def _get_or_create_device(self) -> Device: + """ + Get a device from WDM or create a new one if none exists + """ + devices = await self._get_devices() + # try to find "our" device + device = next((d for d in devices if d.name == DEVICE_NAME), None) + if device is None: + # create new device + r = await self.api.session.rest_post(f'{DEFAULT_DEVICE_URL}/devices', json=CREATE_DEVICE) + device = Device.parse_obj(r) + return device + + @backoff.on_exception(backoff.expo, Exception, max_value=60) + async def _token_monitor(self): + """ + Task monitoring the access token validity and refreshing the access token if needed + """ + while True: + remaining_seconds = self.tokens.remaining + log.debug(f'access token valid until {self.tokens.expires_at} exires in ' + f'{remaining_seconds} seconds') + # we want to renew 30 minutes before the expiration + to_wait = max(1, remaining_seconds - 30 * 60) + log.debug(f'wait for {to_wait} seconds') + await asyncio.sleep(to_wait) + + # run sync io in extra thread + def refresh_tokens(): + # get a new access token + self.integration.refresh(tokens=self.tokens) + self.write_tokens(self.tokens) + + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, refresh_tokens) + + # after refreshing the access token send new authentication on websocket + await self._websocket_auth() + + return + + async def _activity_in_blocked_space(self, space: Room): + """ + We detected activity in an unwanted space + """ + memberships = await self.api.membership.list(room_id=space.id) + membership = next((m for m in memberships + if m.person_id == self.me.person_id), + None) + if not membership: + log.error(f'couldn\'t find membership for space "{space.title}"') + return + membership: Membership + try: + if space.type == RoomType.direct and not membership.is_room_hidden: + log.info(f'action, set space "{space.title}" to hidden, ' + f'hide direct: {self.config.hide_direct}') + if self.config.hide_direct: + # try to hide the space + membership.is_room_hidden = True + await self.api.membership.update(update=membership) + elif space.type == RoomType.group: + log.info(f'action, leave space "{space.title}", ' + f'leave group spaces: {self.config.leave_group_spaces}') + if self.config.leave_group_spaces: + await self.api.membership.delete(membership_id=membership.id) + except RestError as e: + log.error(f'Error, {e}') + + async def _get_space_details(self, space_id: str) -> Room: + """ + Get space details from cache + """ + if (space := self.space_cache.get(space_id)) is None: + space = await self.api.rooms.details(room_id=space_id) + self.space_cache[space_id] = space + log.debug(f'not served from cache: "{space.title}"') + else: + log.debug(f'served from cache: "{space.title}"') + return space + + async def _conversation_activity(self, data: dict): + """ + Handle conversation activity. + """ + activity: Activity = Activity.parse_obj(data['activity']) + space_gid = activity.space_id + log.debug(f'{activity.verb} {activity.object.object_type}') + if space_gid is None: + return + + # only act on new messages of someone (me) getting added to a space.. + if activity.verb not in {'post', 'add'}: + log.debug(f'ignore activity {activity.verb}') + return + + # only act on 'add' if this is about me + if activity.verb == 'add' and (not activity.object or activity.object.email_address != self.me.emails[0]): + log.debug(f'ignore add {activity.object.email_address}, not me') + return + + # check if the space name is on the block list + space = await self._get_space_details(space_id=space_gid) + log.debug(f'{activity.verb} {activity.object.object_type} by {activity.actor_email} ' + f'in space "{space.title}"') + + if activity.actor_email == self.me.emails[0]: + # ignore activities by me + return + if any(b.match(space.title) for b in self.block_list): + # this space is on the block list + log.info(f'Space "{space.title}" is on the block list') + await self._activity_in_blocked_space(space) + return + + async def _handle_message(self, message_str: str): + """ + Handle one message received from Websocket + """ + msg = json.loads(message_str) + + data = msg['data'] + event_type = data['eventType'] + if event_type != 'conversation.activity': + log.debug(f'not a conversation activity. Event type: {event_type}') + return + # schedule an async task to handle conversation activity + # keep a reference of the task in a set to avoid that the task gets garbage collected + task = asyncio.create_task(self._conversation_activity(data)) + self.tasks.add(task) + task.add_done_callback(self.tasks.discard) + return + + async def _websocket_auth(self): + """ + Send authentication/authorization message on websocket + """ + # send authentication/authorization message + msg = {'id': str(uuid.uuid4()), + 'type': 'authorization', + 'data': {'token': 'Bearer ' + self.api.access_token}} + await self.websocket.send(json.dumps(msg)) + + def _setup_logging(self): + """ + Set up logging + """ + log_fmt = '%(asctime)s %(levelname)s %(name)s %(funcName)s %(message)s' + logging.basicConfig(level=logging.DEBUG if self.config.debug_logging else logging.INFO, + format=log_fmt) + logging.Formatter.converter = time.gmtime + rest_logger = logging.getLogger('wxc_sdk.as_rest') + if self.config.rest_logging: + rest_logger.propagate = True + rest_logger.setLevel(logging.DEBUG) + log_path = f'{self.config_yml_path()[:-4]}_rest.log' + file_handler = logging.FileHandler(log_path, mode='w') + formatter = logging.Formatter(log_fmt) + file_handler.setFormatter(formatter) + rest_logger.addHandler(file_handler) + else: + rest_logger.setLevel(logging.INFO) + logging.getLogger('websockets.client').setLevel(logging.INFO) + ws_log.setLevel(logging.INFO) + return + + @backoff.on_exception(backoff.expo, + (ClientConnectorError, socket.gaierror), + max_value=30) + async def _init_monitor(self)->bool: + """ + Initialization of monitor + :return: + """ + + try: + self.integration = build_integration() + except ValueError as e: + print( + f'Error: missing integration credentials — {e}.\n' + 'Set INTEGRATION_CLIENT_ID, INTEGRATION_CLIENT_SECRET, and ' + 'INTEGRATION_SCOPES in .env or your environment.', + file=sys.stderr, + ) + return False + tokens = self.integration.get_cached_tokens(read_from_cache=self.read_tokens, write_to_cache=self.write_tokens) + if tokens is None: + print('Failed to get tokens', file=sys.stderr) + return False + self.tokens = tokens + async with AsWebexSimpleApi(tokens=tokens) as api: + self.api = api + # get/register a device from/with WDM + # get person details + try: + self.device, self.me = await asyncio.gather(self._get_or_create_device(), + self.api.people.me()) + except RestError as e: + log.error(f'Failed to init (get/set device, get identity): {e}') + return False + self.api = AsWebexSimpleApi(tokens=tokens) + return True + + async def run(self) -> int: + """ + Run the space monitor + """ + try: + with open(self.config_yml_path(), mode='r') as file: + self.config = Config.parse_obj(safe_load(file)) + except Exception as e: + print(f'Failed to read config: {e}') + return 1 + # try to compile all entries in the block list. Force block list regexes to match full space titles + try: + self.block_list = list(map(lambda b: re.compile(f'^{b}$'), self.config.blocked)) + except re.error as e: + print(f'Failed to compile block list regular expression: {e}') + return 1 + + self._setup_logging() + + success = await self._init_monitor() + if not success: + return 1 + + log.info(f'monitoring conversation activities for {self.me.display_name}({self.me.emails[0]})') + + # schedule the access token monitoring task + task = asyncio.create_task(self._token_monitor()) + self.tasks.add(task) + task.add_done_callback(self.tasks.discard) + + @backoff.on_exception(backoff.expo, + (ClientConnectorError, websockets.ConnectionClosedError, + websockets.ConnectionClosedOK, websockets.ConnectionClosed, + socket.gaierror), + max_value=30) + async def _connect_and_listen(): + ws_url = self.device.web_socket_url + ws_log.info(f"Opening websocket {ws_url}") + ssl_context = ssl.create_default_context() + + async with websockets.connect(ws_url, ssl=ssl_context) as websocket: + self.websocket = websocket + ws_log.info("WebSocket Opened.") + await self._websocket_auth() + + while True: + # continuously receive and handle ws messages + message = await websocket.recv() + ws_log.debug("WebSocket Received Message(raw): %s\n" % message) + try: + await self._handle_message(message) + except Exception as handle_error: + ws_log.error(f'Failed to handle message: {handle_error}') + return + + try: + await _connect_and_listen() + except Exception as e: + # should not really happen as we try to catch everything using backoff but ... + ws_log.error(f"Error working the websocket: {e}") + return 1 + return 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + return + + +async def main(): + load_dotenv() + async with SpaceMonitor() as monitor: + exit_code = await monitor.run() + exit(exit_code) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/playbooks/auto-leave-spaces/src/auto_leave.yml.sample b/playbooks/auto-leave-spaces/src/auto_leave.yml.sample new file mode 100644 index 0000000..aa07609 --- /dev/null +++ b/playbooks/auto-leave-spaces/src/auto_leave.yml.sample @@ -0,0 +1,17 @@ +# Copy this file to auto_leave.yml in the same directory you run auto_leave.py from. +# +# Both flags default false: a matching space title only logs until you set at least one +# to true (then the app calls Webex Memberships APIs). + +# Hide 1:1 (direct) spaces from the messaging list when they match the block list +hide_direct: false +# Leave group spaces when they match. Caution: you cannot rejoin without a new invite. +leave_group_spaces: false +# Verbose console logging +debug_logging: true +# Log REST traffic to auto_leave_rest.log on disk +rest_logging: true +blocked: + # Regex strings; each must match the full space title (anchored in code as ^pattern$). + - example-bot-space + - .+Notification.* diff --git a/playbooks/auto-leave-spaces/src/docker-compose.yml b/playbooks/auto-leave-spaces/src/docker-compose.yml new file mode 100644 index 0000000..90c99be --- /dev/null +++ b/playbooks/auto-leave-spaces/src/docker-compose.yml @@ -0,0 +1,7 @@ +services: + auto_leave: + build: . + ports: + - "6001:6001" + volumes: + - ./:/app diff --git a/playbooks/auto-leave-spaces/src/env.template b/playbooks/auto-leave-spaces/src/env.template new file mode 100644 index 0000000..cb2ba45 --- /dev/null +++ b/playbooks/auto-leave-spaces/src/env.template @@ -0,0 +1,15 @@ +# Copy to .env in your working directory (same folder as auto_leave.py) or export these +# variables in your shell. Never commit real secrets. + +# Webex OAuth integration (https://developer.webex.com — create an integration) +INTEGRATION_CLIENT_ID= +INTEGRATION_CLIENT_SECRET= + +# Space-separated OAuth scopes granted to your integration (must match what you +# configured on the integration). Example (adjust to your app): +# spark:memberships_read spark:all spark:kms spark:people_read spark:rooms_read spark:memberships_write spark:devices_write spark:devices_read +INTEGRATION_SCOPES= + +# Optional: the sample upstream .env listed INTEGRATION_REDIRECT_URL; this script +# currently uses http://localhost:6001/redirect in code. Expose port 6001 when using +# Docker so the browser OAuth redirect can reach the local listener. diff --git a/playbooks/auto-leave-spaces/src/requirements.txt b/playbooks/auto-leave-spaces/src/requirements.txt new file mode 100644 index 0000000..9053dba --- /dev/null +++ b/playbooks/auto-leave-spaces/src/requirements.txt @@ -0,0 +1,27 @@ +-i https://pypi.org/simple +# Pinned set verified with `pip install -r requirements.txt` on Python 3.13; use Python 3.9+. +aenum==3.1.17 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.5 +aiosignal==1.4.0 +attrs==26.1.0 +backoff==2.2.1 +certifi==2026.2.25 +charset-normalizer==3.4.7 +frozenlist==1.8.0 +idna==3.11 +multidict==6.7.1 +propcache==0.4.1 +pydantic==1.10.26 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.2 +pytz==2026.1.post1 +PyYAML==6.0.3 +requests==2.31.0 +requests-toolbelt==0.10.1 +six==1.17.0 +typing_extensions==4.15.0 +urllib3==1.26.20 +websockets==16.0 +wxc-sdk==1.12.0 +yarl==1.23.0