objectionengine2-teams 1.0.0__py3-none-any.whl
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.
- objectionengine2_teams/__init__.py +20 -0
- objectionengine2_teams/auth.py +159 -0
- objectionengine2_teams/cli.py +61 -0
- objectionengine2_teams/convert.py +119 -0
- objectionengine2_teams/graph.py +171 -0
- objectionengine2_teams/links.py +120 -0
- objectionengine2_teams/render.py +114 -0
- objectionengine2_teams-1.0.0.dist-info/METADATA +174 -0
- objectionengine2_teams-1.0.0.dist-info/RECORD +12 -0
- objectionengine2_teams-1.0.0.dist-info/WHEEL +4 -0
- objectionengine2_teams-1.0.0.dist-info/entry_points.txt +3 -0
- objectionengine2_teams-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
|
@@ -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,12 @@
|
|
|
1
|
+
objectionengine2_teams/__init__.py,sha256=xbRHbqbWuQPK7CNLsDPUeYEHuRwrbZPMUuBDVQylO1k,581
|
|
2
|
+
objectionengine2_teams/auth.py,sha256=f6hHzY65R-gecLtn5fry56HEXJ7_dYQZL7ktdkhAsnY,5736
|
|
3
|
+
objectionengine2_teams/cli.py,sha256=h2rFvnKVYavKNU70NM4PU6tCmHwtVuP2rzaLnXbpZPM,2270
|
|
4
|
+
objectionengine2_teams/convert.py,sha256=CXWrdfeezQxiYvBJlr1DJPRhbtpkU9mZCKEtjiq_Uu4,4850
|
|
5
|
+
objectionengine2_teams/graph.py,sha256=TZKcQeBh6ZLfrPlB5z-VUetrr67bjC2nuu4LXc9m5fg,7466
|
|
6
|
+
objectionengine2_teams/links.py,sha256=WHCdpjqVPbZastnkF8Ufrd8CAV7g6nG7PobOiC2nPvQ,4622
|
|
7
|
+
objectionengine2_teams/render.py,sha256=Up4CGFw95GFpeSe_fCAyaiLuF7oSuj9myeFNjcR-oZ0,3962
|
|
8
|
+
objectionengine2_teams-1.0.0.dist-info/licenses/LICENSE,sha256=wGhNQGmqMQ0_rdITtIw5RmIuamyl3dP-KkuFVDkIMR0,1092
|
|
9
|
+
objectionengine2_teams-1.0.0.dist-info/WHEEL,sha256=nQ0eT7mepDP-DkLACRDiCUi7cAIJyLg4OevuC4KpLLY,80
|
|
10
|
+
objectionengine2_teams-1.0.0.dist-info/entry_points.txt,sha256=M_2uhUs-lltWr4rM4BaYJl-y3tx2XX69fHV784_TAhk,76
|
|
11
|
+
objectionengine2_teams-1.0.0.dist-info/METADATA,sha256=NIKe5e7VCxuBJCxOJeAt46yuvziEevghWdeJ7nVVl8g,7473
|
|
12
|
+
objectionengine2_teams-1.0.0.dist-info/RECORD,,
|
|
@@ -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.
|