mgraphctl 0.1.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.
Potentially problematic release.
This version of mgraphctl might be problematic. Click here for more details.
- mgraphctl/__init__.py +3 -0
- mgraphctl/__main__.py +3 -0
- mgraphctl/auth.py +395 -0
- mgraphctl/cli.py +259 -0
- mgraphctl/commands/__init__.py +71 -0
- mgraphctl/commands/api.py +164 -0
- mgraphctl/commands/calendar.py +491 -0
- mgraphctl/commands/chats.py +329 -0
- mgraphctl/commands/config_cmd.py +140 -0
- mgraphctl/commands/groups.py +65 -0
- mgraphctl/commands/mail.py +705 -0
- mgraphctl/commands/mailbox.py +176 -0
- mgraphctl/commands/meetings.py +294 -0
- mgraphctl/commands/onedrive.py +351 -0
- mgraphctl/commands/onenote.py +196 -0
- mgraphctl/commands/org.py +102 -0
- mgraphctl/commands/people.py +201 -0
- mgraphctl/commands/planner.py +268 -0
- mgraphctl/commands/presence.py +102 -0
- mgraphctl/commands/search.py +75 -0
- mgraphctl/commands/sharepoint.py +304 -0
- mgraphctl/commands/teams.py +235 -0
- mgraphctl/commands/todo.py +253 -0
- mgraphctl/commands/top.py +284 -0
- mgraphctl/config.py +385 -0
- mgraphctl/errors.py +276 -0
- mgraphctl/fixtures.py +149 -0
- mgraphctl/graph/__init__.py +1 -0
- mgraphctl/graph/calendar.py +313 -0
- mgraphctl/graph/chats.py +447 -0
- mgraphctl/graph/files.py +269 -0
- mgraphctl/graph/groups.py +55 -0
- mgraphctl/graph/mail.py +663 -0
- mgraphctl/graph/mailbox.py +89 -0
- mgraphctl/graph/meetings.py +269 -0
- mgraphctl/graph/onedrive.py +35 -0
- mgraphctl/graph/onenote.py +138 -0
- mgraphctl/graph/org.py +86 -0
- mgraphctl/graph/people.py +83 -0
- mgraphctl/graph/planner.py +415 -0
- mgraphctl/graph/presence.py +89 -0
- mgraphctl/graph/search.py +178 -0
- mgraphctl/graph/sharepoint.py +294 -0
- mgraphctl/graph/teams.py +240 -0
- mgraphctl/graph/todo.py +199 -0
- mgraphctl/graph/users.py +85 -0
- mgraphctl/html.py +127 -0
- mgraphctl/http.py +843 -0
- mgraphctl/odata.py +72 -0
- mgraphctl/render.py +627 -0
- mgraphctl/resolve.py +111 -0
- mgraphctl/token_store.py +196 -0
- mgraphctl-0.1.0.dist-info/METADATA +226 -0
- mgraphctl-0.1.0.dist-info/RECORD +57 -0
- mgraphctl-0.1.0.dist-info/WHEEL +4 -0
- mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
- mgraphctl-0.1.0.dist-info/licenses/LICENSE +21 -0
mgraphctl/graph/chats.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Graph operations for Teams chats and direct messages (spec §8.8).
|
|
2
|
+
|
|
3
|
+
Pure: client and parameters in, Graph dicts, a `PageResult` or a `Plan` out.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from urllib.parse import unquote
|
|
12
|
+
|
|
13
|
+
from mgraphctl import config, odata, resolve
|
|
14
|
+
from mgraphctl.errors import NotFoundError, UsageError
|
|
15
|
+
from mgraphctl.graph import teams, users
|
|
16
|
+
from mgraphctl.http import (
|
|
17
|
+
JSON_HEADERS,
|
|
18
|
+
GraphClient,
|
|
19
|
+
PageResult,
|
|
20
|
+
Plan,
|
|
21
|
+
PlannedRequest,
|
|
22
|
+
SearchResult,
|
|
23
|
+
filter_page,
|
|
24
|
+
with_routing,
|
|
25
|
+
)
|
|
26
|
+
from mgraphctl.render import dig, parse_graph_dt, to_iso_offset
|
|
27
|
+
|
|
28
|
+
CHAT_SELECT = "id,topic,chatType,lastUpdatedDateTime,viewpoint,webUrl"
|
|
29
|
+
CHAT_EXPAND = "members,lastMessagePreview"
|
|
30
|
+
CHAT_ORDER = "lastMessagePreview/createdDateTime desc"
|
|
31
|
+
CHAT_TYPES = ("oneOnOne", "group", "meeting")
|
|
32
|
+
DM_SELECT = "id,displayName"
|
|
33
|
+
MEMBER_TYPE = "#microsoft.graph.aadUserConversationMember"
|
|
34
|
+
# The plan a `--dry-run` prints cannot know which chat will be created (spec §6.4).
|
|
35
|
+
ME_PLACEHOLDER = "{me}"
|
|
36
|
+
CHAT_ID_PLACEHOLDER = "{chatId}"
|
|
37
|
+
GROUP_TITLE_NAMES = 3
|
|
38
|
+
|
|
39
|
+
PAGE_CHATS, CAP_CHATS = 50, 200
|
|
40
|
+
CAP_CHAT_MEMBERS = 999
|
|
41
|
+
PAGE_MESSAGES, CAP_MESSAGES = 50, 200
|
|
42
|
+
# The only chat-message property Graph filters on with both `gt` and `lt`, and only when
|
|
43
|
+
# `$orderby` names it too — otherwise the `$filter` is silently ignored (spec §8.8).
|
|
44
|
+
MESSAGE_WINDOW_FIELD = "lastModifiedDateTime"
|
|
45
|
+
CAP_ONE_ON_ONE = 500
|
|
46
|
+
SEARCH_SIZE, CAP_SEARCH = 25, 200
|
|
47
|
+
HOSTED_NAME_PREFIX = "teams_hosted_"
|
|
48
|
+
HOSTED_ID_IN_NAME = 8
|
|
49
|
+
# Every request carries the access token, so a URL taken from a message body is only ever
|
|
50
|
+
# followed when it is a Graph hosted-contents address on our own base (spec §5.1, §10 quirk 21).
|
|
51
|
+
_HOSTED_TAIL = r"/messages/[^/]+/hostedContents/([^/?]+)/\$value(?:\?.*)?$"
|
|
52
|
+
_CHAT_HOSTED_RE = re.compile(r"^/chats/[^/]+" + _HOSTED_TAIL)
|
|
53
|
+
_CHANNEL_HOSTED_RE = re.compile(r"^/teams/[^/]+/channels/[^/]+" + _HOSTED_TAIL)
|
|
54
|
+
# The magic bytes the Teams hosted contents actually carry (spec §8.8 `hosted-content`).
|
|
55
|
+
_MAGIC = (
|
|
56
|
+
(b"\x89PNG\r\n\x1a\n", "png"),
|
|
57
|
+
(b"\xff\xd8\xff", "jpg"),
|
|
58
|
+
(b"GIF87a", "gif"),
|
|
59
|
+
(b"GIF89a", "gif"),
|
|
60
|
+
(b"%PDF", "pdf"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalise_chat_type(value: str | None) -> str | None:
|
|
65
|
+
"""Fold `--type` onto the Graph spelling; anything else is a usage error."""
|
|
66
|
+
if value is None:
|
|
67
|
+
return None
|
|
68
|
+
for known in CHAT_TYPES:
|
|
69
|
+
if known.casefold() == value.casefold():
|
|
70
|
+
return known
|
|
71
|
+
raise UsageError("USAGE", f"unknown chat type {value!r}; use one of {', '.join(CHAT_TYPES)}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def last_message_at(chat: dict) -> datetime | None:
|
|
75
|
+
"""When the chat's last message preview was written, which is the order `/me/chats` is in."""
|
|
76
|
+
return parse_graph_dt(dig(chat, "lastMessagePreview.createdDateTime"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _quiet_since(since: datetime | None) -> Callable[[dict], bool] | None:
|
|
80
|
+
"""Page no further once a chat's last message predates `since`.
|
|
81
|
+
|
|
82
|
+
A chat with no readable preview timestamp is not the end of the feed — it says nothing
|
|
83
|
+
about where the boundary is — so it neither stops the fetch nor, in `_active_since`, gets
|
|
84
|
+
dropped from it. The two rules are deliberately the same: an undatable chat is kept.
|
|
85
|
+
"""
|
|
86
|
+
if since is None:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def past_it(chat: dict) -> bool:
|
|
90
|
+
created = last_message_at(chat)
|
|
91
|
+
return created is not None and created < since
|
|
92
|
+
|
|
93
|
+
return past_it
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _active_since(since: datetime) -> Callable[[dict], bool]:
|
|
97
|
+
"""Keep the chats whose last message is at or after `since`, and the undatable ones."""
|
|
98
|
+
|
|
99
|
+
def keep(chat: dict) -> bool:
|
|
100
|
+
created = last_message_at(chat)
|
|
101
|
+
return created is None or created >= since
|
|
102
|
+
|
|
103
|
+
return keep
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def list_chats(
|
|
107
|
+
client: GraphClient,
|
|
108
|
+
*,
|
|
109
|
+
chat_type: str | None,
|
|
110
|
+
since: datetime | None = None,
|
|
111
|
+
limit: int | None,
|
|
112
|
+
all_: bool,
|
|
113
|
+
) -> PageResult:
|
|
114
|
+
"""The signed-in user's chats, most recently active first.
|
|
115
|
+
|
|
116
|
+
`$orderby=lastMessagePreview/createdDateTime desc` already puts the feed in that order, so
|
|
117
|
+
`since` needs no server-side filter: paging stops at the first chat whose last message
|
|
118
|
+
predates it, and the chats past that boundary are dropped here.
|
|
119
|
+
"""
|
|
120
|
+
params: dict[str, object] = {
|
|
121
|
+
"$expand": CHAT_EXPAND,
|
|
122
|
+
"$orderby": CHAT_ORDER,
|
|
123
|
+
"$select": CHAT_SELECT,
|
|
124
|
+
}
|
|
125
|
+
if chat_type:
|
|
126
|
+
params["$filter"] = f"chatType eq '{odata.odata_str(chat_type)}'"
|
|
127
|
+
page = client.paginate(
|
|
128
|
+
"/me/chats",
|
|
129
|
+
params=params,
|
|
130
|
+
limit=limit,
|
|
131
|
+
all_=all_,
|
|
132
|
+
cap=CAP_CHATS,
|
|
133
|
+
page_size=PAGE_CHATS,
|
|
134
|
+
stop=_quiet_since(since),
|
|
135
|
+
)
|
|
136
|
+
if since is None:
|
|
137
|
+
return page
|
|
138
|
+
return filter_page(page, _active_since(since))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def chat_title(chat: dict, my_oid: str | None) -> str:
|
|
142
|
+
"""The other person for a 1:1 chat, else the topic, else the first few member names."""
|
|
143
|
+
others = [m for m in (chat.get("members") or []) if (m.get("userId") or "") != my_oid]
|
|
144
|
+
names = [m["displayName"] for m in others if m.get("displayName")]
|
|
145
|
+
if chat.get("chatType") == "oneOnOne":
|
|
146
|
+
return names[0] if names else (chat.get("topic") or "")
|
|
147
|
+
return chat.get("topic") or ", ".join(names[:GROUP_TITLE_NAMES])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def is_unread(chat: dict) -> bool:
|
|
151
|
+
"""Whether the last message preview is newer than what the viewpoint says was read."""
|
|
152
|
+
created = parse_graph_dt(dig(chat, "lastMessagePreview.createdDateTime"))
|
|
153
|
+
if created is None:
|
|
154
|
+
return False
|
|
155
|
+
read = parse_graph_dt(dig(chat, "viewpoint.lastMessageReadDateTime"))
|
|
156
|
+
return read is None or read < created
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_chat(client: GraphClient, chat_id: str) -> dict:
|
|
160
|
+
return client.get(odata.p("chats", chat_id), params={"$expand": "members"})
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def list_chat_members(client: GraphClient, chat_id: str) -> PageResult:
|
|
164
|
+
return client.paginate(
|
|
165
|
+
odata.p("chats", chat_id, "members"),
|
|
166
|
+
limit=None,
|
|
167
|
+
all_=True,
|
|
168
|
+
cap=CAP_CHAT_MEMBERS,
|
|
169
|
+
page_size=None,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def chat_messages(
|
|
174
|
+
client: GraphClient,
|
|
175
|
+
chat_id: str,
|
|
176
|
+
*,
|
|
177
|
+
after: datetime | None,
|
|
178
|
+
before: datetime | None,
|
|
179
|
+
limit: int | None,
|
|
180
|
+
all_: bool,
|
|
181
|
+
) -> PageResult:
|
|
182
|
+
"""Messages in one chat, newest first, optionally narrowed to a time window.
|
|
183
|
+
|
|
184
|
+
Graph filters chat messages on `lastModifiedDateTime` (`gt` and `lt`); `createdDateTime`
|
|
185
|
+
takes only `lt`, and either way the filter is *ignored* unless `$orderby` names the same
|
|
186
|
+
property. So a window switches both to `lastModifiedDateTime`; without one the order stays
|
|
187
|
+
`createdDateTime desc`, which is the order a thread reads in.
|
|
188
|
+
"""
|
|
189
|
+
params: dict[str, object] = {"$orderby": "createdDateTime desc"}
|
|
190
|
+
bounds: list[str] = []
|
|
191
|
+
if after is not None:
|
|
192
|
+
bounds.append(f"{MESSAGE_WINDOW_FIELD} gt {to_iso_offset(after)}")
|
|
193
|
+
if before is not None:
|
|
194
|
+
bounds.append(f"{MESSAGE_WINDOW_FIELD} lt {to_iso_offset(before)}")
|
|
195
|
+
if bounds:
|
|
196
|
+
params["$orderby"] = f"{MESSAGE_WINDOW_FIELD} desc"
|
|
197
|
+
params["$filter"] = " and ".join(bounds)
|
|
198
|
+
page = client.paginate(
|
|
199
|
+
odata.p("chats", chat_id, "messages"),
|
|
200
|
+
params=params,
|
|
201
|
+
limit=limit,
|
|
202
|
+
all_=all_,
|
|
203
|
+
cap=CAP_MESSAGES,
|
|
204
|
+
page_size=PAGE_MESSAGES,
|
|
205
|
+
)
|
|
206
|
+
return with_routing(page, chatId=chat_id)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def message_body(body: str, html: bool) -> dict:
|
|
210
|
+
"""Plain text goes out as `contentType: "text"`, so nothing needs escaping (§10 quirk 9)."""
|
|
211
|
+
return {"body": {"contentType": "html" if html else "text", "content": body}}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def plan_chat_send(client: GraphClient, chat_id: str, *, body: str, html: bool) -> Plan:
|
|
215
|
+
url = client.url(odata.p("chats", chat_id, "messages"))
|
|
216
|
+
return [PlannedRequest("POST", url, dict(JSON_HEADERS), message_body(body, html))]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def find_one_on_one(client: GraphClient, user_id: str) -> dict | None:
|
|
220
|
+
"""The existing 1:1 chat with `user_id`, found by paging every 1:1 chat (§10 quirk 10)."""
|
|
221
|
+
page = client.paginate(
|
|
222
|
+
"/me/chats",
|
|
223
|
+
params={"$filter": "chatType eq 'oneOnOne'", "$expand": "members"},
|
|
224
|
+
limit=None,
|
|
225
|
+
all_=True,
|
|
226
|
+
cap=CAP_ONE_ON_ONE,
|
|
227
|
+
page_size=PAGE_CHATS,
|
|
228
|
+
)
|
|
229
|
+
for chat in page.items:
|
|
230
|
+
if any((m.get("userId") or "") == user_id for m in chat.get("members") or []):
|
|
231
|
+
return chat
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def resolve_chat(client: GraphClient, value: str) -> str:
|
|
236
|
+
"""A `19:` id as-is, or the 1:1 chat with a UPN (§6.6)."""
|
|
237
|
+
bare, _ = resolve.split_id_prefix(value)
|
|
238
|
+
if resolve.looks_like_id(value, "chat"):
|
|
239
|
+
return bare
|
|
240
|
+
if "@" not in value:
|
|
241
|
+
raise UsageError("USAGE", f"{value!r} is not a chat id or a UPN; chat ids start with '19:'")
|
|
242
|
+
user = users.get_user(client, value, select=DM_SELECT)
|
|
243
|
+
chat = find_one_on_one(client, user["id"])
|
|
244
|
+
if chat is None:
|
|
245
|
+
raise NotFoundError("NOT_FOUND", f"no 1:1 chat with {value!r}")
|
|
246
|
+
return chat["id"]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _bind(client: GraphClient, user_ref: str) -> dict:
|
|
250
|
+
# The reference sits inside an OData string literal, so an apostrophe has to be doubled.
|
|
251
|
+
return {
|
|
252
|
+
"@odata.type": MEMBER_TYPE,
|
|
253
|
+
"roles": ["owner"],
|
|
254
|
+
"user@odata.bind": client.url(f"/users('{odata.odata_str(user_ref)}')"),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def create_chat_body(
|
|
259
|
+
client: GraphClient, *, member_ids: list[str], topic: str | None, my_oid: str
|
|
260
|
+
) -> dict:
|
|
261
|
+
"""A `POST /chats` body: one member and no topic makes a 1:1 chat, anything else a group."""
|
|
262
|
+
one_on_one = len(member_ids) == 1 and not topic
|
|
263
|
+
body: dict[str, object] = {"chatType": "oneOnOne" if one_on_one else "group"}
|
|
264
|
+
if topic and not one_on_one:
|
|
265
|
+
body["topic"] = topic
|
|
266
|
+
body["members"] = [_bind(client, my_oid), *(_bind(client, ref) for ref in member_ids)]
|
|
267
|
+
return body
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def plan_create_chat(
|
|
271
|
+
client: GraphClient, *, member_ids: list[str], topic: str | None, my_oid: str
|
|
272
|
+
) -> Plan:
|
|
273
|
+
if not member_ids:
|
|
274
|
+
raise UsageError("USAGE", "give at least one --members UPN")
|
|
275
|
+
body = create_chat_body(client, member_ids=member_ids, topic=topic, my_oid=my_oid)
|
|
276
|
+
return [PlannedRequest("POST", client.url("/chats"), dict(JSON_HEADERS), body)]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def plan_dm(
|
|
280
|
+
client: GraphClient, user: dict | str, existing_chat: dict | None, *, body: str, html: bool
|
|
281
|
+
) -> Plan:
|
|
282
|
+
"""Both possible steps of a DM. `user` may be the raw reference and `existing_chat` None."""
|
|
283
|
+
user_ref = user["id"] if isinstance(user, dict) else user
|
|
284
|
+
steps: Plan = []
|
|
285
|
+
if existing_chat is None:
|
|
286
|
+
steps.append(
|
|
287
|
+
PlannedRequest(
|
|
288
|
+
"POST",
|
|
289
|
+
client.url("/chats"),
|
|
290
|
+
dict(JSON_HEADERS),
|
|
291
|
+
create_chat_body(client, member_ids=[user_ref], topic=None, my_oid=ME_PLACEHOLDER),
|
|
292
|
+
note="only when no 1:1 chat exists",
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
send_url = client.url(f"/chats/{CHAT_ID_PLACEHOLDER}/messages")
|
|
296
|
+
else:
|
|
297
|
+
send_url = client.url(odata.p("chats", existing_chat["id"], "messages"))
|
|
298
|
+
steps.append(PlannedRequest("POST", send_url, dict(JSON_HEADERS), message_body(body, html)))
|
|
299
|
+
return steps
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def run_dm(
|
|
303
|
+
client: GraphClient,
|
|
304
|
+
user: dict,
|
|
305
|
+
*,
|
|
306
|
+
body: str,
|
|
307
|
+
html: bool,
|
|
308
|
+
my_oid: str,
|
|
309
|
+
can_create: Callable[[], None],
|
|
310
|
+
) -> dict:
|
|
311
|
+
"""Send to the existing 1:1 chat, creating one first when there is none."""
|
|
312
|
+
chat = find_one_on_one(client, user["id"])
|
|
313
|
+
if chat is None:
|
|
314
|
+
can_create() # Chat.Create is only needed on this branch (spec §4.4).
|
|
315
|
+
plan = plan_create_chat(client, member_ids=[user["id"]], topic=None, my_oid=my_oid)
|
|
316
|
+
chat = client.execute(plan[0]) or {}
|
|
317
|
+
sent = dict(client.execute(plan_chat_send(client, chat["id"], body=body, html=html)[0]) or {})
|
|
318
|
+
sent.setdefault("chatId", chat["id"])
|
|
319
|
+
return sent
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _within(hit: dict, after: datetime | None, before: datetime | None) -> bool:
|
|
323
|
+
if after is None and before is None:
|
|
324
|
+
return True
|
|
325
|
+
created = parse_graph_dt(dig(hit, "resource.createdDateTime"))
|
|
326
|
+
if created is None:
|
|
327
|
+
return False
|
|
328
|
+
if after is not None and created < after:
|
|
329
|
+
return False
|
|
330
|
+
return before is None or created <= before
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def search_chat_messages(
|
|
334
|
+
client: GraphClient,
|
|
335
|
+
q: str,
|
|
336
|
+
*,
|
|
337
|
+
after: datetime | None,
|
|
338
|
+
before: datetime | None,
|
|
339
|
+
limit: int,
|
|
340
|
+
) -> SearchResult:
|
|
341
|
+
"""Search chat and channel messages. The Search API has no date filter, so we apply one."""
|
|
342
|
+
found = client.search(["chatMessage"], q, size=SEARCH_SIZE, limit=limit)
|
|
343
|
+
hits = [h for h in found.hits if _within(h, after, before)]
|
|
344
|
+
return SearchResult(
|
|
345
|
+
hits=hits,
|
|
346
|
+
total=found.total,
|
|
347
|
+
more=found.more,
|
|
348
|
+
fetched=len(found.hits),
|
|
349
|
+
cap=limit,
|
|
350
|
+
query={"entityTypes": "chatMessage", "query": q},
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def shape_chat_hit(hit: dict) -> dict:
|
|
355
|
+
"""Flatten one `chatMessage` search hit, naming the chat or channel it came from (quirk 16).
|
|
356
|
+
|
|
357
|
+
`where` is the text column, a single readable string. The same routing also comes out as
|
|
358
|
+
`kind`/`chatId`/`teamId`/`channelId` so a JSON consumer can follow a hit into
|
|
359
|
+
`chats messages` or `teams channel messages` without parsing that string back apart —
|
|
360
|
+
which is the point of the windowed channel fetch.
|
|
361
|
+
"""
|
|
362
|
+
resource = hit.get("resource") or {}
|
|
363
|
+
chat_id = resource.get("chatId")
|
|
364
|
+
identity = resource.get("channelIdentity") or {}
|
|
365
|
+
team_id = identity.get("teamId")
|
|
366
|
+
channel_id = identity.get("channelId")
|
|
367
|
+
if chat_id:
|
|
368
|
+
kind, where = "chat", f"chat:{chat_id}"
|
|
369
|
+
elif team_id and channel_id:
|
|
370
|
+
kind, where = "channel", f"channel:{team_id}/{channel_id}"
|
|
371
|
+
else:
|
|
372
|
+
# A hit whose routing Graph did not supply: say so rather than emit "channel:None/None".
|
|
373
|
+
kind, where = "unknown", ""
|
|
374
|
+
return {
|
|
375
|
+
"id": resource.get("id") or hit.get("hitId"),
|
|
376
|
+
"created": resource.get("createdDateTime"),
|
|
377
|
+
"from": teams.message_sender(resource),
|
|
378
|
+
"kind": kind,
|
|
379
|
+
"chatId": chat_id if kind == "chat" else None,
|
|
380
|
+
"teamId": team_id if kind == "channel" else None,
|
|
381
|
+
"channelId": channel_id if kind == "channel" else None,
|
|
382
|
+
"where": where,
|
|
383
|
+
# The Search API returns a snippet, never the body (spec §8.8 `search`).
|
|
384
|
+
"summary": " ".join((hit.get("summary") or "").split()),
|
|
385
|
+
"webUrl": resource.get("webUrl"),
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def hosted_content_path(chat_id: str, msg_id: str, hc_id: str) -> str:
|
|
390
|
+
return odata.p("chats", chat_id, "messages", msg_id, "hostedContents", hc_id) + "/$value"
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _graph_path(url: str) -> str | None:
|
|
394
|
+
"""The path of a URL on our own Graph base, or None when it is anywhere else."""
|
|
395
|
+
for base in (config.GRAPH_V1, config.GRAPH_BETA):
|
|
396
|
+
if url.startswith(base + "/"):
|
|
397
|
+
return url[len(base) :]
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _verbatim_url(url: str) -> tuple[str, str, bool]:
|
|
402
|
+
"""Validate a hosted-contents URL before it is sent with the bearer token attached."""
|
|
403
|
+
path = _graph_path(url)
|
|
404
|
+
if path is None:
|
|
405
|
+
raise UsageError(
|
|
406
|
+
"USAGE",
|
|
407
|
+
"a hosted content URL must be on "
|
|
408
|
+
f"{config.GRAPH_V1} or {config.GRAPH_BETA}; refusing to send the access token "
|
|
409
|
+
"to another host",
|
|
410
|
+
)
|
|
411
|
+
chat = _CHAT_HOSTED_RE.match(path)
|
|
412
|
+
if chat:
|
|
413
|
+
return url, unquote(chat.group(1)), False
|
|
414
|
+
channel = _CHANNEL_HOSTED_RE.match(path)
|
|
415
|
+
if channel:
|
|
416
|
+
return url, unquote(channel.group(1)), True
|
|
417
|
+
raise UsageError(
|
|
418
|
+
"USAGE",
|
|
419
|
+
"that URL is not a /chats/… or /teams/… messages/hostedContents/<id>/$value address",
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def hosted_content_target(ref: str, msg_id: str | None, hc_id: str | None) -> tuple[str, str, bool]:
|
|
424
|
+
"""`(path or URL, hosted content id, is a channel message)` for either argument form."""
|
|
425
|
+
if ref.startswith(("https://", "http://")):
|
|
426
|
+
if msg_id is not None or hc_id is not None:
|
|
427
|
+
raise UsageError("USAGE", "give CHAT MSGID HCID, or one hostedContents URL")
|
|
428
|
+
return _verbatim_url(ref)
|
|
429
|
+
if msg_id is None or hc_id is None:
|
|
430
|
+
raise UsageError("USAGE", "give CHAT MSGID HCID, or one hostedContents URL")
|
|
431
|
+
return hosted_content_path(ref, msg_id, hc_id), hc_id, False
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def sniff_extension(data: bytes) -> str:
|
|
435
|
+
"""The file extension the bytes themselves imply; `bin` when nothing matches."""
|
|
436
|
+
for magic, extension in _MAGIC:
|
|
437
|
+
if data.startswith(magic):
|
|
438
|
+
return extension
|
|
439
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
440
|
+
return "webp"
|
|
441
|
+
if b"<svg" in data[:512].lower():
|
|
442
|
+
return "svg"
|
|
443
|
+
return "bin"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def hosted_content_name(hc_id: str, data: bytes) -> str:
|
|
447
|
+
return f"{HOSTED_NAME_PREFIX}{hc_id[:HOSTED_ID_IN_NAME]}.{sniff_extension(data)}"
|