diff --git a/connector/src/platforms/gbp.js b/connector/src/platforms/gbp.js index 5a1c453..8511044 100644 --- a/connector/src/platforms/gbp.js +++ b/connector/src/platforms/gbp.js @@ -47,6 +47,28 @@ export default { return out; }, + /** Latest reviews on the chosen location (Business Profile API v4). */ + async reviews(tokens, target, { pageSize = 30 } = {}) { + const STARS = { ONE: 1, TWO: 2, THREE: 3, FOUR: 4, FIVE: 5 }; + const { data } = await request("Google Business Profile", `https://mybusiness.googleapis.com/v4/${target.id}/reviews`, { token: tokens.accessToken, query: { pageSize, orderBy: "updateTime desc" } }); + return { + average: data.averageRating ?? null, total: data.totalReviewCount ?? null, + reviews: (data.reviews || []).map((r) => ({ + id: r.name, author: r.reviewer?.isAnonymous ? "A Google user" : r.reviewer?.displayName || "A Google user", avatar: r.reviewer?.profilePhotoUrl || null, + stars: STARS[r.starRating] ?? null, text: r.comment || "", at: r.createTime, updatedAt: r.updateTime, + reply: r.reviewReply ? { text: r.reviewReply.comment, at: r.reviewReply.updateTime } : null, + })), + }; + }, + + async replyReview(tokens, reviewName, comment) { + const text = String(comment || "").trim(); + if (!text) throw new Error("Write a reply first."); + if (text.length > 4096) throw new Error("Google allows replies up to 4,096 characters."); + const { data } = await request("Google Business Profile", `https://mybusiness.googleapis.com/v4/${reviewName}/reply`, { token: tokens.accessToken, method: "PUT", json: { comment: text } }); + return { text: data.comment, at: data.updateTime }; + }, + async publish({ tokens, conn, post, opts, caption }) { const location = conn.target?.id; if (!location) throw new Error("Google Business Profile: pick the business location on the Connections page first."); diff --git a/connector/src/platforms/index.js b/connector/src/platforms/index.js index 7b5f3d4..e426803 100644 --- a/connector/src/platforms/index.js +++ b/connector/src/platforms/index.js @@ -110,6 +110,31 @@ export async function postStats(platform, client, ids) { return out; } +/** Google reviews for a client's Business Profile location. */ +export async function gbpReviews(client) { + const conn = vault.connectionFor(client, "gbp"); + if (!conn) throw err("Google Business Profile isn't connected for this client. Connect it on the Connections page."); + if (!conn.target) throw err("Choose the business location on the Connections page first."); + const { tokens } = await tokensFor(conn.id); + return { location: conn.target.name, ...(await adapter("gbp").reviews(tokens, conn.target)) }; +} +export async function gbpReply(client, reviewId, text) { + const conn = vault.connectionFor(client, "gbp"); + if (!conn?.target || !String(reviewId).startsWith(`${conn.target.id}/reviews/`)) throw err("That review isn't on this client's location."); + const { tokens } = await tokensFor(conn.id); + return adapter("gbp").replyReview(tokens, reviewId, text); +} +/** Latest YouTube comments on the client's channel. */ +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); + quota.ensure("youtube", null, "read"); + const out = await adapter("youtube").comments(tokens, conn.account); + quota.record("youtube", null, "read"); + return { channel: conn.account?.name, comments: out }; +} + /** Re-read the account (name, followers) to check the connection still works. */ export async function checkConnection(connId) { const { conn, tokens } = await tokensFor(connId); diff --git a/connector/src/platforms/youtube.js b/connector/src/platforms/youtube.js index 3c35ee4..62720da 100644 --- a/connector/src/platforms/youtube.js +++ b/connector/src/platforms/youtube.js @@ -32,6 +32,15 @@ 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" } }); + 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}` }; + }); + }, + /** View/like/comment counts for uploaded videos (1 quota unit per call, up to 50 ids). */ async stats(tokens, ids) { if (!ids.length) return {}; diff --git a/connector/test/platforms.test.mjs b/connector/test/platforms.test.mjs index 3ed6656..8a7cfb8 100644 --- a/connector/test/platforms.test.mjs +++ b/connector/test/platforms.test.mjs @@ -346,3 +346,28 @@ test("platform errors include the code and a plain fix", async () => { route("POST", "https://open.tiktokapis.com/v2/post/publish/video/init/", json({ error: { code: "unaudited_client_can_only_post_to_private_accounts", message: "Please review our integration guidelines" } }, 403)); await assert.rejects(P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "SELF_ONLY" } } }), /Private account.*unaudited_client_can_only_post_to_private_accounts/); }); + +test("Google reviews: list with stars and replies, reply only to this location's reviews", async () => { + reset(); + route("GET", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/reviews", json({ averageRating: 4.6, totalReviewCount: 52, reviews: [ + { name: "accounts/1/locations/5/reviews/r1", reviewer: { displayName: "Sam" }, starRating: "FIVE", comment: "Best croissants", createTime: "2026-10-01T10:00:00Z" }, + { name: "accounts/1/locations/5/reviews/r2", reviewer: { isAnonymous: true }, starRating: "TWO", createTime: "2026-10-02T10:00:00Z", reviewReply: { comment: "Sorry!", updateTime: "2026-10-03T10:00:00Z" } }, + ] })); + const r = await P.gbpReviews("acme"); + assert.equal(r.average, 4.6); + assert.deepEqual(r.reviews.map((x) => [x.stars, x.author, Boolean(x.reply)]), [[5, "Sam", false], [2, "A Google user", true]]); + route("PUT", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/reviews/r1/reply", json({ comment: "Thanks Sam!", updateTime: "2026-10-04T10:00:00Z" })); + assert.equal((await P.gbpReply("acme", "accounts/1/locations/5/reviews/r1", "Thanks Sam!")).text, "Thanks Sam!"); + await assert.rejects(P.gbpReply("acme", "accounts/9/locations/9/reviews/x", "hi"), /isn't on this client's location/); + await assert.rejects(P.gbpReply("acme", "accounts/1/locations/5/reviews/r1", " "), /Write a reply/); +}); + +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 })); + 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"); + 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=/); +}); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 04f1e98..88ea9b7 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -425,6 +425,9 @@ app.get("/api/library/file", h(async (req, res) => { })); // ---------- engagement: comment inbox ---------- +app.get("/api/inbox/reviews", h(async (req) => extra.gbpReviews(String(req.query.client || "")))); +app.post("/api/inbox/reviews/reply", h(async (req) => ({ reply: await extra.gbpReply(String(req.body?.client || ""), String(req.body?.reviewId || ""), req.body?.text) }))); +app.get("/api/inbox/youtube", h(async (req) => extra.youtubeComments(String(req.query.client || "")))); app.get("/api/inbox", h(async (req) => { const slug = req.query.client; clients.safeSlug(slug); return { comments: await meta.commentInbox(await clientAccount(slug), { days: Number(req.query.days || 14) }) }; diff --git a/dashboard/web/src/pages/Inbox.jsx b/dashboard/web/src/pages/Inbox.jsx index ebbdea9..480dc44 100644 --- a/dashboard/web/src/pages/Inbox.jsx +++ b/dashboard/web/src/pages/Inbox.jsx @@ -1,9 +1,36 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { EyeOff, Eye, MessageCircle, RefreshCw, Send } from "lucide-react"; import { api } from "../api.js"; -import { Badge, Button, cx, Empty, ErrorNote, Panel, Spinner, timeAgo } from "../ui.jsx"; +import { Badge, Button, cx, Empty, ErrorNote, Panel, Spinner, Tabs, timeAgo } from "../ui.jsx"; +import { GoogleReviews, YouTubeComments } from "./InboxMore.jsx"; export default function Inbox() { + const [tab, setTab] = useState("comments"); + const [all, setAll] = useState([]); + const [other, setOther] = useState(""); + useEffect(() => { api("/api/overview").then((r) => { setAll(r.clients); setOther((c) => c || r.clients[0]?.slug || ""); }).catch(() => {}); }, []); + return ( +
{tab === "reviews" ? "Reviews on the client's Business Profile. Replying to every review helps local ranking." : "Latest comments on the client's YouTube videos."}
+{r.text}
:Rating only, no text.
} + {r.reply &&{r.reply.text}
Latest comments on {data.channel}'s videos. Reply on YouTube for now: the button opens the comment.
+{c.text}
+