dailybot-cli 3.7.4__tar.gz → 3.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/PKG-INFO +35 -6
  2. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/README.md +34 -5
  3. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/api_client.py +25 -1
  4. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/chat.py +298 -21
  5. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/public_api_helpers.py +45 -0
  6. dailybot_cli-3.8.0/dailybot_cli/commands/workflow.py +227 -0
  7. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli.egg-info/PKG-INFO +35 -6
  8. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/pyproject.toml +1 -1
  9. dailybot_cli-3.7.4/dailybot_cli/commands/workflow.py +0 -129
  10. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/LICENSE +0 -0
  11. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/__init__.py +0 -0
  12. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/__init__.py +0 -0
  13. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/agent.py +0 -0
  14. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/ask.py +0 -0
  15. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/auth.py +0 -0
  16. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/authoring_helpers.py +0 -0
  17. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/channels.py +0 -0
  18. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/checkin.py +0 -0
  19. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/config.py +0 -0
  20. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/conversation.py +0 -0
  21. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/env.py +0 -0
  22. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/form.py +0 -0
  23. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/hook.py +0 -0
  24. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/identity.py +0 -0
  25. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/interactive.py +0 -0
  26. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/interactive_chat.py +0 -0
  27. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/kudos.py +0 -0
  28. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/query_options.py +0 -0
  29. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/status.py +0 -0
  30. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/team.py +0 -0
  31. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/uninstall.py +0 -0
  32. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/update.py +0 -0
  33. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/upgrade.py +0 -0
  34. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/user.py +0 -0
  35. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/user_scoped_actions.py +0 -0
  36. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/commands/version.py +0 -0
  37. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/config.py +0 -0
  38. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/display.py +0 -0
  39. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/install_method.py +0 -0
  40. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/ledger.py +0 -0
  41. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/main.py +0 -0
  42. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/tui/__init__.py +0 -0
  43. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/tui/app.py +0 -0
  44. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/tui/commands.py +0 -0
  45. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/tui/conversation.py +0 -0
  46. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli/tui/intents.py +0 -0
  47. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli.egg-info/SOURCES.txt +0 -0
  48. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli.egg-info/dependency_links.txt +0 -0
  49. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli.egg-info/entry_points.txt +0 -0
  50. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli.egg-info/requires.txt +0 -0
  51. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/dailybot_cli.egg-info/top_level.txt +0 -0
  52. {dailybot_cli-3.7.4 → dailybot_cli-3.8.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dailybot-cli
3
- Version: 3.7.4
3
+ Version: 3.8.0
4
4
  Summary: Dailybot CLI - The command-line bridge between humans and agents. Progress reports, observability, and workflow automation for modern teams across Slack, Google Chat, Discord, MS Teams, and more.
5
5
  Author-email: Dailybot <support@dailybot.com>
6
6
  License: MIT
@@ -534,21 +534,33 @@ The `user list` table shows **Name** and **User UUID**. You can copy a UUID dire
534
534
 
535
535
  ## Workflows
536
536
 
537
- Browse the workflows configured in your organization. This surface is
538
- **read-only** — workflows are created and edited in the Dailybot web app. The
539
- feature is plan-gated; an org on a plan without workflows gets a clear upgrade
540
- message.
537
+ Browse the workflows configured in your organization, and **trigger** ones whose
538
+ trigger type is `api_trigger` ("When triggered via API or button" in the
539
+ automations builder). Creating and editing workflow definitions remains in the
540
+ Dailybot web app. The feature is plan-gated; an org on a plan without workflows
541
+ gets a clear upgrade message.
541
542
 
542
543
  ```bash
543
544
  # List workflows visible to you (paginated + searchable)
544
545
  dailybot workflow list
545
546
  dailybot workflow list --search "release" --all --json
547
+ # Find triggerable workflows
548
+ dailybot workflow list --filter api_trigger --all
546
549
 
547
550
  # Get a single workflow by UUID
548
551
  dailybot workflow get <workflow_uuid>
549
552
  dailybot workflow get <workflow_uuid> --json
553
+
554
+ # Queue an api_trigger run (async — returns 202; no run output)
555
+ dailybot workflow trigger <workflow-uuid>
556
+ dailybot workflow trigger <workflow-uuid> \
557
+ --payload '{"env":"production","requested_by":"release-bot"}'
550
558
  ```
551
559
 
560
+ The optional `--payload` must be a JSON object ≤ 8 KiB; workflow steps read it
561
+ as `{{trigger.body.*}}`. The same `api_trigger` workflows can also be fired from
562
+ a chat button via `callback_workflow` (see Chat below).
563
+
552
564
  `dailybot workflow list` accepts the shared list flags — see
553
565
  [Listing, search, and pagination](#listing-search-and-pagination).
554
566
 
@@ -908,8 +920,9 @@ Replies to agent emails land as messages retrievable via `dailybot agent message
908
920
 
909
921
  | Command | Description |
910
922
  |---------|-------------|
911
- | `dailybot workflow list` | List workflows visible to you (read-only; search + pagination) |
923
+ | `dailybot workflow list` | List workflows visible to you (search + pagination; `--filter api_trigger`) |
912
924
  | `dailybot workflow get <uuid>` | Show a single workflow by UUID |
925
+ | `dailybot workflow trigger <uuid>` | Queue an `api_trigger` workflow run (async 202; optional `--payload`) |
913
926
 
914
927
  ### Chat (send bot messages to Slack/Teams/Discord/Google Chat)
915
928
 
@@ -946,6 +959,22 @@ dailybot chat send -c C0123 -m "Build #421 ✅" \
946
959
  --bot-name "Release Bot" --bot-icon-emoji ":rocket:" \
947
960
  --link-button "Open report::https://app.company.com/report"
948
961
 
962
+ # Approval flow — callback_url buttons (+ optional bearer auth on the POST)
963
+ dailybot chat send -u <user-uuid> -m "Deploy to prod?" \
964
+ --approve-button "Yes=approve" --reject-button "No=deny" \
965
+ --callback-url https://hooks.example.com/req42 --callback-bearer "$TOKEN"
966
+
967
+ # Fire an api_trigger workflow from a button click
968
+ dailybot chat send -c C0123 -m "Ready to release?" \
969
+ --workflow-button "Run release=<workflow-uuid>"
970
+
971
+ # Full button contract (modals, response trees, callback_prompt, …) via JSON
972
+ dailybot chat send -u <user-uuid> -m "Need details" --buttons '[
973
+ {"label":"Open","button_type":"interactive","value":"open",
974
+ "callback_url":"https://hooks.example.com/x",
975
+ "modal_body":{"title":"Notes","blocks":[
976
+ {"type":"input","name":"notes","label":"Notes","multiline":true}]}}]'
977
+
949
978
  # Send with another user's identity, or as yourself (Slack only, admin-only)
950
979
  dailybot chat send -c C0123 -m "Posting for the team" --send-as-user <user-uuid>
951
980
  dailybot chat send -c C0123 -m "This one is from me" --send-as-me
@@ -495,21 +495,33 @@ The `user list` table shows **Name** and **User UUID**. You can copy a UUID dire
495
495
 
496
496
  ## Workflows
497
497
 
498
- Browse the workflows configured in your organization. This surface is
499
- **read-only** — workflows are created and edited in the Dailybot web app. The
500
- feature is plan-gated; an org on a plan without workflows gets a clear upgrade
501
- message.
498
+ Browse the workflows configured in your organization, and **trigger** ones whose
499
+ trigger type is `api_trigger` ("When triggered via API or button" in the
500
+ automations builder). Creating and editing workflow definitions remains in the
501
+ Dailybot web app. The feature is plan-gated; an org on a plan without workflows
502
+ gets a clear upgrade message.
502
503
 
503
504
  ```bash
504
505
  # List workflows visible to you (paginated + searchable)
505
506
  dailybot workflow list
506
507
  dailybot workflow list --search "release" --all --json
508
+ # Find triggerable workflows
509
+ dailybot workflow list --filter api_trigger --all
507
510
 
508
511
  # Get a single workflow by UUID
509
512
  dailybot workflow get <workflow_uuid>
510
513
  dailybot workflow get <workflow_uuid> --json
514
+
515
+ # Queue an api_trigger run (async — returns 202; no run output)
516
+ dailybot workflow trigger <workflow-uuid>
517
+ dailybot workflow trigger <workflow-uuid> \
518
+ --payload '{"env":"production","requested_by":"release-bot"}'
511
519
  ```
512
520
 
521
+ The optional `--payload` must be a JSON object ≤ 8 KiB; workflow steps read it
522
+ as `{{trigger.body.*}}`. The same `api_trigger` workflows can also be fired from
523
+ a chat button via `callback_workflow` (see Chat below).
524
+
513
525
  `dailybot workflow list` accepts the shared list flags — see
514
526
  [Listing, search, and pagination](#listing-search-and-pagination).
515
527
 
@@ -869,8 +881,9 @@ Replies to agent emails land as messages retrievable via `dailybot agent message
869
881
 
870
882
  | Command | Description |
871
883
  |---------|-------------|
872
- | `dailybot workflow list` | List workflows visible to you (read-only; search + pagination) |
884
+ | `dailybot workflow list` | List workflows visible to you (search + pagination; `--filter api_trigger`) |
873
885
  | `dailybot workflow get <uuid>` | Show a single workflow by UUID |
886
+ | `dailybot workflow trigger <uuid>` | Queue an `api_trigger` workflow run (async 202; optional `--payload`) |
874
887
 
875
888
  ### Chat (send bot messages to Slack/Teams/Discord/Google Chat)
876
889
 
@@ -907,6 +920,22 @@ dailybot chat send -c C0123 -m "Build #421 ✅" \
907
920
  --bot-name "Release Bot" --bot-icon-emoji ":rocket:" \
908
921
  --link-button "Open report::https://app.company.com/report"
909
922
 
923
+ # Approval flow — callback_url buttons (+ optional bearer auth on the POST)
924
+ dailybot chat send -u <user-uuid> -m "Deploy to prod?" \
925
+ --approve-button "Yes=approve" --reject-button "No=deny" \
926
+ --callback-url https://hooks.example.com/req42 --callback-bearer "$TOKEN"
927
+
928
+ # Fire an api_trigger workflow from a button click
929
+ dailybot chat send -c C0123 -m "Ready to release?" \
930
+ --workflow-button "Run release=<workflow-uuid>"
931
+
932
+ # Full button contract (modals, response trees, callback_prompt, …) via JSON
933
+ dailybot chat send -u <user-uuid> -m "Need details" --buttons '[
934
+ {"label":"Open","button_type":"interactive","value":"open",
935
+ "callback_url":"https://hooks.example.com/x",
936
+ "modal_body":{"title":"Notes","blocks":[
937
+ {"type":"input","name":"notes","label":"Notes","multiline":true}]}}]'
938
+
910
939
  # Send with another user's identity, or as yourself (Slack only, admin-only)
911
940
  dailybot chat send -c C0123 -m "Posting for the team" --send-as-user <user-uuid>
912
941
  dailybot chat send -c C0123 -m "This one is from me" --send-as-me
@@ -1443,7 +1443,7 @@ class DailyBotClient:
1443
1443
  limit: int | None = None,
1444
1444
  meta: dict[str, Any] | None = None,
1445
1445
  ) -> list[dict[str, Any]]:
