objectionengine2-teams 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 objectionengine2-teams contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: objectionengine2-teams
3
+ Version: 1.0.0
4
+ Summary: Render Microsoft Teams chats and channels with objectionengine2
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Dist: objectionengine2[sentiment]>=1.0.0,<2
8
+ Requires-Dist: azure-identity>=1.25,<2
9
+ Requires-Dist: requests>=2.32,<3
10
+ Requires-Dist: torch>=2.8,<3
11
+ Requires-Dist: azure-identity-broker>=1.3.0
12
+ Requires-Dist: msal>=1.30,<2
13
+ Requires-Dist: objectionengine2[sentiment] ; extra == 'sentiment'
14
+ Requires-Python: >=3.10, <3.15
15
+ Provides-Extra: sentiment
16
+ Description-Content-Type: text/markdown
17
+
18
+ # objectionengine2-teams
19
+
20
+ Render a Microsoft Teams chat or channel as an Objection Engine 2 video. This is
21
+ a separate Python 3.10–3.14 package using Microsoft Graph delegated access.
22
+
23
+ ## Install
24
+
25
+ Clone `objectionengine2-teams` and install its dependencies with uv. The core
26
+ `objectionengine2` package is installed from PyPI:
27
+
28
+ ```bash
29
+ cd ~/development/objectionengine2-teams
30
+ uv sync --locked
31
+ ```
32
+
33
+ The standard install includes the core sentiment dependencies. The first
34
+ render downloads the Hugging Face sentiment model.
35
+
36
+ Install FFmpeg and `ffprobe` for video rendering. Objection Engine saves its
37
+ asset pack and sentiment model under the platformdirs user cache for
38
+ `objectionengine2`. The first render populates the cache. See the cache path
39
+ with `uv run python -c 'from objectionengine2.cache import cache_path; print(cache_path())'`.
40
+ Run from the Teams checkout:
41
+
42
+ ```bash
43
+ cd ~/development/objectionengine2-teams
44
+ mkdir -p outputs
45
+ uv run --locked objectionengine2-teams \
46
+ 'https://teams.microsoft.com/l/chat/19:example@thread.v2/conversations' \
47
+ -o outputs/teams.mp4
48
+ ```
49
+
50
+ Replace the example URL with a real link copied from a chat or channel. A
51
+ channel link renders the channel; a channel **message link** renders only its
52
+ thread. Its `parentMessageId` identifies the thread root, while the message ID
53
+ in the URL path is the first message rendered. A chat message link likewise
54
+ starts at the linked message. You can bound the render with another message
55
+ link in the same chat or channel thread:
56
+
57
+ ```bash
58
+ uv run --locked objectionengine2-teams \
59
+ '<start message link>' --end-link '<end message link>' -o outputs/teams.mp4
60
+ ```
61
+
62
+ Both endpoints are included. `--max-messages 20` selects the first 20 rendered
63
+ messages after a start link, or the newest 20 for an unbounded chat/channel
64
+ link. The CLI sorts them by creation time. When a channel thread's first
65
+ message has a title, it appears before that message's body in its comment.
66
+ Starting from a later reply excludes the first message and its title. Rich
67
+ attachments, reactions, deleted messages, and system events are not represented;
68
+ an attachment-only message becomes a short placeholder.
69
+
70
+ ## Sign-in and permissions
71
+
72
+ The default public client ID is the requested Microsoft Office ID,
73
+ `d3590ed6-52b3-4102-aeff-aad2292ab01c`. Azure Identity uses
74
+ `InteractiveBrowserBrokerCredential` by default and requests only the delegated
75
+ permission needed for the link:
76
+
77
+ | Link | Delegated Microsoft Graph permission |
78
+ | --- | --- |
79
+ | Chat | `Chat.Read` |
80
+ | Channel | `ChannelMessage.Read.All` |
81
+
82
+ The signed-in user must have access to the conversation. Microsoft Entra may
83
+ reject this client or require administrator consent, especially for the channel
84
+ permission. The client ID does not itself guarantee a grant. If the Office
85
+ client cannot obtain the requested scope, register a public client application
86
+ in your tenant with the Graph delegated permission and pass its ID through
87
+ `--client-id`. Broker sign-in uses the platform's broker redirect URI; on
88
+ Windows and WSL, that is
89
+ `ms-appx-web://Microsoft.AAD.BrokerPlugin/<client-id>`. The application must
90
+ allow that redirect. The `--redirect-uri` option applies only to explicit
91
+ `--auth-method browser` sign-in, whose default callback is
92
+ `http://localhost:8400`.
93
+
94
+ On Windows, broker sign-in uses Web Account Manager. On WSL 2.4.13 or newer,
95
+ it can use the Windows broker as well. On other Linux setups it may open a
96
+ browser if a supported broker is unavailable. To select another method, use:
97
+
98
+ ```bash
99
+ uv run --locked objectionengine2-teams '<Teams message link>' \
100
+ --auth-method browser -o outputs/teams.mp4
101
+ uv run --locked objectionengine2-teams '<Teams message link>' \
102
+ --auth-method device-code -o outputs/teams.mp4
103
+ ```
104
+
105
+ Azure Identity persists tokens in a per-user cache and reuses them on later
106
+ runs. Broker sign-in first tries the default broker account silently and then
107
+ offers interactive sign-in if needed. Use `--auth-method cache` to read an
108
+ already cached token without an interactive fallback. Azure Identity encrypts
109
+ the persistent cache when the platform supports it; on platforms without
110
+ user-data encryption (including some WSL installations), it may store tokens
111
+ unencrypted. Keep your user profile private. Browser and device-code sign-in
112
+ also save a separate non-secret account record under the user state directory
113
+ (`~/.local/state/objectionengine2-teams/` on Linux unless `XDG_STATE_HOME` is
114
+ set). The methods use the same named persistent token cache.
115
+
116
+ ## Sentiment and objections
117
+
118
+ Teams messages have no explicit `Comment.score`. Teams renders use the core
119
+ Hugging Face sentiment model by default. The normal command is enough:
120
+
121
+ ```bash
122
+ uv run --locked objectionengine2-teams \
123
+ '<Teams message link>' -o outputs/teams.mp4
124
+ ```
125
+
126
+ Set `oe_sentiment_model=neutral` or `oe_bypass_sentiment=1` to use neutral
127
+ sentiment for a render. An explicit `oe_sentiment_model` value is respected.
128
+ A strongly negative classification
129
+ (confidence above 0.5) has roughly a 70% chance of triggering an objection;
130
+ a strongly positive one has roughly a 30% chance. An objection is therefore
131
+ possible, not guaranteed, for any particular message. If you need to choose
132
+ the sentiment yourself, call `fetch_comments`, set `comment.score` to a negative
133
+ or positive value above 0.5 in magnitude, then pass the comments to
134
+ `objectionengine2.renderer.render_comment_list`. The objection still has a
135
+ random chance of occurring.
136
+
137
+ ## Python API
138
+
139
+ ```python
140
+ from objectionengine2_teams import fetch_comments, render_teams_link
141
+
142
+ comments = fetch_comments(start_message_url, end_link=end_message_url)
143
+ render_teams_link(start_message_url, "outputs/teams.mp4", end_link=end_message_url)
144
+ ```
145
+
146
+ `fetch_comments` returns `objectionengine2.beans.comment.Comment` objects.
147
+ `render_teams_link` enables Hugging Face sentiment by default for its render.
148
+ Both calls default to broker sign-in. Pass `auth_method="browser"`,
149
+ `auth_method="device-code"`, or `auth_method="cache"` to select another
150
+ method. Later calls reuse cached tokens when valid.
151
+ To inspect messages and render without fetching them twice, use the comments
152
+ with `objectionengine2.renderer.render_comment_list` directly.
153
+
154
+ ## Tests
155
+
156
+ ```bash
157
+ cd ~/development/objectionengine2-teams
158
+ uv run --locked pytest -q
159
+ uv build --no-sources
160
+ ```
161
+
162
+ The default tests use fake Graph responses and Azure Identity credentials. They
163
+ never access a tenant, download assets or models, or render private Teams content. To exercise
164
+ the complete video encoder with local fixture messages, run:
165
+
166
+ ```bash
167
+ uv run --locked pytest -q -m asset_integration
168
+ ```
169
+
170
+ That test calls `objectionengine2.utils.ensure_assets_are_available()` first,
171
+ then renders with default Hugging Face sentiment. It populates the platform
172
+ cache with assets and the model when necessary. A live Teams render still
173
+ requires a link, sign-in, and Graph consent; it cannot be validated by the
174
+ offline suite.
@@ -0,0 +1,157 @@
1
+ # objectionengine2-teams
2
+
3
+ Render a Microsoft Teams chat or channel as an Objection Engine 2 video. This is
4
+ a separate Python 3.10–3.14 package using Microsoft Graph delegated access.
5
+
6
+ ## Install
7
+
8
+ Clone `objectionengine2-teams` and install its dependencies with uv. The core
9
+ `objectionengine2` package is installed from PyPI:
10
+
11
+ ```bash
12
+ cd ~/development/objectionengine2-teams
13
+ uv sync --locked
14
+ ```
15
+
16
+ The standard install includes the core sentiment dependencies. The first
17
+ render downloads the Hugging Face sentiment model.
18
+
19
+ Install FFmpeg and `ffprobe` for video rendering. Objection Engine saves its
20
+ asset pack and sentiment model under the platformdirs user cache for
21
+ `objectionengine2`. The first render populates the cache. See the cache path
22
+ with `uv run python -c 'from objectionengine2.cache import cache_path; print(cache_path())'`.
23
+ Run from the Teams checkout:
24
+
25
+ ```bash
26
+ cd ~/development/objectionengine2-teams
27
+ mkdir -p outputs
28
+ uv run --locked objectionengine2-teams \
29
+ 'https://teams.microsoft.com/l/chat/19:example@thread.v2/conversations' \
30
+ -o outputs/teams.mp4
31
+ ```
32
+
33
+ Replace the example URL with a real link copied from a chat or channel. A
34
+ channel link renders the channel; a channel **message link** renders only its
35
+ thread. Its `parentMessageId` identifies the thread root, while the message ID
36
+ in the URL path is the first message rendered. A chat message link likewise
37
+ starts at the linked message. You can bound the render with another message
38
+ link in the same chat or channel thread:
39
+
40
+ ```bash
41
+ uv run --locked objectionengine2-teams \
42
+ '<start message link>' --end-link '<end message link>' -o outputs/teams.mp4
43
+ ```
44
+
45
+ Both endpoints are included. `--max-messages 20` selects the first 20 rendered
46
+ messages after a start link, or the newest 20 for an unbounded chat/channel
47
+ link. The CLI sorts them by creation time. When a channel thread's first
48
+ message has a title, it appears before that message's body in its comment.
49
+ Starting from a later reply excludes the first message and its title. Rich
50
+ attachments, reactions, deleted messages, and system events are not represented;
51
+ an attachment-only message becomes a short placeholder.
52
+
53
+ ## Sign-in and permissions
54
+
55
+ The default public client ID is the requested Microsoft Office ID,
56
+ `d3590ed6-52b3-4102-aeff-aad2292ab01c`. Azure Identity uses
57
+ `InteractiveBrowserBrokerCredential` by default and requests only the delegated
58
+ permission needed for the link:
59
+
60
+ | Link | Delegated Microsoft Graph permission |
61
+ | --- | --- |
62
+ | Chat | `Chat.Read` |
63
+ | Channel | `ChannelMessage.Read.All` |
64
+
65
+ The signed-in user must have access to the conversation. Microsoft Entra may
66
+ reject this client or require administrator consent, especially for the channel
67
+ permission. The client ID does not itself guarantee a grant. If the Office
68
+ client cannot obtain the requested scope, register a public client application
69
+ in your tenant with the Graph delegated permission and pass its ID through
70
+ `--client-id`. Broker sign-in uses the platform's broker redirect URI; on
71
+ Windows and WSL, that is
72
+ `ms-appx-web://Microsoft.AAD.BrokerPlugin/<client-id>`. The application must
73
+ allow that redirect. The `--redirect-uri` option applies only to explicit
74
+ `--auth-method browser` sign-in, whose default callback is
75
+ `http://localhost:8400`.
76
+
77
+ On Windows, broker sign-in uses Web Account Manager. On WSL 2.4.13 or newer,
78
+ it can use the Windows broker as well. On other Linux setups it may open a
79
+ browser if a supported broker is unavailable. To select another method, use:
80
+
81
+ ```bash
82
+ uv run --locked objectionengine2-teams '<Teams message link>' \
83
+ --auth-method browser -o outputs/teams.mp4
84
+ uv run --locked objectionengine2-teams '<Teams message link>' \
85
+ --auth-method device-code -o outputs/teams.mp4
86
+ ```
87
+
88
+ Azure Identity persists tokens in a per-user cache and reuses them on later
89
+ runs. Broker sign-in first tries the default broker account silently and then
90
+ offers interactive sign-in if needed. Use `--auth-method cache` to read an
91
+ already cached token without an interactive fallback. Azure Identity encrypts
92
+ the persistent cache when the platform supports it; on platforms without
93
+ user-data encryption (including some WSL installations), it may store tokens
94
+ unencrypted. Keep your user profile private. Browser and device-code sign-in
95
+ also save a separate non-secret account record under the user state directory
96
+ (`~/.local/state/objectionengine2-teams/` on Linux unless `XDG_STATE_HOME` is
97
+ set). The methods use the same named persistent token cache.
98
+
99
+ ## Sentiment and objections
100
+
101
+ Teams messages have no explicit `Comment.score`. Teams renders use the core
102
+ Hugging Face sentiment model by default. The normal command is enough:
103
+
104
+ ```bash
105
+ uv run --locked objectionengine2-teams \
106
+ '<Teams message link>' -o outputs/teams.mp4
107
+ ```
108
+
109
+ Set `oe_sentiment_model=neutral` or `oe_bypass_sentiment=1` to use neutral
110
+ sentiment for a render. An explicit `oe_sentiment_model` value is respected.
111
+ A strongly negative classification
112
+ (confidence above 0.5) has roughly a 70% chance of triggering an objection;
113
+ a strongly positive one has roughly a 30% chance. An objection is therefore
114
+ possible, not guaranteed, for any particular message. If you need to choose
115
+ the sentiment yourself, call `fetch_comments`, set `comment.score` to a negative
116
+ or positive value above 0.5 in magnitude, then pass the comments to
117
+ `objectionengine2.renderer.render_comment_list`. The objection still has a
118
+ random chance of occurring.
119
+
120
+ ## Python API
121
+
122
+ ```python
123
+ from objectionengine2_teams import fetch_comments, render_teams_link
124
+
125
+ comments = fetch_comments(start_message_url, end_link=end_message_url)
126
+ render_teams_link(start_message_url, "outputs/teams.mp4", end_link=end_message_url)
127
+ ```
128
+
129
+ `fetch_comments` returns `objectionengine2.beans.comment.Comment` objects.
130
+ `render_teams_link` enables Hugging Face sentiment by default for its render.
131
+ Both calls default to broker sign-in. Pass `auth_method="browser"`,
132
+ `auth_method="device-code"`, or `auth_method="cache"` to select another
133
+ method. Later calls reuse cached tokens when valid.
134
+ To inspect messages and render without fetching them twice, use the comments
135
+ with `objectionengine2.renderer.render_comment_list` directly.
136
+
137
+ ## Tests
138
+
139
+ ```bash
140
+ cd ~/development/objectionengine2-teams
141
+ uv run --locked pytest -q
142
+ uv build --no-sources
143
+ ```
144
+
145
+ The default tests use fake Graph responses and Azure Identity credentials. They
146
+ never access a tenant, download assets or models, or render private Teams content. To exercise
147
+ the complete video encoder with local fixture messages, run:
148
+
149
+ ```bash
150
+ uv run --locked pytest -q -m asset_integration
151
+ ```
152
+
153
+ That test calls `objectionengine2.utils.ensure_assets_are_available()` first,
154
+ then renders with default Hugging Face sentiment. It populates the platform
155
+ cache with assets and the model when necessary. A live Teams render still
156
+ requires a link, sign-in, and Graph consent; it cannot be validated by the
157
+ offline suite.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "objectionengine2-teams"
3
+ version = "1.0.0"
4
+ description = "Render Microsoft Teams chats and channels with objectionengine2"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10,<3.15"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ dependencies = [
10
+ "objectionengine2[sentiment]>=1.0.0,<2",
11
+ "azure-identity>=1.25,<2",
12
+ "requests>=2.32,<3",
13
+ "torch>=2.8,<3",
14
+ "azure-identity-broker>=1.3.0",
15
+ "msal>=1.30,<2",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ sentiment = ["objectionengine2[sentiment]"]
20
+
21
+ [project.scripts]
22
+ objectionengine2-teams = "objectionengine2_teams.cli:main"
23
+
24
+ [dependency-groups]
25
+ dev = ["pytest>=8.4,<10"]
26
+
27
+ [[tool.uv.sources.torch]]
28
+ index = "pytorch-cpu"
29
+ marker = "sys_platform == 'linux' or sys_platform == 'win32'"
30
+
31
+ [[tool.uv.index]]
32
+ name = "pytorch-cpu"
33
+ url = "https://download.pytorch.org/whl/cpu"
34
+ explicit = true
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ addopts = "-m 'not asset_integration'"
39
+ markers = ["asset_integration: renders an MP4 after downloading game assets"]
40
+
41
+ [build-system]
42
+ requires = ["uv_build>=0.12.4,<0.13"]
43
+ build-backend = "uv_build"
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "objectionengine2-teams"
3
+ version = "1.0.0"
4
+ description = "Render Microsoft Teams chats and channels with objectionengine2"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10,<3.15"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ dependencies = [
10
+ "objectionengine2[sentiment]>=1.0.0,<2",
11
+ "azure-identity>=1.25,<2",
12
+ "requests>=2.32,<3",
13
+ "torch>=2.8,<3",
14
+ "azure-identity-broker>=1.3.0",
15
+ "msal>=1.30,<2",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ sentiment = ["objectionengine2[sentiment]"]
20
+
21
+ [project.scripts]
22
+ objectionengine2-teams = "objectionengine2_teams.cli:main"
23
+
24
+ [dependency-groups]
25
+ dev = ["pytest>=8.4,<10"]
26
+
27
+ [tool.uv.sources]
28
+ torch = [{ index = "pytorch-cpu", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }]
29
+
30
+ [[tool.uv.index]]
31
+ name = "pytorch-cpu"
32
+ url = "https://download.pytorch.org/whl/cpu"
33
+ explicit = true
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ addopts = "-m 'not asset_integration'"
38
+ markers = ["asset_integration: renders an MP4 after downloading game assets"]
39
+
40
+ [build-system]
41
+ requires = ["uv_build>=0.12.4,<0.13"]
42
+ build-backend = "uv_build"
@@ -0,0 +1,20 @@
1
+ """Use Microsoft Graph messages as objectionengine2 dialogue."""
2
+
3
+ from .auth import OFFICE_CLIENT_ID, AuthenticationError, acquire_access_token
4
+ from .convert import messages_to_comments
5
+ from .graph import GraphClient, GraphError
6
+ from .links import TeamsTarget, parse_teams_link
7
+ from .render import fetch_comments, render_teams_link
8
+
9
+ __all__ = [
10
+ "OFFICE_CLIENT_ID",
11
+ "AuthenticationError",
12
+ "GraphClient",
13
+ "GraphError",
14
+ "TeamsTarget",
15
+ "acquire_access_token",
16
+ "fetch_comments",
17
+ "messages_to_comments",
18
+ "parse_teams_link",
19
+ "render_teams_link",
20
+ ]
@@ -0,0 +1,159 @@
1
+ """Delegated Microsoft Graph authentication with a persistent Azure Identity cache."""
2
+
3
+ import hashlib
4
+ import os
5
+ import sys
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from azure.core.exceptions import AzureError
10
+ from azure.identity import (
11
+ AuthenticationRecord,
12
+ DeviceCodeCredential,
13
+ InteractiveBrowserCredential,
14
+ SharedTokenCacheCredential,
15
+ TokenCachePersistenceOptions,
16
+ )
17
+ from azure.identity.broker import InteractiveBrowserBrokerCredential
18
+ from msal import PublicClientApplication
19
+
20
+ OFFICE_CLIENT_ID = "d3590ed6-52b3-4102-aeff-aad2292ab01c"
21
+ BROWSER_REDIRECT_URI = "http://localhost:8400"
22
+ _SCOPES = {
23
+ "chat": "https://graph.microsoft.com/Chat.ReadWrite",
24
+ "channel": "https://graph.microsoft.com/ChannelMessage.Read.All",
25
+ }
26
+
27
+
28
+ class AuthenticationError(RuntimeError):
29
+ """Microsoft Entra ID did not issue a usable Graph token."""
30
+
31
+
32
+ def _state_dir() -> Path:
33
+ """Return the per-user directory for non-secret authentication records."""
34
+ if os.name == "nt":
35
+ root = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
36
+ elif sys.platform == "darwin":
37
+ root = Path.home() / "Library" / "Application Support"
38
+ else:
39
+ root = Path(
40
+ os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state"
41
+ )
42
+ return root / "objectionengine2-teams"
43
+
44
+
45
+ def _cache_key(client_id: str, tenant_id: str) -> str:
46
+ return hashlib.sha256(
47
+ f"{client_id.lower()}\0{tenant_id.lower()}".encode()
48
+ ).hexdigest()[:24]
49
+
50
+
51
+ def _load_record(path: Path, client_id: str) -> AuthenticationRecord | None:
52
+ try:
53
+ serialized = path.read_text(encoding="utf-8")
54
+ except FileNotFoundError:
55
+ return None
56
+ except OSError as exc:
57
+ raise AuthenticationError(
58
+ f"Could not read the cached account record at {path}: {exc}"
59
+ ) from exc
60
+ try:
61
+ record = AuthenticationRecord.deserialize(serialized)
62
+ except (TypeError, ValueError, KeyError) as exc:
63
+ raise AuthenticationError(
64
+ f"The cached account record at {path} is invalid. Delete it and sign in again."
65
+ ) from exc
66
+ return record if record.client_id.lower() == client_id.lower() else None
67
+
68
+
69
+ def _save_record(path: Path, record: AuthenticationRecord) -> None:
70
+ """Atomically save account identity; Azure Identity stores tokens separately."""
71
+ temporary_path: Path | None = None
72
+ try:
73
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
74
+ with tempfile.NamedTemporaryFile(
75
+ mode="w",
76
+ encoding="utf-8",
77
+ dir=path.parent,
78
+ prefix=".account-",
79
+ delete=False,
80
+ ) as temporary:
81
+ temporary_path = Path(temporary.name)
82
+ temporary.write(record.serialize())
83
+ os.replace(temporary_path, path)
84
+ except OSError as exc:
85
+ raise AuthenticationError(
86
+ f"Could not save the cached account record at {path}: {exc}"
87
+ ) from exc
88
+ finally:
89
+ if temporary_path is not None:
90
+ temporary_path.unlink(missing_ok=True)
91
+
92
+
93
+ def acquire_access_token(
94
+ kind: str,
95
+ *,
96
+ tenant_id: str | None = None,
97
+ client_id: str = OFFICE_CLIENT_ID,
98
+ auth_method: str = "broker",
99
+ redirect_uri: str = BROWSER_REDIRECT_URI,
100
+ record_dir: str | Path | None = None,
101
+ ) -> str:
102
+ """Return a Graph token, reusing the user's cached account when possible.
103
+
104
+ Broker sign-in is the default. Set ``auth_method='browser'`` or
105
+ ``auth_method='device-code'`` to use another interactive method. All
106
+ methods use the Microsoft Office public client ID unless overridden.
107
+ """
108
+ if kind not in _SCOPES:
109
+ raise ValueError("kind must be 'chat' or 'channel'")
110
+ if auth_method not in ("broker", "browser", "device-code", "cache"):
111
+ raise ValueError(
112
+ "auth_method must be 'broker', 'browser', 'device-code', or 'cache'"
113
+ )
114
+
115
+ tenant = tenant_id or "organizations"
116
+ key = _cache_key(client_id, tenant)
117
+ record_path = (
118
+ Path(record_dir) if record_dir is not None else _state_dir()
119
+ ) / f"{key}.json"
120
+ record = _load_record(record_path, client_id)
121
+ options = TokenCachePersistenceOptions(
122
+ name=f"objectionengine2-teams-{key}", allow_unencrypted_storage=True
123
+ )
124
+ kwargs = {
125
+ "client_id": client_id,
126
+ "tenant_id": tenant,
127
+ "cache_persistence_options": options,
128
+ }
129
+ if record is not None:
130
+ kwargs["authentication_record"] = record
131
+ scope = _SCOPES[kind]
132
+ credential = None
133
+ try:
134
+ if auth_method == "broker":
135
+ credential = InteractiveBrowserBrokerCredential(
136
+ parent_window_handle=PublicClientApplication.CONSOLE_WINDOW_HANDLE,
137
+ use_default_broker_account=True,
138
+ **kwargs,
139
+ )
140
+ elif auth_method == "cache":
141
+ credential = SharedTokenCacheCredential(**kwargs)
142
+ elif auth_method == "browser":
143
+ credential = InteractiveBrowserCredential(
144
+ redirect_uri=redirect_uri, **kwargs
145
+ )
146
+ elif auth_method == "device-code":
147
+ credential = DeviceCodeCredential(**kwargs)
148
+ if record is None and auth_method in ("browser", "device-code"):
149
+ record = credential.authenticate(scopes=[scope])
150
+ _save_record(record_path, record)
151
+ return credential.get_token(scope).token
152
+ except AzureError as exc:
153
+ raise AuthenticationError(
154
+ f"Microsoft sign-in could not grant {scope} to client {client_id}: {exc}. "
155
+ "Your tenant may require administrator consent for this Graph permission."
156
+ ) from exc
157
+ finally:
158
+ if credential is not None:
159
+ credential.close()
@@ -0,0 +1,61 @@
1
+ """Command-line interface for rendering a Teams conversation."""
2
+
3
+ import argparse
4
+
5
+ from .auth import BROWSER_REDIRECT_URI, OFFICE_CLIENT_ID, AuthenticationError
6
+ from .graph import GraphError
7
+ from .render import render_teams_link
8
+
9
+
10
+ def main(argv: list[str] | None = None) -> int:
11
+ parser = argparse.ArgumentParser(
12
+ prog="objectionengine2-teams",
13
+ description="Render a Microsoft Teams chat or channel as an Objection Engine video.",
14
+ )
15
+ parser.add_argument("link", help="A Teams chat, channel, or starting message link")
16
+ parser.add_argument("-o", "--output", default="teams.mp4", help="Output MP4 path")
17
+ parser.add_argument(
18
+ "--end-link", help="Inclusive ending message link in the same conversation"
19
+ )
20
+ parser.add_argument(
21
+ "--max-messages",
22
+ type=int,
23
+ help="At most N messages from the start link, or the newest N for a whole chat/channel",
24
+ )
25
+ parser.add_argument(
26
+ "--client-id", default=OFFICE_CLIENT_ID, help="Public client application ID"
27
+ )
28
+ parser.add_argument(
29
+ "--auth-method",
30
+ choices=("broker", "browser", "device-code", "cache"),
31
+ default="broker",
32
+ help="Sign-in method (default: broker)",
33
+ )
34
+ parser.add_argument(
35
+ "--redirect-uri",
36
+ default=BROWSER_REDIRECT_URI,
37
+ help="Registered redirect URI for browser sign-in",
38
+ )
39
+ parser.add_argument(
40
+ "--music-code", default="pwr", help="Objection Engine music code"
41
+ )
42
+ parser.add_argument("--resolution-scale", type=float, default=1.0)
43
+ args = parser.parse_args(argv)
44
+ if args.max_messages is not None and args.max_messages < 1:
45
+ parser.error("--max-messages must be positive")
46
+ try:
47
+ output = render_teams_link(
48
+ args.link,
49
+ args.output,
50
+ end_link=args.end_link,
51
+ max_messages=args.max_messages,
52
+ client_id=args.client_id,
53
+ auth_method=args.auth_method,
54
+ redirect_uri=args.redirect_uri,
55
+ music_code=args.music_code,
56
+ resolution_scale=args.resolution_scale,
57
+ )
58
+ except (AuthenticationError, GraphError, ValueError) as exc:
59
+ parser.exit(1, f"{parser.prog}: {exc}\n")
60
+ print(f"Saved {output}")
61
+ return 0
@@ -0,0 +1,119 @@
1
+ """Map Microsoft Graph chatMessage bodies to objectionengine2 comments."""
2
+
3
+ from html.parser import HTMLParser
4
+ import re
5
+ from typing import Any, Iterable
6
+
7
+ from objectionengine2.beans.comment import Comment
8
+
9
+
10
+ class _TeamsHTMLParser(HTMLParser):
11
+ _BREAKS = {"br", "div", "p", "li", "blockquote", "tr"}
12
+
13
+ def __init__(self) -> None:
14
+ super().__init__(convert_charrefs=True)
15
+ self.parts: list[str] = []
16
+ self.hidden_depth = 0
17
+
18
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
19
+ if tag in {"script", "style"}:
20
+ self.hidden_depth += 1
21
+ elif self.hidden_depth == 0 and tag in self._BREAKS:
22
+ self.parts.append("\n")
23
+ elif self.hidden_depth == 0 and tag == "img":
24
+ alt = dict(attrs).get("alt")
25
+ if alt:
26
+ self.parts.append(alt)
27
+
28
+ def handle_endtag(self, tag: str) -> None:
29
+ if tag in {"script", "style"} and self.hidden_depth:
30
+ self.hidden_depth -= 1
31
+ elif self.hidden_depth == 0 and tag in self._BREAKS:
32
+ self.parts.append("\n")
33
+
34
+ def handle_data(self, data: str) -> None:
35
+ if self.hidden_depth == 0:
36
+ self.parts.append(data)
37
+
38
+
39
+ def _message_text(message: dict[str, Any]) -> str:
40
+ subject = message.get("subject")
41
+ title = re.sub(r"\s+", " ", subject).strip() if isinstance(subject, str) else ""
42
+ body = message.get("body") or {}
43
+ if not isinstance(body, dict):
44
+ body = {}
45
+ content = body.get("content") or ""
46
+ if not isinstance(content, str):
47
+ content = ""
48
+ if str(body.get("contentType", "")).lower() == "html":
49
+ parser = _TeamsHTMLParser()
50
+ parser.feed(content)
51
+ content = "".join(parser.parts)
52
+ lines = [re.sub(r"\s+", " ", line).strip() for line in content.splitlines()]
53
+ text = "\n".join(line for line in lines if line)
54
+ if not text:
55
+ attachments = message.get("attachments") or []
56
+ if isinstance(attachments, list) and attachments:
57
+ names = [attachment.get("name") for attachment in attachments if isinstance(attachment, dict)]
58
+ names = [name for name in names if isinstance(name, str) and name.strip()]
59
+ text = "Shared an attachment" + (": " + ", ".join(names) if names else "")
60
+ return "\n".join(part for part in (title, text) if part)
61
+
62
+
63
+ def _speaker(message: dict[str, Any]) -> tuple[str, str]:
64
+ actor = message.get("from") or {}
65
+ if not isinstance(actor, dict):
66
+ actor = {}
67
+ for key in ("user", "application", "device"):
68
+ value = actor.get(key)
69
+ if isinstance(value, dict):
70
+ name = value.get("displayName") or "Unknown speaker"
71
+ identity = value.get("id") or name
72
+ return str(identity), str(name)
73
+ return "unknown", "Unknown speaker"
74
+
75
+
76
+ def messages_to_comments(
77
+ messages: Iterable[dict[str, Any]],
78
+ *,
79
+ start_message_id: str | None = None,
80
+ end_message_id: str | None = None,
81
+ max_messages: int | None = None,
82
+ ) -> list[Comment]:
83
+ """Convert an inclusive message range to chronological dialogue."""
84
+ if max_messages is not None and max_messages < 1:
85
+ raise ValueError("max_messages must be positive")
86
+ ordered: list[dict[str, Any]] = []
87
+ seen: set[str] = set()
88
+ for message in messages:
89
+ message_id = str(message.get("id") or "")
90
+ if message_id and message_id in seen:
91
+ continue
92
+ seen.add(message_id)
93
+ ordered.append(message)
94
+ ordered.sort(key=lambda message: (str(message.get("createdDateTime") or ""), str(message.get("id") or "")))
95
+
96
+ ids = [str(message.get("id") or "") for message in ordered]
97
+ if start_message_id is not None and start_message_id not in ids:
98
+ raise ValueError(f"Start message {start_message_id} was not found in this conversation")
99
+ if end_message_id is not None and end_message_id not in ids:
100
+ raise ValueError(f"End message {end_message_id} was not found in this conversation")
101
+ if not ordered:
102
+ return []
103
+ start = ids.index(start_message_id) if start_message_id is not None else 0
104
+ end = ids.index(end_message_id) if end_message_id is not None else len(ordered) - 1
105
+ if end < start:
106
+ raise ValueError("The end message occurs before the start message")
107
+
108
+ comments: list[Comment] = []
109
+ for message in ordered[start : end + 1]:
110
+ if message.get("messageType", "message") != "message" or message.get("deletedDateTime"):
111
+ continue
112
+ text = _message_text(message)
113
+ if not text:
114
+ continue
115
+ user_id, user_name = _speaker(message)
116
+ comments.append(Comment(user_id=user_id, user_name=user_name, text_content=text))
117
+ if max_messages is not None:
118
+ comments = comments[:max_messages] if start_message_id else comments[-max_messages:]
119
+ return comments
@@ -0,0 +1,171 @@
1
+ """Small Microsoft Graph client for Teams message collections."""
2
+
3
+ from collections.abc import Callable, Iterator
4
+ from datetime import datetime, timezone
5
+ from email.utils import parsedate_to_datetime
6
+ from time import sleep
7
+ from typing import Any
8
+ from urllib.parse import quote, urlsplit
9
+
10
+ import requests
11
+
12
+ from .links import TeamsTarget
13
+
14
+
15
+ _GRAPH_ROOT = "https://graph.microsoft.com/v1.0"
16
+
17
+
18
+ class GraphError(RuntimeError):
19
+ """Microsoft Graph did not return a usable Teams message collection."""
20
+
21
+
22
+ class GraphClient:
23
+ def __init__(
24
+ self,
25
+ access_token: str,
26
+ *,
27
+ session: requests.Session | None = None,
28
+ wait: Callable[[float], None] = sleep,
29
+ max_retries: int = 4,
30
+ ) -> None:
31
+ if not access_token:
32
+ raise ValueError("A Microsoft Graph access token is required")
33
+ self._session = session if session is not None else requests.Session()
34
+ self._owns_session = session is None
35
+ self._headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/json"}
36
+ self._wait = wait
37
+ self._max_retries = max_retries
38
+
39
+ def close(self) -> None:
40
+ if self._owns_session:
41
+ self._session.close()
42
+
43
+ def __enter__(self) -> "GraphClient":
44
+ return self
45
+
46
+ def __exit__(self, *_exc: object) -> None:
47
+ self.close()
48
+
49
+ @staticmethod
50
+ def _validate_url(url: str) -> None:
51
+ parsed = urlsplit(url)
52
+ if (
53
+ parsed.scheme != "https"
54
+ or parsed.hostname != "graph.microsoft.com"
55
+ or parsed.username is not None
56
+ or parsed.password is not None
57
+ or parsed.port is not None
58
+ or not parsed.path.startswith("/v1.0/")
59
+ ):
60
+ raise GraphError("Microsoft Graph returned an unsafe pagination URL")
61
+
62
+ @staticmethod
63
+ def _retry_delay(response: requests.Response, attempt: int) -> float:
64
+ retry_after = response.headers.get("Retry-After")
65
+ if retry_after:
66
+ try:
67
+ return min(120.0, max(0.0, float(retry_after)))
68
+ except ValueError:
69
+ try:
70
+ deadline = parsedate_to_datetime(retry_after)
71
+ return min(120.0, max(0.0, (deadline - datetime.now(timezone.utc)).total_seconds()))
72
+ except (TypeError, ValueError):
73
+ pass
74
+ return min(30.0, float(2**attempt))
75
+
76
+ def _get_json(self, url: str, scope: str) -> dict[str, Any]:
77
+ self._validate_url(url)
78
+ for attempt in range(self._max_retries + 1):
79
+ try:
80
+ response = self._session.get(
81
+ url, headers=self._headers, timeout=30, allow_redirects=False
82
+ )
83
+ except requests.RequestException as exc:
84
+ raise GraphError(f"Could not reach Microsoft Graph: {exc}") from exc
85
+ if response.status_code in (429, 503, 504) and attempt < self._max_retries:
86
+ self._wait(self._retry_delay(response, attempt))
87
+ continue
88
+ if response.status_code == 403:
89
+ raise GraphError(
90
+ f"Microsoft Graph denied access (403). The signed-in user needs access "
91
+ f"to this conversation, and client consent for {scope} is required."
92
+ )
93
+ if response.status_code == 401:
94
+ raise GraphError("Microsoft Graph rejected the access token (401); sign in again")
95
+ if response.status_code >= 400 or response.status_code < 200 or response.status_code >= 300:
96
+ detail = ""
97
+ try:
98
+ detail = response.json().get("error", {}).get("message", "")
99
+ except (ValueError, AttributeError):
100
+ pass
101
+ raise GraphError(f"Microsoft Graph returned HTTP {response.status_code}: {detail}".strip())
102
+ try:
103
+ data = response.json()
104
+ except ValueError as exc:
105
+ raise GraphError("Microsoft Graph returned invalid JSON") from exc
106
+ if not isinstance(data, dict):
107
+ raise GraphError("Microsoft Graph returned an unexpected response")
108
+ return data
109
+ raise AssertionError("unreachable")
110
+
111
+ def _pages(self, url: str, scope: str) -> Iterator[dict[str, Any]]:
112
+ seen: set[str] = set()
113
+ while url:
114
+ if url in seen:
115
+ raise GraphError("Microsoft Graph returned a repeated pagination URL")
116
+ seen.add(url)
117
+ page = self._get_json(url, scope)
118
+ items = page.get("value")
119
+ if not isinstance(items, list):
120
+ raise GraphError("Microsoft Graph omitted the message collection")
121
+ for item in items:
122
+ if not isinstance(item, dict):
123
+ raise GraphError("Microsoft Graph returned a malformed message")
124
+ yield item
125
+ next_url = page.get("@odata.nextLink")
126
+ if next_url is not None and not isinstance(next_url, str):
127
+ raise GraphError("Microsoft Graph returned a malformed pagination URL")
128
+ url = next_url or ""
129
+
130
+ def iter_messages(
131
+ self, target: TeamsTarget, *, end_message_id: str | None = None
132
+ ) -> Iterator[dict[str, Any]]:
133
+ """Yield chat messages, one channel thread, or a complete channel."""
134
+ if target.kind == "chat" and target.chat_id:
135
+ chat = quote(target.chat_id, safe="")
136
+ url = f"{_GRAPH_ROOT}/chats/{chat}/messages?$top=50&$orderby=createdDateTime desc"
137
+ found_start = False
138
+ found_end = end_message_id is None
139
+ for message in self._pages(url, "Chat.Read"):
140
+ yield message
141
+ if str(message.get("id")) == target.start_message_id:
142
+ found_start = True
143
+ if str(message.get("id")) == end_message_id:
144
+ found_end = True
145
+ if target.start_message_id and found_start and found_end:
146
+ break
147
+ return
148
+ if target.kind == "channel" and target.team_id and target.channel_id:
149
+ team = quote(target.team_id, safe="")
150
+ channel = quote(target.channel_id, safe="")
151
+ root = f"{_GRAPH_ROOT}/teams/{team}/channels/{channel}/messages"
152
+ if target.start_message_id:
153
+ parent_id = target.parent_message_id or target.start_message_id
154
+ thread_url = f"{root}/{quote(parent_id, safe='')}"
155
+ parent = self._get_json(thread_url, "ChannelMessage.Read.All")
156
+ if str(parent.get("id")) != parent_id:
157
+ raise GraphError("Microsoft Graph returned a different channel thread")
158
+ yield parent
159
+ yield from self._pages(
160
+ f"{thread_url}/replies?$top=50", "ChannelMessage.Read.All"
161
+ )
162
+ return
163
+ for message in self._pages(f"{root}?$top=50", "ChannelMessage.Read.All"):
164
+ yield message
165
+ message_id = message.get("id")
166
+ if not isinstance(message_id, str) or not message_id:
167
+ raise GraphError("A channel message is missing its ID")
168
+ reply_url = f"{root}/{quote(message_id, safe='')}/replies?$top=50"
169
+ yield from self._pages(reply_url, "ChannelMessage.Read.All")
170
+ return
171
+ raise ValueError("Incomplete Teams target")
@@ -0,0 +1,120 @@
1
+ """Recognize documented Microsoft Teams chat and channel deep links."""
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ import re
6
+ from typing import Literal
7
+ from urllib.parse import parse_qs, unquote, urlsplit
8
+ from uuid import UUID
9
+
10
+
11
+ _TEAMS_HOSTS = {"teams.microsoft.com", "teams.cloud.microsoft"}
12
+ _ID_RE = re.compile(r"^19:[A-Za-z0-9._@:-]{1,500}$")
13
+ _MESSAGE_ID_RE = re.compile(r"^[A-Za-z0-9._@:-]{1,500}$")
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class TeamsTarget:
18
+ kind: Literal["chat", "channel"]
19
+ chat_id: str | None = None
20
+ team_id: str | None = None
21
+ channel_id: str | None = None
22
+ tenant_id: str | None = None
23
+ start_message_id: str | None = None
24
+ parent_message_id: str | None = None
25
+
26
+
27
+ def _uuid_parameter(query: dict[str, list[str]], name: str) -> str | None:
28
+ values = query.get(name, [])
29
+ if not values:
30
+ return None
31
+ if len(values) != 1:
32
+ raise ValueError(f"Teams link has more than one {name} parameter")
33
+ try:
34
+ return str(UUID(values[0]))
35
+ except ValueError as exc:
36
+ raise ValueError(f"Teams link has an invalid {name} parameter") from exc
37
+
38
+
39
+ def _teams_id(raw: str) -> str:
40
+ value = unquote(raw)
41
+ if not _ID_RE.fullmatch(value):
42
+ raise ValueError("Teams link has an invalid chat or channel ID")
43
+ return value
44
+
45
+
46
+ def _message_id(raw: str) -> str:
47
+ value = unquote(raw)
48
+ if not _MESSAGE_ID_RE.fullmatch(value):
49
+ raise ValueError("Teams link has an invalid message ID")
50
+ return value
51
+
52
+
53
+ def parse_teams_link(link: str) -> TeamsTarget:
54
+ """Return the conversation addressed by a Teams deep link.
55
+
56
+ A channel message link selects one thread and starts at its path message ID.
57
+ """
58
+ parsed = urlsplit(link)
59
+ if (
60
+ parsed.scheme != "https"
61
+ or parsed.hostname not in _TEAMS_HOSTS
62
+ or parsed.username is not None
63
+ or parsed.password is not None
64
+ or parsed.port is not None
65
+ ):
66
+ raise ValueError("Expected an HTTPS link on teams.microsoft.com or teams.cloud.microsoft")
67
+
68
+ parts = parsed.path.strip("/").split("/")
69
+ query = parse_qs(parsed.query)
70
+ tenant_id = _uuid_parameter(query, "tenantId")
71
+ group_id = _uuid_parameter(query, "groupId")
72
+
73
+ if len(parts) >= 4 and parts[:2] == ["l", "chat"] and parts[3] == "conversations":
74
+ chat_id = _teams_id(parts[2])
75
+ return TeamsTarget("chat", chat_id=chat_id, tenant_id=tenant_id)
76
+
77
+ if len(parts) >= 4 and parts[:2] == ["l", "channel"]:
78
+ channel_id = _teams_id(parts[2])
79
+ if group_id is None:
80
+ raise ValueError("Channel links must include groupId (the team ID)")
81
+ return TeamsTarget("channel", team_id=group_id, channel_id=channel_id, tenant_id=tenant_id)
82
+
83
+ if len(parts) == 4 and parts[:2] == ["l", "team"] and parts[3] == "conversations":
84
+ channel_id = _teams_id(parts[2])
85
+ if group_id is None:
86
+ raise ValueError("Team links must include groupId (the team ID)")
87
+ return TeamsTarget("channel", team_id=group_id, channel_id=channel_id, tenant_id=tenant_id)
88
+
89
+ if len(parts) == 4 and parts[:2] == ["l", "message"]:
90
+ conversation_id = _teams_id(parts[2])
91
+ start_message_id = _message_id(parts[3])
92
+ if group_id is not None:
93
+ parents = query.get("parentMessageId", [])
94
+ if len(parents) > 1:
95
+ raise ValueError("Teams link has more than one parentMessageId parameter")
96
+ parent_message_id = _message_id(parents[0]) if parents else start_message_id
97
+ return TeamsTarget(
98
+ "channel",
99
+ team_id=group_id,
100
+ channel_id=conversation_id,
101
+ tenant_id=tenant_id,
102
+ start_message_id=start_message_id,
103
+ parent_message_id=parent_message_id,
104
+ )
105
+ context_values = query.get("context", [])
106
+ if context_values:
107
+ try:
108
+ context = json.loads(context_values[0])
109
+ except json.JSONDecodeError as exc:
110
+ raise ValueError("Teams link has an invalid context parameter") from exc
111
+ if not isinstance(context, dict) or context.get("contextType") != "chat":
112
+ raise ValueError("A channel message link must include groupId")
113
+ if conversation_id.endswith("@thread.tacv2"):
114
+ raise ValueError("A channel message link must include groupId")
115
+ return TeamsTarget(
116
+ "chat", chat_id=conversation_id, tenant_id=tenant_id,
117
+ start_message_id=start_message_id,
118
+ )
119
+
120
+ raise ValueError("Unsupported Teams link; copy a chat, channel, or message link from Teams")
@@ -0,0 +1,114 @@
1
+ """Public Teams-to-video API."""
2
+
3
+ from contextlib import contextmanager
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from objectionengine2.beans.comment import Comment
8
+ from objectionengine2.renderer import render_comment_list
9
+
10
+ from .auth import BROWSER_REDIRECT_URI, OFFICE_CLIENT_ID, acquire_access_token
11
+ from .convert import messages_to_comments
12
+ from .graph import GraphClient
13
+ from .links import TeamsTarget, parse_teams_link
14
+
15
+
16
+ @contextmanager
17
+ def _default_sentiment():
18
+ """Select Hugging Face for this render unless the caller chose a core mode."""
19
+ if "oe_sentiment_model" in os.environ:
20
+ yield
21
+ return
22
+ os.environ["oe_sentiment_model"] = "hf"
23
+ try:
24
+ yield
25
+ finally:
26
+ os.environ.pop("oe_sentiment_model", None)
27
+
28
+
29
+ def _end_target(start: TeamsTarget, end_link: str | None) -> TeamsTarget | None:
30
+ if end_link is None:
31
+ return None
32
+ end = parse_teams_link(end_link)
33
+ if start.start_message_id is None or end.start_message_id is None:
34
+ raise ValueError("Both start and end links must point to messages")
35
+ if start.kind != end.kind:
36
+ raise ValueError("Start and end links must point to the same conversation")
37
+ if start.kind == "chat" and start.chat_id != end.chat_id:
38
+ raise ValueError("Start and end links must point to the same chat")
39
+ if start.kind == "channel":
40
+ if (start.team_id, start.channel_id) != (end.team_id, end.channel_id):
41
+ raise ValueError("Start and end links must point to the same channel")
42
+ if start.parent_message_id != end.parent_message_id:
43
+ raise ValueError("Start and end links must point to the same channel thread")
44
+ if start.tenant_id and end.tenant_id and start.tenant_id != end.tenant_id:
45
+ raise ValueError("Start and end links must point to the same tenant")
46
+ return end
47
+
48
+
49
+ def fetch_comments(
50
+ link: str,
51
+ *,
52
+ end_link: str | None = None,
53
+ max_messages: int | None = None,
54
+ client_id: str = OFFICE_CLIENT_ID,
55
+ auth_method: str = "broker",
56
+ redirect_uri: str = BROWSER_REDIRECT_URI,
57
+ ) -> list[Comment]:
58
+ """Read a Teams conversation from the linked message through an optional end."""
59
+ target = parse_teams_link(link)
60
+ end = _end_target(target, end_link)
61
+ tenant_id = target.tenant_id or (end.tenant_id if end else None)
62
+ token = acquire_access_token(
63
+ target.kind,
64
+ tenant_id=tenant_id,
65
+ client_id=client_id,
66
+ auth_method=auth_method,
67
+ redirect_uri=redirect_uri,
68
+ )
69
+ with GraphClient(token) as graph:
70
+ messages = (
71
+ graph.iter_messages(target, end_message_id=end.start_message_id)
72
+ if end else graph.iter_messages(target)
73
+ )
74
+ comments = messages_to_comments(
75
+ messages,
76
+ start_message_id=target.start_message_id,
77
+ end_message_id=end.start_message_id if end else None,
78
+ max_messages=max_messages,
79
+ )
80
+ return comments
81
+
82
+
83
+ def render_teams_link(
84
+ link: str,
85
+ output_filename: str | Path = "teams.mp4",
86
+ *,
87
+ end_link: str | None = None,
88
+ max_messages: int | None = None,
89
+ client_id: str = OFFICE_CLIENT_ID,
90
+ auth_method: str = "broker",
91
+ redirect_uri: str = BROWSER_REDIRECT_URI,
92
+ music_code: str = "pwr",
93
+ resolution_scale: float = 1,
94
+ ) -> Path:
95
+ """Render a Teams conversation, starting at the linked message, to MP4."""
96
+ comments = fetch_comments(
97
+ link,
98
+ end_link=end_link,
99
+ max_messages=max_messages,
100
+ client_id=client_id,
101
+ auth_method=auth_method,
102
+ redirect_uri=redirect_uri,
103
+ )
104
+ if not comments:
105
+ raise ValueError("This Teams conversation has no renderable messages")
106
+ output = Path(output_filename)
107
+ with _default_sentiment():
108
+ render_comment_list(
109
+ comments,
110
+ output_filename=str(output),
111
+ music_code=music_code,
112
+ resolution_scale=resolution_scale,
113
+ )
114
+ return output