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.

Files changed (57) hide show
  1. mgraphctl/__init__.py +3 -0
  2. mgraphctl/__main__.py +3 -0
  3. mgraphctl/auth.py +395 -0
  4. mgraphctl/cli.py +259 -0
  5. mgraphctl/commands/__init__.py +71 -0
  6. mgraphctl/commands/api.py +164 -0
  7. mgraphctl/commands/calendar.py +491 -0
  8. mgraphctl/commands/chats.py +329 -0
  9. mgraphctl/commands/config_cmd.py +140 -0
  10. mgraphctl/commands/groups.py +65 -0
  11. mgraphctl/commands/mail.py +705 -0
  12. mgraphctl/commands/mailbox.py +176 -0
  13. mgraphctl/commands/meetings.py +294 -0
  14. mgraphctl/commands/onedrive.py +351 -0
  15. mgraphctl/commands/onenote.py +196 -0
  16. mgraphctl/commands/org.py +102 -0
  17. mgraphctl/commands/people.py +201 -0
  18. mgraphctl/commands/planner.py +268 -0
  19. mgraphctl/commands/presence.py +102 -0
  20. mgraphctl/commands/search.py +75 -0
  21. mgraphctl/commands/sharepoint.py +304 -0
  22. mgraphctl/commands/teams.py +235 -0
  23. mgraphctl/commands/todo.py +253 -0
  24. mgraphctl/commands/top.py +284 -0
  25. mgraphctl/config.py +385 -0
  26. mgraphctl/errors.py +276 -0
  27. mgraphctl/fixtures.py +149 -0
  28. mgraphctl/graph/__init__.py +1 -0
  29. mgraphctl/graph/calendar.py +313 -0
  30. mgraphctl/graph/chats.py +447 -0
  31. mgraphctl/graph/files.py +269 -0
  32. mgraphctl/graph/groups.py +55 -0
  33. mgraphctl/graph/mail.py +663 -0
  34. mgraphctl/graph/mailbox.py +89 -0
  35. mgraphctl/graph/meetings.py +269 -0
  36. mgraphctl/graph/onedrive.py +35 -0
  37. mgraphctl/graph/onenote.py +138 -0
  38. mgraphctl/graph/org.py +86 -0
  39. mgraphctl/graph/people.py +83 -0
  40. mgraphctl/graph/planner.py +415 -0
  41. mgraphctl/graph/presence.py +89 -0
  42. mgraphctl/graph/search.py +178 -0
  43. mgraphctl/graph/sharepoint.py +294 -0
  44. mgraphctl/graph/teams.py +240 -0
  45. mgraphctl/graph/todo.py +199 -0
  46. mgraphctl/graph/users.py +85 -0
  47. mgraphctl/html.py +127 -0
  48. mgraphctl/http.py +843 -0
  49. mgraphctl/odata.py +72 -0
  50. mgraphctl/render.py +627 -0
  51. mgraphctl/resolve.py +111 -0
  52. mgraphctl/token_store.py +196 -0
  53. mgraphctl-0.1.0.dist-info/METADATA +226 -0
  54. mgraphctl-0.1.0.dist-info/RECORD +57 -0
  55. mgraphctl-0.1.0.dist-info/WHEEL +4 -0
  56. mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
  57. mgraphctl-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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)}"