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,304 @@
1
+ """SharePoint sites, drives, browsing, lists and items (spec §8.12)."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, Literal
5
+
6
+ import typer
7
+
8
+ from mgraphctl.cli import (
9
+ AllFlag,
10
+ DryRunFlag,
11
+ JsonFlag,
12
+ LimitOpt,
13
+ graph_command,
14
+ make_noun_app,
15
+ page_bounds,
16
+ )
17
+ from mgraphctl.graph import files, sharepoint
18
+ from mgraphctl.http import GraphClient
19
+ from mgraphctl.render import (
20
+ Column,
21
+ DryRunResult,
22
+ FileResult,
23
+ ListResult,
24
+ ObjectResult,
25
+ WriteResult,
26
+ fmt_size,
27
+ )
28
+
29
+ app = make_noun_app("SharePoint sites, drives and lists.")
30
+
31
+ SITE_FIELDS = [
32
+ ("Name", "displayName"),
33
+ ("Site name", "name"),
34
+ ("Web URL", "webUrl"),
35
+ ("Description", "description"),
36
+ ("Id", "id"),
37
+ ]
38
+ # Shared by `sites` and `lists`: both show id, a display name, and a webUrl.
39
+ SITE_LIST_COLUMNS = [Column("id", "id"), Column("name", "displayName"), Column("webUrl", "webUrl")]
40
+ LIST_COLUMNS = SITE_LIST_COLUMNS
41
+ DRIVE_COLUMNS = [
42
+ Column("id", "id"),
43
+ Column("name", "name"),
44
+ Column("type", "driveType"),
45
+ Column("webUrl", "webUrl"),
46
+ ]
47
+
48
+ DriveOpt = Annotated[str | None, typer.Option("--drive", help="Drive name or id.")]
49
+
50
+
51
+ def _open_drive(client: GraphClient, site: str, drive: str | None) -> tuple[str, str]:
52
+ """Resolve `SITE` (and `--drive`, if given) to `(site_id, base)`.
53
+
54
+ Uses `resolve_site_id`, not `resolve_site`, so an already-known site id (composite
55
+ `host,guid,guid`, or `id:`-forced) needs no Graph call — required for `upload --dry-run`
56
+ to make zero requests.
57
+ """
58
+ site_id = sharepoint.resolve_site_id(client, site)
59
+ drive_id = sharepoint.resolve_drive(client, site_id, drive) if drive else None
60
+ return site_id, sharepoint.drive_base(site_id, drive_id)
61
+
62
+
63
+ def _item_columns(items: list[dict], fields: list[str] | None) -> list[Column]:
64
+ """One column per requested `--fields` entry, else per field seen on the items (first 8)."""
65
+ if fields:
66
+ keys = fields[:8]
67
+ else:
68
+ keys = []
69
+ for item in items:
70
+ for key in item.get("fields") or {}:
71
+ if key not in keys:
72
+ keys.append(key)
73
+ if len(keys) >= 8:
74
+ break
75
+ keys = keys[:8]
76
+ return [
77
+ Column(key, (lambda k: lambda it: (it.get("fields") or {}).get(k))(key)) for key in keys
78
+ ]
79
+
80
+
81
+ @app.command("sites")
82
+ @graph_command(scopes=["Sites.Read.All"])
83
+ def sites(
84
+ client: GraphClient,
85
+ search: Annotated[
86
+ str | None, typer.Option("--search", help="Search text (default '*').")
87
+ ] = None,
88
+ limit: LimitOpt = None,
89
+ json_: JsonFlag = False,
90
+ ):
91
+ """List followed sites, or search all sites."""
92
+ page = sharepoint.list_sites(client, search=search, limit=limit if limit is not None else 20)
93
+ return ListResult(items=page.items, page=page, supports_all=False, columns=SITE_LIST_COLUMNS)
94
+
95
+
96
+ @app.command("site")
97
+ @graph_command(scopes=["Sites.Read.All"])
98
+ def site(
99
+ client: GraphClient, ref: Annotated[str, typer.Argument(metavar="REF")], json_: JsonFlag = False
100
+ ):
101
+ """Show one site, resolved by URL, `host:/a/b`, composite id, or name."""
102
+ return ObjectResult(obj=sharepoint.resolve_site(client, ref), fields=list(SITE_FIELDS))
103
+
104
+
105
+ @app.command("drives")
106
+ @graph_command(scopes=["Sites.Read.All"])
107
+ def drives(
108
+ client: GraphClient,
109
+ site: Annotated[str, typer.Argument(metavar="SITE")],
110
+ json_: JsonFlag = False,
111
+ ):
112
+ """List a site's document libraries (drives)."""
113
+ site_obj = sharepoint.resolve_site(client, site)
114
+ items = sharepoint.list_drives(client, site_obj["id"])
115
+ return ListResult(items=items, columns=DRIVE_COLUMNS)
116
+
117
+
118
+ @app.command("ls")
119
+ @graph_command(scopes=["Sites.Read.All"])
120
+ def ls(
121
+ client: GraphClient,
122
+ site: Annotated[str, typer.Argument(metavar="SITE")],
123
+ path: Annotated[str | None, typer.Argument(metavar="PATH")] = None,
124
+ drive: DriveOpt = None,
125
+ limit: LimitOpt = None,
126
+ all_: AllFlag = False,
127
+ json_: JsonFlag = False,
128
+ ):
129
+ """List the children of a folder in the site drive (default: root)."""
130
+ limit_, all_val = page_bounds(limit, all_, default=50)
131
+ _site_id, base = _open_drive(client, site, drive)
132
+ page = files.list_children(client, base, path, limit=limit_, all_=all_val)
133
+ return ListResult(
134
+ items=page.items,
135
+ page=page,
136
+ hit_cap=files.CAP_LS if all_val else None,
137
+ columns=files.item_columns(client.tz),
138
+ )
139
+
140
+
141
+ @app.command("search")
142
+ @graph_command(scopes=["Sites.Read.All"])
143
+ def search(
144
+ client: GraphClient,
145
+ q: Annotated[str, typer.Argument(metavar="Q")],
146
+ site: Annotated[
147
+ str | None, typer.Option("--site", help="Search within one site's drive.")
148
+ ] = None,
149
+ limit: LimitOpt = None,
150
+ all_: AllFlag = False,
151
+ json_: JsonFlag = False,
152
+ ):
153
+ """Search a site's drive, or every site (Search API) when `--site` is not given."""
154
+ limit_, all_val = page_bounds(limit, all_, default=25)
155
+ if site:
156
+ site_obj = sharepoint.resolve_site(client, site)
157
+ page = sharepoint.search_site_drive(client, site_obj["id"], q, limit=limit_, all_=all_val)
158
+ items, truncated = page.items, page.truncated
159
+ else:
160
+ result = sharepoint.search_all(client, q, limit=limit_, all_=all_val)
161
+ items = [hit.get("resource") or {} for hit in result.hits]
162
+ truncated = result.more
163
+ return ListResult(
164
+ items=items,
165
+ truncated=truncated,
166
+ hit_cap=sharepoint.SEARCH_CAP if all_val else None,
167
+ columns=files.item_columns(client.tz),
168
+ )
169
+
170
+
171
+ @app.command("download")
172
+ @graph_command(scopes=["Sites.Read.All"])
173
+ def download(
174
+ client: GraphClient,
175
+ site: Annotated[str, typer.Argument(metavar="SITE")],
176
+ item: Annotated[str, typer.Argument(metavar="ITEM_OR_PATH")],
177
+ output: Annotated[Path | None, typer.Option("--output", help="Destination file.")] = None,
178
+ drive: DriveOpt = None,
179
+ json_: JsonFlag = False,
180
+ ):
181
+ """Download a file from the site drive (fixes the Node quirk of using the wrong drive)."""
182
+ _site_id, base = _open_drive(client, site, drive)
183
+ graph_item, result = files.download_item(client, base, item, output)
184
+ return FileResult(
185
+ path=result.path,
186
+ bytes=result.bytes,
187
+ meta=dict(contentType=result.content_type, id=graph_item.get("id")),
188
+ message=f"Downloaded {graph_item.get('name')} ({fmt_size(result.bytes)}) to {result.path}",
189
+ )
190
+
191
+
192
+ @app.command("upload")
193
+ @graph_command(scopes=["Sites.ReadWrite.All"])
194
+ def upload(
195
+ client: GraphClient,
196
+ site: Annotated[str, typer.Argument(metavar="SITE")],
197
+ file: Annotated[Path, typer.Argument(metavar="FILE", exists=True, dir_okay=False)],
198
+ dest: Annotated[
199
+ str | None, typer.Option("--dest", help="Destination path (default: /basename).")
200
+ ] = None,
201
+ drive: DriveOpt = None,
202
+ conflict: Annotated[
203
+ Literal["rename", "replace", "fail"], typer.Option("--conflict", help="On a name clash.")
204
+ ] = "replace",
205
+ dry_run: DryRunFlag = False,
206
+ json_: JsonFlag = False,
207
+ ):
208
+ """Upload a file to the site drive."""
209
+ _site_id, base = _open_drive(client, site, drive)
210
+ plan = files.plan_upload(client, base, file, dest, conflict)
211
+ if dry_run:
212
+ return DryRunResult(plan)
213
+ obj = files.run_upload(client, base, file, dest, conflict)
214
+ return WriteResult(obj=obj, message=f"Uploaded {file.name} to {obj.get('webUrl') or base}.")
215
+
216
+
217
+ @app.command("url")
218
+ @graph_command(scopes=["Sites.Read.All"])
219
+ def url(
220
+ client: GraphClient,
221
+ url: Annotated[str, typer.Argument(metavar="URL")],
222
+ output: Annotated[Path | None, typer.Option("--output", help="Destination file.")] = None,
223
+ info: Annotated[
224
+ bool, typer.Option("--info", help="Resolve and print, without downloading.")
225
+ ] = False,
226
+ json_: JsonFlag = False,
227
+ ):
228
+ """Resolve a SharePoint web URL to a drive item, and download it."""
229
+ if info:
230
+ resolution = sharepoint.resolve_url(client, url)
231
+ obj = {
232
+ "resolution": {
233
+ "siteId": resolution.site_id,
234
+ "driveId": resolution.drive_id,
235
+ "path": resolution.path,
236
+ },
237
+ "item": resolution.item,
238
+ }
239
+ fields = [
240
+ ("siteId", "resolution.siteId"),
241
+ ("driveId", "resolution.driveId"),
242
+ ("path", "resolution.path"),
243
+ ("item", "item.id"),
244
+ ]
245
+ return ObjectResult(obj=obj, fields=fields)
246
+ _resolution, result = sharepoint.download_url(client, url, output)
247
+ return FileResult(
248
+ path=result.path,
249
+ bytes=result.bytes,
250
+ meta=dict(contentType=result.content_type),
251
+ message=f"Downloaded {result.path.name} ({fmt_size(result.bytes)}) to {result.path}",
252
+ )
253
+
254
+
255
+ @app.command("lists")
256
+ @graph_command(scopes=["Sites.Read.All"])
257
+ def lists_(
258
+ client: GraphClient,
259
+ site: Annotated[str, typer.Argument(metavar="SITE")],
260
+ json_: JsonFlag = False,
261
+ ):
262
+ """List a site's lists and document libraries (system lists hidden)."""
263
+ site_obj = sharepoint.resolve_site(client, site)
264
+ items = sharepoint.list_lists(client, site_obj["id"])
265
+ return ListResult(items=items, columns=LIST_COLUMNS)
266
+
267
+
268
+ @app.command("items")
269
+ @graph_command(scopes=["Sites.Read.All"])
270
+ def items_(
271
+ client: GraphClient,
272
+ site: Annotated[str, typer.Argument(metavar="SITE")],
273
+ list_: Annotated[str, typer.Argument(metavar="LIST")],
274
+ fields: Annotated[
275
+ str | None, typer.Option("--fields", help="Comma-separated field names.")
276
+ ] = None,
277
+ filter_: Annotated[
278
+ str | None, typer.Option("--filter", help="OData filter on fields/*.")
279
+ ] = None,
280
+ limit: LimitOpt = None,
281
+ all_: AllFlag = False,
282
+ json_: JsonFlag = False,
283
+ ):
284
+ """List a SharePoint list's items, with their column values."""
285
+ limit_, all_val = page_bounds(limit, all_, default=50)
286
+ site_obj = sharepoint.resolve_site(client, site)
287
+ site_id = site_obj["id"]
288
+ list_obj = sharepoint.resolve_list(client, site_id, list_)
289
+ field_list = [f.strip() for f in fields.split(",")] if fields else None
290
+ page = sharepoint.list_items(
291
+ client,
292
+ site_id,
293
+ list_obj["id"],
294
+ fields=field_list,
295
+ filter_=filter_,
296
+ limit=limit_,
297
+ all_=all_val,
298
+ )
299
+ return ListResult(
300
+ items=page.items,
301
+ page=page,
302
+ hit_cap=sharepoint.ITEMS_CAP if all_val else None,
303
+ columns=_item_columns(page.items, field_list),
304
+ )
@@ -0,0 +1,235 @@
1
+ """Teams and channel commands (spec §8.7)."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from mgraphctl.cli import (
8
+ AllFlag,
9
+ DryRunFlag,
10
+ JsonFlag,
11
+ LimitOpt,
12
+ graph_command,
13
+ make_noun_app,
14
+ page_bounds,
15
+ read_body,
16
+ )
17
+ from mgraphctl.errors import UsageError
18
+ from mgraphctl.graph import teams
19
+ from mgraphctl.http import GraphClient
20
+ from mgraphctl.render import (
21
+ Column,
22
+ DryRunResult,
23
+ ListResult,
24
+ ObjectResult,
25
+ Window,
26
+ WriteResult,
27
+ parse_dt,
28
+ truncate,
29
+ )
30
+
31
+ READ_TEAM = ["Team.ReadBasic.All|Group.Read.All"]
32
+ READ_CHANNEL = ["Channel.ReadBasic.All|Group.Read.All"]
33
+
34
+ TeamArg = Annotated[str, typer.Argument(metavar="TEAM", help="Team GUID or display name.")]
35
+ ChannelArg = Annotated[
36
+ str, typer.Argument(metavar="CHANNEL", help="Channel id (19:…) or display name.")
37
+ ]
38
+
39
+ TEAM_FIELDS = [
40
+ ("Name", "displayName"),
41
+ ("Description", "description"),
42
+ ("Visibility", "visibility"),
43
+ ("Web URL", "webUrl"),
44
+ ("Team id", "id"),
45
+ ]
46
+
47
+ CHANNEL_FIELDS = [
48
+ ("Name", "displayName"),
49
+ ("Description", "description"),
50
+ ("Membership", "membershipType"),
51
+ ("Web URL", "webUrl"),
52
+ ("Channel id", "id"),
53
+ ]
54
+
55
+ app = make_noun_app("Teams and channels.")
56
+ channel = make_noun_app("Channels within a team.")
57
+ app.add_typer(channel, name="channel")
58
+
59
+
60
+ @app.command("list")
61
+ @graph_command(scopes=READ_TEAM)
62
+ def list_(client: GraphClient, json_: JsonFlag = False):
63
+ """List the teams you belong to."""
64
+ page = teams.list_teams(client)
65
+ return ListResult(
66
+ items=page.items,
67
+ page=page,
68
+ supports_all=False,
69
+ empty_text="No teams.",
70
+ columns=[
71
+ Column("id", "id"),
72
+ Column("name", "displayName"),
73
+ Column("description", lambda t: truncate(t.get("description"))),
74
+ ],
75
+ )
76
+
77
+
78
+ @app.command("get")
79
+ @graph_command(scopes=READ_TEAM)
80
+ def get(client: GraphClient, team: TeamArg, json_: JsonFlag = False):
81
+ """Show one team."""
82
+ team_id = teams.resolve_team(client, team)["id"]
83
+ return ObjectResult(obj=teams.get_team(client, team_id), fields=list(TEAM_FIELDS))
84
+
85
+
86
+ @app.command("members")
87
+ @graph_command(scopes=["TeamMember.Read.All"])
88
+ def members(
89
+ client: GraphClient,
90
+ team: TeamArg,
91
+ limit: LimitOpt = None,
92
+ all_: AllFlag = False,
93
+ json_: JsonFlag = False,
94
+ ):
95
+ """List a team's members."""
96
+ limit, all_ = page_bounds(limit, all_, default=100)
97
+ team_id = teams.resolve_team(client, team)["id"]
98
+ page = teams.list_members(client, team_id, limit=limit, all_=all_)
99
+ return ListResult(
100
+ items=page.items,
101
+ page=page,
102
+ hit_cap=teams.CAP_MEMBERS if all_ else None,
103
+ empty_text="No members.",
104
+ columns=[
105
+ Column("id", "id"),
106
+ Column("name", "displayName"),
107
+ Column("email", "email"),
108
+ Column("roles", "roles"),
109
+ ],
110
+ )
111
+
112
+
113
+ @app.command("channels")
114
+ @graph_command(scopes=READ_CHANNEL)
115
+ def channels(client: GraphClient, team: TeamArg, json_: JsonFlag = False):
116
+ """List a team's channels."""
117
+ team_id = teams.resolve_team(client, team)["id"]
118
+ page = teams.list_channels(client, team_id)
119
+ return ListResult(
120
+ items=page.items,
121
+ page=page,
122
+ supports_all=False,
123
+ empty_text="No channels.",
124
+ columns=[
125
+ Column("id", "id"),
126
+ Column("name", "displayName"),
127
+ Column("membership", "membershipType"),
128
+ Column("description", lambda c: truncate(c.get("description"))),
129
+ ],
130
+ )
131
+
132
+
133
+ @channel.command("get")
134
+ @graph_command(scopes=READ_CHANNEL)
135
+ def channel_get(client: GraphClient, team: TeamArg, chan: ChannelArg, json_: JsonFlag = False):
136
+ """Show one channel."""
137
+ team_id = teams.resolve_team(client, team)["id"]
138
+ channel_id = teams.resolve_channel(client, team_id, chan)["id"]
139
+ return ObjectResult(
140
+ obj=teams.get_channel(client, team_id, channel_id), fields=list(CHANNEL_FIELDS)
141
+ )
142
+
143
+
144
+ @channel.command("messages")
145
+ @graph_command(scopes=["ChannelMessage.Read.All"])
146
+ def channel_messages(
147
+ client: GraphClient,
148
+ team: TeamArg,
149
+ chan: ChannelArg,
150
+ full: Annotated[
151
+ bool, typer.Option("--full", help="Print whole bodies, not 300 chars.")
152
+ ] = False,
153
+ with_replies: Annotated[
154
+ bool, typer.Option("--with-replies", help="Expand each message's replies.")
155
+ ] = False,
156
+ replies: Annotated[
157
+ str | None,
158
+ typer.Option("--replies", metavar="MSGID", help="List the replies to one message."),
159
+ ] = None,
160
+ after: Annotated[
161
+ str | None, typer.Option("--after", metavar="DT", help="Only messages touched after this.")
162
+ ] = None,
163
+ before: Annotated[
164
+ str | None,
165
+ typer.Option("--before", metavar="DT", help="Only messages touched before this."),
166
+ ] = None,
167
+ limit: LimitOpt = None,
168
+ all_: AllFlag = False,
169
+ json_: JsonFlag = False,
170
+ ):
171
+ """List channel messages, oldest first in text mode."""
172
+ if replies is not None and with_replies:
173
+ raise UsageError("USAGE", "--replies and --with-replies are mutually exclusive")
174
+ if replies is not None and (after is not None or before is not None):
175
+ raise UsageError("USAGE", "--after/--before do not apply to --replies")
176
+ tz = client.tz
177
+ after_dt = parse_dt(after, tz) if after else None
178
+ before_dt = parse_dt(before, tz, end_of_day=True) if before else None
179
+ if after_dt is not None and before_dt is not None and after_dt > before_dt:
180
+ raise UsageError("USAGE", "--after must not be later than --before")
181
+ limit, all_ = page_bounds(limit, all_, default=20)
182
+ team_id = teams.resolve_team(client, team)["id"]
183
+ channel_id = teams.resolve_channel(client, team_id, chan)["id"]
184
+ if replies is not None:
185
+ page = teams.channel_replies(client, team_id, channel_id, replies, limit=limit, all_=all_)
186
+ else:
187
+ page = teams.channel_messages(
188
+ client,
189
+ team_id,
190
+ channel_id,
191
+ with_replies=with_replies,
192
+ after=after_dt,
193
+ before=before_dt,
194
+ limit=limit,
195
+ all_=all_,
196
+ )
197
+ # JSON keeps the Graph order (newest first); text reads better oldest first (§10 quirk 17).
198
+ return ListResult(
199
+ items=page.items if json_ else list(reversed(page.items)),
200
+ page=page,
201
+ window=Window(after=after_dt, before=before_dt),
202
+ hit_cap=teams.CAP_MESSAGES if all_ else None,
203
+ empty_text="No messages.",
204
+ columns=teams.message_columns(client.tz, full),
205
+ )
206
+
207
+
208
+ @channel.command("send")
209
+ @graph_command(scopes=["ChannelMessage.Send"])
210
+ def channel_send(
211
+ client: GraphClient,
212
+ team: TeamArg,
213
+ chan: ChannelArg,
214
+ body: Annotated[str | None, typer.Option("--body", help="Message text.")] = None,
215
+ body_file: Annotated[
216
+ str | None, typer.Option("--body-file", help="File holding the text, or - for stdin.")
217
+ ] = None,
218
+ html: Annotated[bool, typer.Option("--html", help="Send the body as HTML.")] = False,
219
+ subject: Annotated[str | None, typer.Option("--subject", help="Message subject.")] = None,
220
+ reply_to: Annotated[
221
+ str | None, typer.Option("--reply-to", metavar="MSGID", help="Reply in this thread.")
222
+ ] = None,
223
+ dry_run: DryRunFlag = False,
224
+ json_: JsonFlag = False,
225
+ ):
226
+ """Post a message to a channel."""
227
+ text = read_body(body, body_file)
228
+ team_id = teams.resolve_team(client, team)["id"]
229
+ channel_id = teams.resolve_channel(client, team_id, chan)["id"]
230
+ plan = teams.plan_channel_send(
231
+ client, team_id, channel_id, body=text, html=html, subject=subject, reply_to=reply_to
232
+ )
233
+ if dry_run:
234
+ return DryRunResult(plan)
235
+ return WriteResult(obj=client.execute(plan[0]), message="Sent.")