YouTube comments: read with an API key instead of the channel token
commentThreads.list rejects youtube.readonly ("insufficient authentication
scopes"); it needs youtube.force-ssl, which we don't want to request.
Public comments are now read with YOUTUBE_API_KEY. Also records the
re-submitted Google verification (video v2, new scope justification).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ac7d6f3ed8
commit
15bc0b9bf2
@ -75,3 +75,7 @@ DASHBOARD_NIGHTLY_REFRESH=true
|
||||
# STRIPE_TRIAL_DAYS=14
|
||||
# In a hosted agency workspace (set by the provisioning script):
|
||||
# SETUP_TOKEN= # only the emailed setup link can create the first admin
|
||||
|
||||
# YouTube comments in the Inbox (public comments, read with an API key, not the channel's sign-in).
|
||||
# Google Cloud (project Quorla) → APIs & Services → Credentials → Create API key → restrict to YouTube Data API v3.
|
||||
# YOUTUBE_API_KEY=
|
||||
|
||||
@ -128,9 +128,10 @@ export async function gbpReply(client, reviewId, text) {
|
||||
export async function youtubeComments(client) {
|
||||
const conn = vault.connectionFor(client, "youtube");
|
||||
if (!conn) throw err("YouTube isn't connected for this client.");
|
||||
const { tokens } = await tokensFor(conn.id);
|
||||
const key = process.env.YOUTUBE_API_KEY;
|
||||
if (!key) throw err("YouTube comments need YOUTUBE_API_KEY in .env (Google Cloud → Credentials → API key, restricted to YouTube Data API v3).");
|
||||
quota.ensure("youtube", null, "read");
|
||||
const out = await adapter("youtube").comments(tokens, conn.account);
|
||||
const out = await adapter("youtube").comments(key, conn.account);
|
||||
quota.record("youtube", null, "read");
|
||||
return { channel: conn.account?.name, comments: out };
|
||||
}
|
||||
|
||||
@ -32,9 +32,10 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
/** Latest comments across the channel's videos (read-only: replying needs a broader scope, so reply on YouTube). */
|
||||
async comments(tokens, account, { max = 30 } = {}) {
|
||||
const { data } = await request("YouTube", `${API}/commentThreads`, { token: tokens.accessToken, query: { part: "snippet", allThreadsRelatedToChannelId: account.id, maxResults: max, order: "time" } });
|
||||
/** Latest public comments across the channel's videos. Uses an API key, not the user's token: commentThreads.list
|
||||
* needs youtube.force-ssl with OAuth, a scope we don't request. Reply on YouTube. */
|
||||
async comments(apiKey, account, { max = 30 } = {}) {
|
||||
const { data } = await request("YouTube", `${API}/commentThreads`, { query: { key: apiKey, part: "snippet", allThreadsRelatedToChannelId: account.id, maxResults: max, order: "time" } });
|
||||
return (data.items || []).map((t) => {
|
||||
const c = t.snippet?.topLevelComment?.snippet || {};
|
||||
return { id: t.id, author: c.authorDisplayName, avatar: c.authorProfileImageUrl || null, text: c.textOriginal || "", at: c.publishedAt, likes: c.likeCount ?? 0, replies: t.snippet?.totalReplyCount ?? 0, videoId: t.snippet?.videoId, url: `https://www.youtube.com/watch?v=${t.snippet?.videoId}&lc=${t.id}` };
|
||||
|
||||
@ -364,10 +364,16 @@ test("Google reviews: list with stars and replies, reply only to this location's
|
||||
|
||||
test("YouTube comments: latest threads on the channel with a link to reply", async () => {
|
||||
reset();
|
||||
route("POST", "https://oauth2.googleapis.com/token", json({ access_token: "yt-new", expires_in: 3600 }));
|
||||
delete process.env.YOUTUBE_API_KEY;
|
||||
await assert.rejects(P.youtubeComments("acme"), /YOUTUBE_API_KEY/);
|
||||
process.env.YOUTUBE_API_KEY = "yt-key";
|
||||
route("GET", "https://www.googleapis.com/youtube/v3/commentThreads", json({ items: [{ id: "t1", snippet: { videoId: "v1", totalReplyCount: 2, topLevelComment: { snippet: { authorDisplayName: "Ana", textOriginal: "Love this", publishedAt: "2026-10-05T10:00:00Z", likeCount: 3 } } } }] }));
|
||||
const r = await P.youtubeComments("acme");
|
||||
delete process.env.YOUTUBE_API_KEY;
|
||||
assert.equal(r.comments[0].author, "Ana");
|
||||
assert.equal(r.comments[0].url, "https://www.youtube.com/watch?v=v1&lc=t1");
|
||||
assert.match(sent(/commentThreads/)[0].url, /allThreadsRelatedToChannelId=/);
|
||||
const call = sent(/commentThreads/)[0];
|
||||
assert.match(call.url, /allThreadsRelatedToChannelId=/);
|
||||
assert.match(call.url, /key=yt-key/);
|
||||
assert.ok(!call.headers?.Authorization, "comments are read with the API key, not the user's token");
|
||||
});
|
||||
|
||||
@ -53,6 +53,27 @@ Two Google Cloud projects, both owned by dev@metatroncubesolutions.com:
|
||||
together with the demo video link):
|
||||
> Quorla (quorlasocial.com) is a social media management tool for marketing agencies. An agency admin connects a client's YouTube channel on Quorla's Connections page. youtube.upload: the team prepares a video post in Quorla; an admin reviews and approves the exact video, title, description, visibility and made-for-kids setting, and Quorla uploads it to the connected channel at the scheduled time. Nothing is uploaded without that approval. youtube.readonly: show which channel is connected (name, handle, avatar, subscriber count) so the agency posts to the right channel, and link to the uploaded video afterwards. More limited scopes are not sufficient: youtube.upload is the narrowest scope that allows uploading, and youtube.readonly is needed to read the channel identity, which upload alone does not return. Tokens are encrypted at rest and deleted when the channel is disconnected.
|
||||
|
||||
Status 24 Oct 2026: Google asked for a **new demo video** (first one didn't show the consent screen with both YouTube
|
||||
scopes expanded, or every feature that uses them). New "How will the scopes be used?" text (replaces the one above):
|
||||
> Quorla (quorlasocial.com) is a social media management tool for marketing agencies. An agency admin connects a client's YouTube channel on Quorla's Connections page. youtube.upload: an admin reviews and approves the exact video, title, description, visibility and made-for-kids setting, and Quorla uploads it to the connected channel (videos.insert). Nothing is uploaded without that approval. youtube.readonly is used for two read-only features: (1) Connections page: channels.list (mine=true) shows the connected channel's name, handle, avatar and subscriber count, so the agency posts to the right channel; (2) Published page: videos.list shows the views, likes and comment counts of the videos Quorla uploaded, so the agency can report results to the client. youtube.upload cannot read any of this and there is no narrower read scope. We do not request the broader youtube or youtube.force-ssl scopes. Tokens are encrypted at rest and deleted when the channel is disconnected.
|
||||
|
||||
v2 video recorded 25 Oct 2026: https://youtu.be/CHHreGKIpXE (unlisted). **Submitted 25 Oct 2026** (Data Access updated with
|
||||
the text below and the new link; replied to Google's email from dev@). Waiting for Google's answer.
|
||||
|
||||
**Re-recorded demo video (v2)**, in this order, address bar visible, English:
|
||||
1. Before recording: in Quorla disconnect YouTube; at https://myaccount.google.com/connections remove Quorla's access
|
||||
for the test Google account (so the full consent screen shows). YouTube comments in the Inbox use an API key
|
||||
(`YOUTUBE_API_KEY`), not these scopes, so they are not part of this video.
|
||||
2. quorlasocial.com → Privacy policy → app.quorlasocial.com sign in.
|
||||
3. Connections → client → YouTube → Connect → account chooser. **Pause on the consent screen**: zoom the address bar
|
||||
(client_id and `scope=` with youtube.upload + youtube.readonly), click each scope's arrow / "Show all services" so
|
||||
both descriptions are fully readable, tick both, Continue.
|
||||
4. Back in Quorla: channel name, avatar, subscribers shown → say "youtube.readonly: channels.list".
|
||||
5. Publishing → new post with a vertical video → tick YouTube → title, visibility, made for kids → save draft → Approve →
|
||||
published → open on YouTube → say "youtube.upload: videos.insert".
|
||||
6. Published page → refresh stats → the YouTube video's views / likes / comments → "youtube.readonly: videos.list".
|
||||
7. Connections → Disconnect YouTube.
|
||||
|
||||
Status 22 Oct 2026 (evening): **Quorla project submitted for verification** (youtube.upload, youtube.readonly) with demo
|
||||
video https://youtu.be/lB8FMGDbBOo (unlisted); Data access status "under review". Google replies by email to dev@.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user