diff --git a/README.md b/README.md index d096f5a..ba20b2c 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ command line. - Upload videos from a directory using YAML sidecars for metadata and thumbnails. - Schedule, start, stop, and update YouTube livestream broadcasts, including RTMP ingest details. - Multi-channel profiles: manage several channels from one machine and switch per command. -- Comments moderation: list, approve, reject, and ban from the CLI. +- Comments moderation: list, reply, approve, reject, and ban from the CLI. - Channel analytics queries via the YouTube Analytics API. - Playlists: bulk-add by search and reorder by views with one command. diff --git a/docs/api-quota.md b/docs/api-quota.md index 9b9b909..34d69a1 100644 --- a/docs/api-quota.md +++ b/docs/api-quota.md @@ -28,6 +28,7 @@ not eat into the table below. | `search.list` | 100 units | | `videos.update` | 50 units | | `comments.setModerationStatus` | 50 units | +| `comments.insert` (reply) | 50 units | | `playlists.insert` / `update` / `delete` | 50 units | | `playlistItems.insert` / `update` / `delete` | 50 units | | `liveBroadcasts.insert` | 50 units | diff --git a/docs/comments.md b/docs/comments.md index 185d933..c4444ca 100644 --- a/docs/comments.md +++ b/docs/comments.md @@ -40,7 +40,20 @@ ytstudio comments reject --ban # also ban the author `reject` hides the comment from public view. Pass `--ban` to also ban the author from the channel. +## Reply + +```bash +ytstudio comments reply --text "Thanks for watching!" +``` + +`reply` posts a public reply to a comment and prints the new reply id. +Replies are flat on YouTube: `` must be a **top-level** comment +id (the `id` shown by `comments list`), not the id of another reply. Passing +a reply id (or an otherwise invalid id) returns a clear error. Like +`publish` and `reject`, `reply` executes immediately. + !!! note "Quota" - `comments.setModerationStatus` costs about 50 quota units per call. For - larger moderation runs, see [API quota](api-quota.md). + `comments.setModerationStatus` and `comments.insert` (reply) each cost + about 50 quota units per call. For larger moderation runs, see + [API quota](api-quota.md). diff --git a/src/ytstudio/commands/comments.py b/src/ytstudio/commands/comments.py index a29e7b5..8db0d63 100644 --- a/src/ytstudio/commands/comments.py +++ b/src/ytstudio/commands/comments.py @@ -174,3 +174,34 @@ def reject( """Reject comments (hide from public display)""" count = _set_moderation_status(comment_ids, "rejected", ban_author=ban) console.print(f"{count} comment(s) rejected") + + +@app.command() +def reply( + comment_id: str = typer.Argument( + help="Top-level comment ID to reply to (from 'comments list')" + ), + text: str = typer.Option(..., "--text", "-t", help="Reply text"), +): + """Reply to a top-level comment. + + Replies are flat on YouTube: COMMENT_ID must be a top-level comment id + (the id shown by 'comments list'), not the id of another reply. + """ + service = get_data_service() + try: + response = api( + service.comments().insert( + part="snippet", + body={"snippet": {"parentId": comment_id, "textOriginal": text}}, + ) + ) + except HttpError as e: + if e.resp.status in (400, 404): + console.print( + f"[red]Could not reply to '{comment_id}'.[/red] Pass a top-level " + "comment id from 'comments list' (not a reply id)." + ) + raise typer.Exit(1) from None + handle_api_error(e) + console.print(f"Reply posted: {response['id']}") diff --git a/tests/conftest.py b/tests/conftest.py index c43ef39..bea085e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -152,6 +152,13 @@ def create_mock_service(): } service.commentThreads.return_value.list.return_value = comments_list + comments_insert = MagicMock() + comments_insert.execute.return_value = { + "id": "UgwReply789", + "snippet": {"parentId": MOCK_COMMENT["id"], "textOriginal": "Thanks for watching!"}, + } + service.comments.return_value.insert.return_value = comments_insert + search_list = MagicMock() search_list.execute.return_value = { "items": [{"id": {"videoId": MOCK_VIDEO["id"]}}], diff --git a/tests/test_comments.py b/tests/test_comments.py index 059e793..4c7d1d7 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -1,5 +1,7 @@ from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock +from googleapiclient.errors import HttpError from typer.testing import CliRunner from ytstudio.main import app @@ -47,3 +49,28 @@ def test_list_comments_disabled(self, mock_auth): ) result = runner.invoke(app, ["comments", "list", "--video", "test_video_123"]) assert result.exit_code == 1 + + def test_reply(self, mock_auth): + result = runner.invoke( + app, ["comments", "reply", "UgwComment123", "--text", "Thanks for watching!"] + ) + assert result.exit_code == 0 + assert "UgwReply789" in result.stdout + insert = mock_auth.comments.return_value.insert + body = insert.call_args.kwargs["body"] + assert body["snippet"]["parentId"] == "UgwComment123" + assert body["snippet"]["textOriginal"] == "Thanks for watching!" + + def test_reply_requires_text(self, mock_auth): + result = runner.invoke(app, ["comments", "reply", "UgwComment123"]) + assert result.exit_code != 0 + + def test_reply_invalid_parent(self, mock_auth): + resp = MagicMock() + resp.status = 400 + mock_auth.comments.return_value.insert.return_value.execute.side_effect = HttpError( + resp, b'{"error": {"message": "invalid"}}' + ) + result = runner.invoke(app, ["comments", "reply", "not_a_top_level_id", "--text", "hi"]) + assert result.exit_code == 1 + assert "top-level comment id" in result.stdout