1446
- """GET /v1/workflows/ — list workflows (read-only; plan-gated feature)."""
1446
+ """GET /v1/workflows/ — list workflows (plan-gated feature)."""
1447
1447
  params: dict[str, Any] = {}
1448
1448
  _merge_list_query(params, search=search, start_date=start_date, end_date=end_date)
1449
1449
  result: PaginatedResult = self._paginated_get(
@@ -1464,6 +1464,30 @@ class DailyBotClient:
1464
1464
  )
1465
1465
  return self._handle_response(response)
1466
1466
 
1467
+ def trigger_workflow(
1468
+ self,
1469
+ workflow_uuid: str,
1470
+ *,
1471
+ payload: dict[str, Any] | None = None,
1472
+ ) -> dict[str, Any]:
1473
+ """POST /v1/workflows/<uuid>/trigger/ — queue an ``api_trigger`` workflow.
1474
+
1475
+ Only workflows whose trigger type is ``api_trigger`` ("When triggered via
1476
+ API or button") can be fired this way. The run is asynchronous — success
1477
+ is ``202 {queued: true, workflow_uuid, detail}`` with no run output.
1478
+ Optional *payload* (a JSON object ≤ 8 KiB) is exposed to workflow steps
1479
+ as ``{{trigger.body.*}}`` variables.
1480
+ """
1481
+ body: dict[str, Any] = {}
1482
+ if payload is not None:
1483
+ body["payload"] = payload
1484
+ response: httpx.Response = self._request(
1485
+ "POST",
1486
+ f"{self.api_url}/v1/workflows/{workflow_uuid}/trigger/",
1487
+ json=body,
1488
+ )
1489
+ return self._handle_response(response)
1490
+
1467
1491
  def list_kudos_organization(
1468
1492
  self,
1469
1493
  *,
@@ -28,8 +28,11 @@ import click
28
28
  from dailybot_cli.api_client import APIError, DailyBotClient
29
29
  from dailybot_cli.commands.agent import _merge_repo_metadata, _resolve_agent_context
30
30
  from dailybot_cli.commands.public_api_helpers import (
31
+ EXIT_NOT_AUTHENTICATED,
32
+ EXIT_PERMISSION_DENIED,
31
33
  UUID_PATTERN,
32
34
  emit_json,
35
+ exit_for_api_error,
33
36
  get_current_user_uuid,
34
37
  )
35
38
  from dailybot_cli.display import (
@@ -46,8 +49,32 @@ CHANNEL_TYPES: tuple[str, ...] = (
46
49
  "direct_message",
47
50
  )
48
51
  BUTTON_SEPARATOR: str = "::"
52
+ ERGONOMIC_BUTTON_SEPARATOR: str = "="
49
53
  MAX_BOT_USERNAME_CHARS: int = 80
50
54
  MAX_THREAD_RESPONSES: int = 10
55
+ MAX_BUTTONS_PER_MESSAGE: int = 25
56
+ BUTTON_CALLBACK_KEYS: tuple[str, ...] = (
57
+ "callback_url",
58
+ "callback_form",
59
+ "callback_command",
60
+ "callback_prompt",
61
+ "callback_workflow",
62
+ )
63
+ BUTTON_ERROR_CODES: frozenset[str] = frozenset(
64
+ {
65
+ "button_link_and_callback_conflict",
66
+ "button_callback_conflict",
67
+ "button_callback_url_invalid",
68
+ "button_modal_body_invalid",
69
+ "button_callback_form_not_found",
70
+ "button_callback_command_invalid",
71
+ "button_callback_prompt_invalid",
72
+ "button_callback_workflow_not_found",
73
+ "button_response_invalid",
74
+ "button_callback_auth_invalid",
75
+ "buttons_count_out_of_range",
76
+ }
77
+ )
51
78
 
52
79
 
53
80
  class ChatPayloadError(ValueError):
@@ -68,6 +95,48 @@ def _parse_button(raw: str, *, kind: str) -> tuple[str, str]:
68
95
  return label, value
69
96
 
70
97
 
98
+ def _parse_ergonomic_button(raw: str, *, flag: str) -> tuple[str, str]:
99
+ """Split a ``"Label=value"`` ergonomic button spec into ``(label, value)``."""
100
+ label, sep, value = raw.partition(ERGONOMIC_BUTTON_SEPARATOR)
101
+ label = label.strip()
102
+ value = value.strip()
103
+ if not sep or not label or not value:
104
+ raise ChatPayloadError(
105
+ f"Invalid {flag} '{raw}'. Expected 'Label{ERGONOMIC_BUTTON_SEPARATOR}value' "
106
+ f"(e.g. 'Yes{ERGONOMIC_BUTTON_SEPARATOR}approve')."
107
+ )
108
+ return label, value
109
+
110
+
111
+ def validate_buttons(buttons: list[Any], *, flag_hint: str = "--buttons") -> None:
112
+ """Light client-side checks; never strips unknown keys (API owns the contract).
113
+
114
+ Enforces: ≤25 buttons, each entry is an object with a non-empty ``label``,
115
+ and at most one of the five callback fields per button. Richer rules
116
+ (modal size, URL shape, recursive ``response`` trees) stay server-side.
117
+ """
118
+ if len(buttons) > MAX_BUTTONS_PER_MESSAGE:
119
+ raise ChatPayloadError(
120
+ f"At most {MAX_BUTTONS_PER_MESSAGE} buttons are allowed per message ({flag_hint})."
121
+ )
122
+ for index, button in enumerate(buttons, start=1):
123
+ if not isinstance(button, dict):
124
+ raise ChatPayloadError(f"Button #{index} must be a JSON object ({flag_hint}).")
125
+ label_raw: Any = button.get("label")
126
+ label: str = str(label_raw).strip() if label_raw is not None else ""
127
+ if not label:
128
+ raise ChatPayloadError(f"Button #{index} is missing a required 'label' ({flag_hint}).")
129
+ present: list[str] = [
130
+ key for key in BUTTON_CALLBACK_KEYS if button.get(key) not in (None, "")
131
+ ]
132
+ if len(present) > 1:
133
+ raise ChatPayloadError(
134
+ f"Button '{label}' sets more than one callback ({', '.join(present)}). "
135
+ "At most one of callback_url, callback_form, callback_command, "
136
+ "callback_prompt, or callback_workflow is allowed."
137
+ )
138
+
139
+
71
140
  def build_chat_payload(
72
141
  *,
73
142
  text: str | None = None,
@@ -77,6 +146,7 @@ def build_chat_payload(
77
146
  image_url: str | None = None,
78
147
  link_buttons: list[tuple[str, str]] | None = None,
79
148
  action_buttons: list[tuple[str, str]] | None = None,
149
+ extra_buttons: list[dict[str, Any]] | None = None,
80
150
  thread: str | None = None,
81
151
  channel_type: str | None = None,
82
152
  bot_name: str | None = None,
@@ -95,6 +165,9 @@ def build_chat_payload(
95
165
  :class:`ChatPayloadError` on invalid combinations the API would reject —
96
166
  surfacing a friendly message before the network call.
97
167
 
168
+ *extra_buttons* is a list of raw button objects (from ``--buttons`` JSON or
169
+ ergonomic approval/workflow flags). Keys are forwarded untouched — the CLI
170
+ stays forward-compatible with every current and future button field.
98
171
  *thread_responses* posts follow-up messages inside the parent's thread in
99
172
  the same call (each reply inherits the parent's recipients). The API mints
100
173
  one id per reply in the response, so each is independently editable.
@@ -136,7 +209,10 @@ def build_chat_payload(
136
209
  buttons.append({"label": label, "button_type": "link", "url": url})
137
210
  for label, value in action_buttons or []:
138
211
  buttons.append({"label": label, "button_type": "interactive", "value": value})
212
+ if extra_buttons:
213
+ buttons.extend(extra_buttons)
139
214
  if buttons:
215
+ validate_buttons(buttons)
140
216
  payload["buttons"] = buttons
141
217
 
142
218
  if users:
@@ -212,7 +288,9 @@ def _send(
212
288
  client: DailyBotClient, payload: dict[str, Any], *, updated: bool, json_mode: bool
213
289
  ) -> None:
214
290
  """Run a single send/update call and render the result (used by `update`)."""
215
- result: dict[str, Any] = _execute_send(client, payload, status="Sending message...")
291
+ result: dict[str, Any] = _execute_send(
292
+ client, payload, status="Sending message...", json_mode=json_mode
293
+ )
216
294
  if json_mode:
217
295
  emit_json(result)
218
296
  return
@@ -220,9 +298,13 @@ def _send(
220
298
 
221
299
 
222
300
  def _execute_send(
223
- client: DailyBotClient, payload: dict[str, Any], *, status: str
301
+ client: DailyBotClient,
302
+ payload: dict[str, Any],
303
+ *,
304
+ status: str,
305
+ json_mode: bool = False,
224
306
  ) -> dict[str, Any]:
225
- """Call the API with friendly error translation; return the result or exit 1."""
307
+ """Call the API with friendly error translation; return the result or exit."""
226
308
  if payload.get("platform_settings", {}).get("is_ephemeral") and not payload.get("target_users"):
227
309
  # The API silently skips an ephemeral message with no resolvable user.
228
310
  print_warning(
@@ -233,31 +315,50 @@ def _execute_send(
233
315
  with console.status(status):
234
316
  return client.send_chat_message(payload)
235
317
  except APIError as e:
236
- if e.code == "org_admin_required":
237
- print_error(
318
+ overrides: dict[str, str] = {
319
+ "org_admin_required": (
238
320
  "Sending as another user (--send-as-user / --send-as-me) requires organization "
239
321
  "admin privileges. Run it with an admin account or an org admin's API key."
240
- )
241
- elif e.code == "cli_send_message_target_not_allowed":
242
- print_error(
322
+ ),
323
+ "cli_send_message_target_not_allowed": (
243
324
  f"{e.detail}\n Your role can only reach teammates, public channels, and teams "
244
325
  "you belong to. Use an allowed target, or an org API key for org-wide reach."
245
- )
246
- elif e.code == "invalid_thread_responses":
247
- print_error(
326
+ ),
327
+ "invalid_thread_responses": (
248
328
  f"{e.detail}\n Thread replies allow at most 10 items, one level deep, with no "
249
329
  "targeting of their own (they inherit the parent's recipients)."
250
- )
251
- elif e.status_code in (401, 403):
252
- print_error(
330
+ ),
331
+ }
332
+ # Button contract errors: surface the server detail verbatim (richest signal).
333
+ if e.code and e.code in BUTTON_ERROR_CODES and e.detail:
334
+ overrides[e.code] = e.detail
335
+ # Auth hint when the server didn't send a more specific code.
336
+ if e.status_code in (401, 403) and e.code not in overrides:
337
+ auth_hint: str = (
253
338
  f"{e.detail}\n Authenticate first: run 'dailybot login' (sends as you) or set an "
254
339
  "org API key with 'dailybot config key=<API_KEY>'."
255
340
  )
256
- elif e.status_code == 429:
257
- print_error("Rate limit exceeded for chat sends. Wait a bit and retry.")
258
- else:
259
- print_error(e.detail)
260
- raise SystemExit(1)
341
+ if e.code:
342
+ overrides[e.code] = auth_hint
343
+ else:
344
+ if json_mode:
345
+ emit_json({"error": auth_hint, "status": e.status_code, "detail": e.detail})
346
+ else:
347
+ print_error(auth_hint)
348
+ # Match exit_for_api_error's status→exit-code contract so headless
349
+ # consumers see a stable code whether or not the server sent `code`.
350
+ exit_code: int = (
351
+ EXIT_NOT_AUTHENTICATED if e.status_code == 401 else EXIT_PERMISSION_DENIED
352
+ )
353
+ raise SystemExit(exit_code)
354
+ if e.status_code == 429 and e.code is None:
355
+ rate_msg: str = "Rate limit exceeded for chat sends. Wait a bit and retry."
356
+ if json_mode:
357
+ emit_json({"error": rate_msg, "status": 429, "detail": e.detail})
358
+ else:
359
+ print_error(rate_msg)
360
+ raise SystemExit(1)
361
+ exit_for_api_error(e, json_mode, code_overrides=overrides)
261
362
 
262
363
 
263
364
  # --- chat group ---
@@ -313,6 +414,49 @@ def _target_options(fn: Any) -> Any:
313
414
  multiple=True,
314
415
  help="Interactive button 'Label::value' (repeatable).",
315
416
  ),
417
+ click.option(
418
+ "--buttons",
419
+ "buttons_json",
420
+ default=None,
421
+ help=(
422
+ "Raw buttons JSON array — full API contract (callbacks, modals, response, "
423
+ "callback_auth). Keys forwarded untouched; max 25."
424
+ ),
425
+ ),
426
+ click.option(
427
+ "--approve-button",
428
+ "approve_button_raw",
429
+ default=None,
430
+ help="Approval button 'Label=value' (needs --callback-url).",
431
+ ),
432
+ click.option(
433
+ "--reject-button",
434
+ "reject_button_raw",
435
+ default=None,
436
+ help="Reject button 'Label=value' (needs --callback-url).",
437
+ ),
438
+ click.option(
439
+ "--callback-url",
440
+ "callback_url",
441
+ default=None,
442
+ help="HTTPS callback URL for --approve-button / --reject-button.",
443
+ ),
444
+ click.option(
445
+ "--callback-bearer",
446
+ "callback_bearer",
447
+ default=None,
448
+ help=(
449
+ "Optional bearer token for callback_auth on approval buttons. Prefer "
450
+ 'passing via an env var (e.g. --callback-bearer "$TOKEN") — a raw '
451
+ "token on the command line lands in shell history and process lists."
452
+ ),
453
+ ),
454
+ click.option(
455
+ "--workflow-button",
456
+ "workflow_buttons_raw",
457
+ multiple=True,
458
+ help="Workflow button 'Label=<workflow-uuid>' (callback_workflow; repeatable).",
459
+ ),
316
460
  click.option("--thread", default=None, help="Thread id to reply inside (channels)."),
317
461
  click.option(
318
462
  "--channel-type",
@@ -353,6 +497,75 @@ def _target_options(fn: Any) -> Any:
353
497
  return fn
354
498
 
355
499
 
500
+ def _build_extra_buttons(
501
+ *,
502
+ buttons_json: str | None,
503
+ approve_button_raw: str | None,
504
+ reject_button_raw: str | None,
505
+ callback_url: str | None,
506
+ callback_bearer: str | None,
507
+ workflow_buttons_raw: tuple[str, ...],
508
+ ) -> list[dict[str, Any]]:
509
+ """Assemble raw button objects from --buttons JSON and ergonomic flags."""
510
+ extra: list[dict[str, Any]] = []
511
+
512
+ if buttons_json is not None:
513
+ try:
514
+ parsed: Any = json.loads(buttons_json)
515
+ except json.JSONDecodeError:
516
+ raise ChatPayloadError("Invalid JSON in --buttons.") from None
517
+ if not isinstance(parsed, list):
518
+ raise ChatPayloadError("--buttons must be a JSON array of button objects.")
519
+ for item in parsed:
520
+ if not isinstance(item, dict):
521
+ raise ChatPayloadError("--buttons entries must be JSON objects.")
522
+ extra.append(dict(item)) # shallow copy; keys untouched
523
+
524
+ if callback_bearer and not callback_url:
525
+ raise ChatPayloadError("--callback-bearer requires --callback-url.")
526
+ if (approve_button_raw or reject_button_raw) and not callback_url:
527
+ raise ChatPayloadError("--approve-button / --reject-button require --callback-url.")
528
+ if callback_url and not (approve_button_raw or reject_button_raw):
529
+ raise ChatPayloadError(
530
+ "--callback-url needs at least one of --approve-button / --reject-button "
531
+ "(or put callback_url inside --buttons JSON)."
532
+ )
533
+
534
+ callback_auth: dict[str, Any] | None = None
535
+ if callback_bearer:
536
+ callback_auth = {"type": "bearer", "token": callback_bearer}
537
+
538
+ for raw, flag in (
539
+ (approve_button_raw, "--approve-button"),
540
+ (reject_button_raw, "--reject-button"),
541
+ ):
542
+ if not raw:
543
+ continue
544
+ label, value = _parse_ergonomic_button(raw, flag=flag)
545
+ button: dict[str, Any] = {
546
+ "label": label,
547
+ "button_type": "interactive",
548
+ "value": value,
549
+ "callback_url": callback_url,
550
+ }
551
+ if callback_auth is not None:
552
+ button["callback_auth"] = dict(callback_auth)
553
+ extra.append(button)
554
+
555
+ for raw in workflow_buttons_raw:
556
+ label, workflow_uuid = _parse_ergonomic_button(raw, flag="--workflow-button")
557
+ extra.append(
558
+ {
559
+ "label": label,
560
+ "button_type": "interactive",
561
+ "value": workflow_uuid,
562
+ "callback_workflow": workflow_uuid,
563
+ }
564
+ )
565
+
566
+ return extra
567
+
568
+
356
569
  def _assemble_payload(
357
570
  *,
358
571
  payload_json: str | None,
@@ -391,6 +604,11 @@ def _assemble_payload(
391
604
  raw["bot_message_id"] = bot_message_id
392
605
  try:
393
606
  _validate_targets(raw)
607
+ raw_buttons: Any = raw.get("buttons")
608
+ if raw_buttons is not None:
609
+ if not isinstance(raw_buttons, list):
610
+ raise ChatPayloadError("--payload-json 'buttons' must be a JSON array.")
611
+ validate_buttons(raw_buttons, flag_hint="--payload-json buttons")
394
612
  except ChatPayloadError as exc:
395
613
  print_error(str(exc))
396
614
  raise SystemExit(1)
@@ -400,14 +618,29 @@ def _assemble_payload(
400
618
  action_buttons: list[tuple[str, str]] = []
401
619
  thread_messages: tuple[str, ...] = build_kwargs.pop("thread_messages_raw", ())
402
620
  thread_responses: list[dict[str, Any]] = [{"message": t} for t in thread_messages if t]
621
+ buttons_json: str | None = build_kwargs.pop("buttons_json", None)
622
+ approve_button_raw: str | None = build_kwargs.pop("approve_button_raw", None)
623
+ reject_button_raw: str | None = build_kwargs.pop("reject_button_raw", None)
624
+ callback_url: str | None = build_kwargs.pop("callback_url", None)
625
+ callback_bearer: str | None = build_kwargs.pop("callback_bearer", None)
626
+ workflow_buttons_raw: tuple[str, ...] = build_kwargs.pop("workflow_buttons_raw", ())
403
627
  try:
404
628
  for raw_btn in build_kwargs.pop("link_buttons_raw", ()):
405
629
  link_buttons.append(_parse_button(raw_btn, kind="link"))
406
630
  for raw_btn in build_kwargs.pop("action_buttons_raw", ()):
407
631
  action_buttons.append(_parse_button(raw_btn, kind="interactive"))
632
+ extra_buttons: list[dict[str, Any]] = _build_extra_buttons(
633
+ buttons_json=buttons_json,
634
+ approve_button_raw=approve_button_raw,
635
+ reject_button_raw=reject_button_raw,
636
+ callback_url=callback_url,
637
+ callback_bearer=callback_bearer,
638
+ workflow_buttons_raw=workflow_buttons_raw,
639
+ )
408
640
  return build_chat_payload(
409
641
  link_buttons=link_buttons,
410
642
  action_buttons=action_buttons,
643
+ extra_buttons=extra_buttons or None,
411
644
  metadata=metadata_dict,
412
645
  bot_message_id=bot_message_id,
413
646
  thread_responses=thread_responses or None,
@@ -452,6 +685,12 @@ def chat_send(
452
685
  image_url: str | None,
453
686
  link_buttons_raw: tuple[str, ...],
454
687
  action_buttons_raw: tuple[str, ...],
688
+ buttons_json: str | None,
689
+ approve_button_raw: str | None,
690
+ reject_button_raw: str | None,
691
+ callback_url: str | None,
692
+ callback_bearer: str | None,
693
+ workflow_buttons_raw: tuple[str, ...],
455
694
  thread: str | None,
456
695
  channel_type: str | None,
457
696
  bot_name: str | None,
@@ -478,6 +717,25 @@ def chat_send(
478
717
  dailybot chat send -u ana@co.com -m "Heads up" --ephemeral
479
718
  dailybot chat send --payload-json '{"target_channels":["C0"],"messages":[...]}' --json
480
719
 
720
+ \b
721
+ Approval flow (callback_url buttons + optional bearer auth):
722
+ dailybot chat send -u <uuid> -m "Deploy?" \\
723
+ --approve-button "Yes=approve" --reject-button "No=deny" \\
724
+ --callback-url https://hooks.example.com/req42 --callback-bearer "$TOKEN"
725
+
726
+ \b
727
+ Workflow button (fires an api_trigger workflow on click):
728
+ dailybot chat send -c C0123 -m "Ready?" \\
729
+ --workflow-button "Run release=<workflow-uuid>"
730
+
731
+ \b
732
+ Full button contract via --buttons JSON (modals, response trees, …):
733
+ dailybot chat send -u <uuid> -m "Details?" --buttons '[
734
+ {"label":"Open","button_type":"interactive","value":"open",
735
+ "callback_url":"https://hooks.example.com/x",
736
+ "modal_body":{"title":"Notes","blocks":[
737
+ {"type":"input","name":"notes","label":"Notes","multiline":true}]}}]'
738
+
481
739
  \b
482
740
  Report style — a short headline plus the detail inside its thread:
483
741
  dailybot chat send -c C0123 -m "🚀 Release v2.4 shipped" \\
@@ -511,6 +769,12 @@ def chat_send(
511
769
  image_url=image_url,
512
770
  link_buttons_raw=link_buttons_raw,
513
771
  action_buttons_raw=action_buttons_raw,
772
+ buttons_json=buttons_json,
773
+ approve_button_raw=approve_button_raw,
774
+ reject_button_raw=reject_button_raw,
775
+ callback_url=callback_url,
776
+ callback_bearer=callback_bearer,
777
+ workflow_buttons_raw=workflow_buttons_raw,
514
778
  thread_messages_raw=thread_messages_raw,
515
779
  thread=thread,
516
780
  channel_type=channel_type,
@@ -538,6 +802,12 @@ def chat_update(
538
802
  image_url: str | None,
539
803
  link_buttons_raw: tuple[str, ...],
540
804
  action_buttons_raw: tuple[str, ...],
805
+ buttons_json: str | None,
806
+ approve_button_raw: str | None,
807
+ reject_button_raw: str | None,
808
+ callback_url: str | None,
809
+ callback_bearer: str | None,
810
+ workflow_buttons_raw: tuple[str, ...],
541
811
  thread: str | None,
542
812
  channel_type: str | None,
543
813
  bot_name: str | None,
@@ -554,8 +824,9 @@ def chat_update(
554
824
  \b
555
825
  dailybot chat update <bot_message_id> -c C0123 -m "Status: DONE ✅"
556
826
 
557
- Note: the chat platform keeps the message's original bot name/avatar on an
558
- edit, so identity flags are ignored when updating.
827
+ Buttons round-trip on update the same way as send. Note: the chat platform
828
+ keeps the message's original bot name/avatar on an edit, so identity flags
829
+ are ignored when updating. Re-send within 72h with the same bot_message_id.
559
830
  """
560
831
  profile_flag: str | None = ctx.obj.get("profile")
561
832
  client, repo_default_metadata = _resolved_client(profile_flag)
@@ -572,6 +843,12 @@ def chat_update(
572
843
  image_url=image_url,
573
844
  link_buttons_raw=link_buttons_raw,
574
845
  action_buttons_raw=action_buttons_raw,
846
+ buttons_json=buttons_json,
847
+ approve_button_raw=approve_button_raw,
848
+ reject_button_raw=reject_button_raw,
849
+ callback_url=callback_url,
850
+ callback_bearer=callback_bearer,
851
+ workflow_buttons_raw=workflow_buttons_raw,
575
852
  thread=thread,
576
853
  channel_type=channel_type,
577
854
  bot_name=bot_name,