Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .agents/skills/dailybot/conversation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ Participants can be named three ways — the CLI resolves them for you:
> The bot is added automatically — you do **not** pass the bot as a participant.
> One human participant already makes a valid group with the bot.

### Participant limit

Slack group DMs (MPIMs) support **at most 8 total members**. The Dailybot bot
always occupies one slot, so you can pass **at most 7 user UUIDs / emails /
names**. The CLI rejects more than 7 before making the API call; the server also
validates and returns `400 conversation_too_many_participants` if the limit is
exceeded.

---

## Step 3 — Open the Conversation (idempotent)
Expand Down Expand Up @@ -170,6 +178,7 @@ Match on the machine-readable `code` (with `--json`), never the prose `detail`.
|------|--------|------------------------|
| 406 | `open_conversation_not_supported` | Org is not on Slack. Tell the developer group DMs are Slack-only; stop. |
| 403 | — | Caller is not an org admin. Explain the command needs admin rights. |
| 400 | `conversation_too_many_participants` | More than 7 users passed (8 total including the bot). Reduce the list. |
| 400 | `one_or_more_users_not_found` | A participant isn't an active org user. Re-check the names/UUIDs. |
| 400 | `no_valid_users` | The list contained invalid UUIDs. Fix the identifiers. |
| 400 | `params_validation_error` | `users_uuids` wasn't a list — a CLI usage error; re-run with `-u`. |
Expand Down
22 changes: 20 additions & 2 deletions dailybot_cli/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
FREE_PLAN_DAILY_LIMIT_CODE: str = "free_plan_daily_limit_exceeded"

MAX_FALLBACK_DETAIL_CHARS: int = 160 # cap for a non-JSON error body echoed to the user
MAX_SEARCH_QUERY_LENGTH: int = 256 # server rejects search queries longer than this


def _fallback_detail(response: httpx.Response) -> str:
Expand Down Expand Up @@ -67,8 +68,20 @@ def _merge_list_query(
start_date: str | None = None,
end_date: str | None = None,
) -> dict[str, Any]:
"""Merge the shared list query params (search / date range) into ``params``."""
"""Merge the shared list query params (search / date range) into ``params``.

Raises :class:`APIError` with ``search_query_too_long`` if *search* exceeds
:data:`MAX_SEARCH_QUERY_LENGTH` — matching the server-side validation so the
user gets instant feedback instead of a round-trip 400.
"""
if search is not None:
normalized: str = " ".join(search.split())
if len(normalized) > MAX_SEARCH_QUERY_LENGTH:
raise APIError(
400,
"Search query is too long.",
code="search_query_too_long",
)
params["search"] = search
if start_date is not None:
params["start_date"] = start_date
Expand Down Expand Up @@ -1571,7 +1584,12 @@ def get_agent_messages(
)
if response.status_code >= 400:
self._handle_response(response)
return response.json()
data: Any = response.json()
if isinstance(data, dict) and "results" in data:
return data["results"]
if isinstance(data, list):
return data
return []

def mark_agent_messages_read(
self,
Expand Down
2 changes: 1 addition & 1 deletion dailybot_cli/commands/checkin.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ def checkin_create(
"""Create a check-in with a schedule, participants, questions, and config.

\b
Creating check-ins is role-gated server-side (admins/managers). A check-in must
Creating check-ins is role-gated server-side. A check-in must
have at least one question at create time — seed them with --questions-file or
--interactive (add/edit/remove more later with `dailybot checkin questions`). The
scheduling/behavior flags (frequency, reminders, timezone mode, submission rules,
Expand Down
21 changes: 21 additions & 0 deletions dailybot_cli/commands/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
)

GROUP_CHAT_TYPE: str = "group_chat"
MAX_CONVERSATION_PARTICIPANTS: int = 7 # Slack MPIM limit: 8 total, minus the bot


def _split_identifiers(values: tuple[str, ...]) -> list[str]:
Expand Down Expand Up @@ -67,6 +68,11 @@ def _exit_for_conversation_error(exc: APIError, json_mode: bool) -> NoReturn:
message = "One or more users were not found or aren't active in your organization."
elif code == "no_valid_users":
message = "The participant list contains invalid user UUIDs."
elif code == "conversation_too_many_participants":
message = (
f"Too many participants. Slack group DMs support at most "
f"{MAX_CONVERSATION_PARTICIPANTS} users plus the Dailybot bot (8 total)."
)
elif code == "conversation_can_not_be_opened":
message = "Slack rejected the request. A participant may be deactivated on the Slack side."
elif exc.status_code == 403:
Expand Down Expand Up @@ -147,6 +153,14 @@ def conversation_open(
print_error("Provide at least one participant with --user (UUID, email, or name).")
raise SystemExit(1)

if len(identifiers) > MAX_CONVERSATION_PARTICIPANTS:
print_error(
f"Too many participants ({len(identifiers)}). "
f"Slack group DMs support at most {MAX_CONVERSATION_PARTICIPANTS} "
"users plus the Dailybot bot (8 total)."
)
raise SystemExit(1)

client: DailyBotClient = require_auth()
try:
participants: list[tuple[str, str]] = _resolve_participants(client, identifiers)
Expand All @@ -155,6 +169,13 @@ def conversation_open(
raise SystemExit(1)

uuids: list[str] = [uuid for uuid, _name in participants]
if len(uuids) > MAX_CONVERSATION_PARTICIPANTS:
print_error(
f"Too many participants ({len(uuids)}). "
f"Slack group DMs support at most {MAX_CONVERSATION_PARTICIPANTS} "
"users plus the Dailybot bot (8 total)."
)
raise SystemExit(1)
try:
with console.status("Opening group conversation..."):
result: dict[str, Any] = client.open_conversation(uuids)
Expand Down
56 changes: 53 additions & 3 deletions dailybot_cli/commands/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,47 @@ def form_update(
print_form_response_state(result, form_data)


def _resolve_workflow_state(client: DailyBotClient, form_uuid: str, user_input: str) -> str:
"""Resolve a user-provided state label or key to the canonical state key.

Fetches the form's workflow states and tries, in order:
1. Exact key match (e.g. ``"done"`` matches key ``"done"``).
2. Case-insensitive label match (e.g. ``"Done"`` matches label ``"Done"``
→ key ``"done"``).
3. Case-insensitive key match (e.g. ``"Done"`` matches key ``"done"``).
Falls back to the original input if no match, letting the API decide.
"""
try:
form_data: dict[str, Any] = client.get_form(form_uuid)
except APIError:
return user_input

workflow: dict[str, Any] | None = form_data.get("workflow")
if not workflow or not workflow.get("enabled"):
return user_input

states: list[dict[str, Any]] = workflow.get("states", [])
if not states:
return user_input

for state in states:
if state.get("key") == user_input:
return user_input

user_lower: str = user_input.lower()
for state in states:
label: str = state.get("label", "")
if label.lower() == user_lower:
return state["key"]

for state in states:
key: str = state.get("key", "")
if key.lower() == user_lower:
return key

return user_input


@form.command("transition")
@click.argument("form_uuid")
@click.argument("response_uuid")
Expand All @@ -527,6 +568,10 @@ def form_transition(
) -> None:
"""Advance a response to a new workflow state.

\b
Accepts either the state key (e.g. 'done') or its human label (e.g. 'Done')
— the CLI resolves labels to keys automatically (case-insensitive).

\b
The form's state_change_permission audience is the sole gate — there is no
response-author short-circuit. If you're not in the audience the API will
Expand All @@ -535,15 +580,20 @@ def form_transition(
\b
Examples:
dailybot form transition <form_uuid> <response_uuid> qa --note "QA assigned"
dailybot form transition <form_uuid> <response_uuid> Done
dailybot form transition <form_uuid> <response_uuid> released --json
"""
client = require_auth()

resolved_state: str = _resolve_workflow_state(client, form_uuid, to_state)

summary_lines: list[str] = [
f"Form UUID: {form_uuid}",
f"Response UUID: {response_uuid}",
f"To state: {to_state}",
f"To state: {resolved_state}",
]
if resolved_state != to_state:
summary_lines.append(f" (resolved from label '{to_state}')")
if note:
summary_lines.append(f"Note: {note}")
confirm_write(summary_lines, assume_yes)
Expand All @@ -553,7 +603,7 @@ def form_transition(
result: dict[str, Any] = client.transition_form_response(
form_uuid=form_uuid,
response_uuid=response_uuid,
to_state=to_state,
to_state=resolved_state,
note=note,
)
except APIError as exc:
Expand All @@ -563,7 +613,7 @@ def form_transition(
emit_json(result)
return

print_success(f"Response {response_uuid} transitioned to '{to_state}'.")
print_success(f"Response {response_uuid} transitioned to '{resolved_state}'.")
form_data: dict[str, Any] | None = _maybe_load_form(client, form_uuid)
print_form_response_state(result, form_data)

Expand Down
7 changes: 6 additions & 1 deletion dailybot_cli/commands/query_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ def build_query_params(
params: dict[str, Any] = {}

if search is not None:
params["search"] = search[:MAX_SEARCH_LEN]
normalized: str = " ".join(search.split())
if len(normalized) > MAX_SEARCH_LEN:
raise ValueError(
f"Search query is too long ({len(normalized)} chars, max {MAX_SEARCH_LEN})."
)
params["search"] = search

start: str | None = None
end: str | None = None
Expand Down
13 changes: 12 additions & 1 deletion dailybot_cli/commands/status.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Status command for Dailybot CLI."""

import json as json_mod
from typing import Any

import click
Expand Down Expand Up @@ -58,12 +59,19 @@ def _check_auth() -> None:

@click.command()
@click.option("--auth", is_flag=True, default=False, help="Check authentication status.")
def status(auth: bool) -> None:
@click.option(
"--json", "json_mode", is_flag=True, default=False, help="Emit machine-readable JSON to stdout."
)
def status(auth: bool, json_mode: bool) -> None:
"""Show pending check-ins for today.

\b
Use --auth to verify your credentials are valid:
dailybot status --auth

\b
Use --json for machine-readable output:
dailybot status --json
"""
if auth:
_check_auth()
Expand All @@ -77,6 +85,9 @@ def status(auth: bool) -> None:
try:
with console.status("Fetching pending check-ins..."):
data: dict[str, Any] = client.get_status()
if json_mode:
click.echo(json_mod.dumps(data))
return
checkins: list[dict[str, Any]] = data.get("pending_checkins", [])
print_pending_checkins(checkins)
except APIError as e:
Expand Down
5 changes: 4 additions & 1 deletion dailybot_cli/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ def print_webhook_result(data: dict[str, Any]) -> None:
console.print(Panel(table, title="[bold]Webhook Registered[/bold]", border_style="green"))


def print_agent_messages(messages: list[dict[str, Any]]) -> None:
def print_agent_messages(messages: list[Any]) -> None:
"""Display a list of agent messages."""
if not messages:
print_info("No messages found.")
Expand All @@ -339,6 +339,9 @@ def print_agent_messages(messages: list[dict[str, Any]]) -> None:
table.add_column("Delivered")
table.add_column("Created", style="dim")
for msg in messages:
if not isinstance(msg, dict):
table.add_row("text", "", str(msg), "", "")
continue
delivered: bool = msg.get("delivered", False)
delivered_text: str = "[green]yes[/green]" if delivered else "[yellow]no[/yellow]"
sender_type: str = msg.get("sender_type", "")
Expand Down
2 changes: 1 addition & 1 deletion docker/local/cli/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | d
# dev-user). `-f` makes an HTTP error a non-zero curl exit instead of piping an error
# page into bash; the `SHELL … pipefail` above makes a failing curl abort the build.
# install.sh itself runs `set -euo pipefail`, so failures inside it abort too.
RUN curl -fsSL https://cli.dailybot.com/install.sh | DAILYBOT_VERSION='>=3.2.1' bash
RUN curl -fsSL https://cli.dailybot.com/install.sh | DAILYBOT_VERSION='>=3.2.2' bash

# Install all Python dev/runtime deps from the repo's lock file.
# This single COPY/RUN pair is the only build cache layer pip touches — it
Expand Down
80 changes: 80 additions & 0 deletions tests/api_client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,54 @@ def test_submit_agent_report_defaults_omit_new_fields(self, client: DailyBotClie
assert "is_milestone" not in call_kwargs["json"]
assert "co_authors" not in call_kwargs["json"]

def test_get_agent_messages_paginated_envelope(self, client: DailyBotClient) -> None:
"""Regression: API returns {count, results} envelope — CORE-2260."""
mock_response: MagicMock = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"count": 1,
"next": None,
"previous": None,
"results": [
{"id": "msg-1", "content": "hello", "delivered": False},
],
}

with patch("httpx.get", return_value=mock_response):
result: list[dict[str, Any]] = client.get_agent_messages(agent_name="Bot")

assert len(result) == 1
assert result[0]["id"] == "msg-1"

def test_get_agent_messages_plain_list(self, client: DailyBotClient) -> None:
"""If the API ever returns a plain list, handle it gracefully."""
mock_response: MagicMock = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = [
{"id": "msg-1", "content": "hello"},
]

with patch("httpx.get", return_value=mock_response):
result: list[dict[str, Any]] = client.get_agent_messages(agent_name="Bot")

assert len(result) == 1
assert result[0]["id"] == "msg-1"

def test_get_agent_messages_empty_envelope(self, client: DailyBotClient) -> None:
mock_response: MagicMock = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"count": 0,
"next": None,
"previous": None,
"results": [],
}

with patch("httpx.get", return_value=mock_response):
result: list[dict[str, Any]] = client.get_agent_messages(agent_name="Bot")

assert result == []


class TestDailyBotClientChat:
def test_send_chat_message_passes_payload_through(self, client: DailyBotClient) -> None:
Expand Down Expand Up @@ -1347,6 +1395,38 @@ def test_archive_checkin_raises_api_error_with_code(self, client: DailyBotClient
assert exc_info.value.status_code == 403


class TestSearchQueryValidation:
"""Client-side search query length validation (CORE-2263)."""

def test_search_query_too_long_raises_api_error(self, client: DailyBotClient) -> None:
long_query: str = "a" * 257
with pytest.raises(APIError) as exc_info:
client.list_forms(search=long_query)
assert exc_info.value.status_code == 400
assert exc_info.value.code == "search_query_too_long"

def test_search_query_at_limit_passes(self, client: DailyBotClient) -> None:
query_at_limit: str = "a" * 256
mock_response: MagicMock = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
with patch("httpx.get", return_value=mock_response):
result: list[dict[str, Any]] = client.list_forms(search=query_at_limit)
assert result == []

def test_search_query_whitespace_normalized(self, client: DailyBotClient) -> None:
"""Whitespace-padded query exceeding 256 after normalization is rejected."""
padded_query: str = "a " * 200
with pytest.raises(APIError) as exc_info:
client.list_forms(search=padded_query)
assert exc_info.value.code == "search_query_too_long"


class TestPaginatedGet:
"""Unit tests for the shared _paginated_get helper (Task 1)."""

Expand Down
Loading