@hasna/connectors 1.3.37 → 1.3.38
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.
- package/dashboard/dist/assets/{index-DvfmyAO4.js → index-CUbp3qRv.js} +20 -20
- package/dashboard/dist/index.html +1 -1
- package/dist/.types/src/social/googlebusinessprofile.d.ts +40 -0
- package/dist/.types/src/social/http.d.ts +37 -0
- package/dist/.types/src/social/linkedin.d.ts +29 -0
- package/dist/.types/src/social/pinterest.d.ts +31 -0
- package/dist/.types/src/social/reddit.d.ts +31 -0
- package/dist/.types/src/social/tiktok.d.ts +24 -0
- package/dist/.types/src/social/types.d.ts +7 -1
- package/dist/.types/src/social/youtube.d.ts +29 -0
- package/dist/social/index.js +776 -4
- package/package.json +1 -1
package/dist/social/index.js
CHANGED
|
@@ -1467,12 +1467,772 @@ class BlueskyAdapter {
|
|
|
1467
1467
|
}
|
|
1468
1468
|
}
|
|
1469
1469
|
|
|
1470
|
+
// src/social/http.ts
|
|
1471
|
+
function resolveFetch(fetchImpl) {
|
|
1472
|
+
return fetchImpl ?? globalThis.fetch;
|
|
1473
|
+
}
|
|
1474
|
+
function appendQuery(url, query) {
|
|
1475
|
+
if (!query)
|
|
1476
|
+
return url;
|
|
1477
|
+
const qs = new URLSearchParams;
|
|
1478
|
+
for (const [k, v] of Object.entries(query)) {
|
|
1479
|
+
if (v !== undefined && v !== null && v !== "")
|
|
1480
|
+
qs.append(k, String(v));
|
|
1481
|
+
}
|
|
1482
|
+
const s = qs.toString();
|
|
1483
|
+
return s ? `${url}${url.includes("?") ? "&" : "?"}${s}` : url;
|
|
1484
|
+
}
|
|
1485
|
+
function extractError(data, fallback) {
|
|
1486
|
+
if (data && typeof data === "object") {
|
|
1487
|
+
const d = data;
|
|
1488
|
+
const candidates = [
|
|
1489
|
+
d.error_description,
|
|
1490
|
+
typeof d.error === "string" ? d.error : undefined,
|
|
1491
|
+
d.message,
|
|
1492
|
+
d.error && typeof d.error === "object" ? d.error.message : undefined
|
|
1493
|
+
];
|
|
1494
|
+
for (const c of candidates) {
|
|
1495
|
+
if (typeof c === "string" && c)
|
|
1496
|
+
return c;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
return fallback;
|
|
1500
|
+
}
|
|
1501
|
+
async function jsonRequest(fetchImpl, url, options = {}) {
|
|
1502
|
+
const { method = "GET", query, body } = options;
|
|
1503
|
+
const finalUrl = appendQuery(url, query);
|
|
1504
|
+
const headers = { Accept: "application/json", ...options.headers ?? {} };
|
|
1505
|
+
let serialized;
|
|
1506
|
+
if (body !== undefined && method !== "GET" && method !== "HEAD") {
|
|
1507
|
+
if (typeof body === "string" || body instanceof FormData) {
|
|
1508
|
+
serialized = body;
|
|
1509
|
+
} else {
|
|
1510
|
+
if (!headers["Content-Type"])
|
|
1511
|
+
headers["Content-Type"] = "application/json";
|
|
1512
|
+
serialized = JSON.stringify(body);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
const res = await fetchImpl(finalUrl, { method, headers, body: serialized });
|
|
1516
|
+
const text = await res.text();
|
|
1517
|
+
const data = text ? safeJsonParse(text) : {};
|
|
1518
|
+
if (!res.ok) {
|
|
1519
|
+
const label = options.errorLabel ?? "request";
|
|
1520
|
+
throw new Error(`${label} ${res.status}: ${extractError(data, res.statusText || "failed")}`);
|
|
1521
|
+
}
|
|
1522
|
+
return data;
|
|
1523
|
+
}
|
|
1524
|
+
function safeJsonParse(text) {
|
|
1525
|
+
try {
|
|
1526
|
+
return JSON.parse(text);
|
|
1527
|
+
} catch {
|
|
1528
|
+
return { raw: text };
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// src/social/linkedin.ts
|
|
1533
|
+
var DEFAULT_BASE = "https://api.linkedin.com";
|
|
1534
|
+
|
|
1535
|
+
class LinkedInAdapter {
|
|
1536
|
+
accessToken;
|
|
1537
|
+
baseUrl;
|
|
1538
|
+
authorUrn;
|
|
1539
|
+
fetchImpl;
|
|
1540
|
+
constructor(creds, fetchImpl) {
|
|
1541
|
+
if (!creds || !creds.accessToken) {
|
|
1542
|
+
throw new Error("linkedin credentials require `accessToken`");
|
|
1543
|
+
}
|
|
1544
|
+
this.accessToken = creds.accessToken;
|
|
1545
|
+
this.authorUrn = creds.authorUrn;
|
|
1546
|
+
this.baseUrl = (creds.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, "");
|
|
1547
|
+
this.fetchImpl = resolveFetch(fetchImpl);
|
|
1548
|
+
}
|
|
1549
|
+
static fromCredentials(creds, fetchImpl) {
|
|
1550
|
+
return new LinkedInAdapter(creds, fetchImpl);
|
|
1551
|
+
}
|
|
1552
|
+
headers(extra) {
|
|
1553
|
+
return {
|
|
1554
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
1555
|
+
"X-Restli-Protocol-Version": "2.0.0",
|
|
1556
|
+
...extra ?? {}
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
async resolveAuthorUrn() {
|
|
1560
|
+
if (this.authorUrn)
|
|
1561
|
+
return this.authorUrn;
|
|
1562
|
+
const me = await jsonRequest(this.fetchImpl, `${this.baseUrl}/v2/me`, {
|
|
1563
|
+
headers: this.headers(),
|
|
1564
|
+
errorLabel: "LinkedIn"
|
|
1565
|
+
});
|
|
1566
|
+
this.authorUrn = `urn:li:person:${me.id}`;
|
|
1567
|
+
return this.authorUrn;
|
|
1568
|
+
}
|
|
1569
|
+
async accountMe() {
|
|
1570
|
+
const me = await jsonRequest(this.fetchImpl, `${this.baseUrl}/v2/me`, { headers: this.headers(), errorLabel: "LinkedIn" });
|
|
1571
|
+
this.authorUrn = `urn:li:person:${me.id}`;
|
|
1572
|
+
const displayName = [me.localizedFirstName, me.localizedLastName].filter(Boolean).join(" ") || undefined;
|
|
1573
|
+
return {
|
|
1574
|
+
id: me.id,
|
|
1575
|
+
username: me.vanityName ?? me.id,
|
|
1576
|
+
displayName,
|
|
1577
|
+
url: me.vanityName ? `https://www.linkedin.com/in/${me.vanityName}` : undefined
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
async postCreate(input) {
|
|
1581
|
+
const author = await this.resolveAuthorUrn();
|
|
1582
|
+
const visibility = input.visibility ?? "PUBLIC";
|
|
1583
|
+
const hasMedia = Boolean(input.mediaIds && input.mediaIds.length > 0);
|
|
1584
|
+
const shareContent = {
|
|
1585
|
+
shareCommentary: { text: input.text },
|
|
1586
|
+
shareMediaCategory: hasMedia ? "IMAGE" : "NONE"
|
|
1587
|
+
};
|
|
1588
|
+
if (hasMedia) {
|
|
1589
|
+
shareContent.media = input.mediaIds.map((m) => ({ status: "READY", media: m }));
|
|
1590
|
+
}
|
|
1591
|
+
const body = {
|
|
1592
|
+
author,
|
|
1593
|
+
lifecycleState: "PUBLISHED",
|
|
1594
|
+
specificContent: { "com.linkedin.ugc.ShareContent": shareContent },
|
|
1595
|
+
visibility: { "com.linkedin.ugc.MemberNetworkVisibility": visibility }
|
|
1596
|
+
};
|
|
1597
|
+
const res = await jsonRequest(this.fetchImpl, `${this.baseUrl}/v2/ugcPosts`, {
|
|
1598
|
+
method: "POST",
|
|
1599
|
+
headers: this.headers(),
|
|
1600
|
+
body,
|
|
1601
|
+
errorLabel: "LinkedIn"
|
|
1602
|
+
});
|
|
1603
|
+
return {
|
|
1604
|
+
id: res.id,
|
|
1605
|
+
url: `https://www.linkedin.com/feed/update/${res.id}`
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
async postDelete(input) {
|
|
1609
|
+
await jsonRequest(this.fetchImpl, `${this.baseUrl}/v2/ugcPosts/${encodeURIComponent(input.id)}`, {
|
|
1610
|
+
method: "DELETE",
|
|
1611
|
+
headers: this.headers(),
|
|
1612
|
+
errorLabel: "LinkedIn"
|
|
1613
|
+
});
|
|
1614
|
+
return { id: input.id, deleted: true };
|
|
1615
|
+
}
|
|
1616
|
+
async mediaUpload(input) {
|
|
1617
|
+
const author = await this.resolveAuthorUrn();
|
|
1618
|
+
const register = await jsonRequest(this.fetchImpl, `${this.baseUrl}/v2/assets?action=registerUpload`, {
|
|
1619
|
+
method: "POST",
|
|
1620
|
+
headers: this.headers(),
|
|
1621
|
+
body: {
|
|
1622
|
+
registerUploadRequest: {
|
|
1623
|
+
owner: author,
|
|
1624
|
+
recipes: ["urn:li:digitalmediaRecipe:feedshare-image"],
|
|
1625
|
+
serviceRelationships: [
|
|
1626
|
+
{ relationshipType: "OWNER", identifier: "urn:li:userGeneratedContent" }
|
|
1627
|
+
]
|
|
1628
|
+
}
|
|
1629
|
+
},
|
|
1630
|
+
errorLabel: "LinkedIn"
|
|
1631
|
+
});
|
|
1632
|
+
const asset = register.value.asset;
|
|
1633
|
+
const uploadUrl = register.value.uploadMechanism["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"].uploadUrl;
|
|
1634
|
+
const buffer = decodeBase64(input.dataBase64);
|
|
1635
|
+
const bytes = Uint8Array.from(buffer);
|
|
1636
|
+
const put = await this.fetchImpl(uploadUrl, {
|
|
1637
|
+
method: "PUT",
|
|
1638
|
+
headers: { Authorization: `Bearer ${this.accessToken}`, "Content-Type": input.mimeType },
|
|
1639
|
+
body: bytes
|
|
1640
|
+
});
|
|
1641
|
+
if (!put.ok) {
|
|
1642
|
+
throw new Error(`LinkedIn ${put.status}: media upload failed`);
|
|
1643
|
+
}
|
|
1644
|
+
return { mediaId: asset };
|
|
1645
|
+
}
|
|
1646
|
+
async mentionsList(_input = {}) {
|
|
1647
|
+
throw new ConnectorOperationNotSupported("linkedin", "mentions.list (no public mentions/notifications endpoint)");
|
|
1648
|
+
}
|
|
1649
|
+
async analyticsPost(_input) {
|
|
1650
|
+
throw new ConnectorOperationNotSupported("linkedin", "analytics.post (requires organization socialActions scope; not available for member shares)");
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// src/social/reddit.ts
|
|
1655
|
+
var BASE = "https://oauth.reddit.com";
|
|
1656
|
+
function fullname(id) {
|
|
1657
|
+
return id.startsWith("t3_") ? id : `t3_${id}`;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
class RedditAdapter {
|
|
1661
|
+
accessToken;
|
|
1662
|
+
userAgent;
|
|
1663
|
+
fetchImpl;
|
|
1664
|
+
constructor(creds, fetchImpl) {
|
|
1665
|
+
if (!creds || !creds.accessToken) {
|
|
1666
|
+
throw new Error("reddit credentials require `accessToken`");
|
|
1667
|
+
}
|
|
1668
|
+
this.accessToken = creds.accessToken;
|
|
1669
|
+
this.userAgent = creds.userAgent ?? "hasna-connectors/social";
|
|
1670
|
+
this.fetchImpl = resolveFetch(fetchImpl);
|
|
1671
|
+
}
|
|
1672
|
+
static fromCredentials(creds, fetchImpl) {
|
|
1673
|
+
return new RedditAdapter(creds, fetchImpl);
|
|
1674
|
+
}
|
|
1675
|
+
headers(extra) {
|
|
1676
|
+
return {
|
|
1677
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
1678
|
+
"User-Agent": this.userAgent,
|
|
1679
|
+
...extra ?? {}
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
form(params) {
|
|
1683
|
+
const qs = new URLSearchParams;
|
|
1684
|
+
for (const [k, v] of Object.entries(params)) {
|
|
1685
|
+
if (v !== undefined)
|
|
1686
|
+
qs.append(k, v);
|
|
1687
|
+
}
|
|
1688
|
+
return qs.toString();
|
|
1689
|
+
}
|
|
1690
|
+
async accountMe() {
|
|
1691
|
+
const me = await jsonRequest(this.fetchImpl, `${BASE}/api/v1/me`, { headers: this.headers(), errorLabel: "Reddit" });
|
|
1692
|
+
return {
|
|
1693
|
+
id: me.id,
|
|
1694
|
+
username: me.name,
|
|
1695
|
+
displayName: me.name,
|
|
1696
|
+
url: `https://www.reddit.com/user/${me.name}`
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
async postCreate(input) {
|
|
1700
|
+
if (!input.title) {
|
|
1701
|
+
throw new Error("reddit post.create requires `title`");
|
|
1702
|
+
}
|
|
1703
|
+
if (!input.subreddit) {
|
|
1704
|
+
throw new Error("reddit post.create requires `subreddit`");
|
|
1705
|
+
}
|
|
1706
|
+
const kind = input.kind ?? "self";
|
|
1707
|
+
if (kind === "link" && !input.url) {
|
|
1708
|
+
throw new Error('reddit post.create with kind "link" requires `url`');
|
|
1709
|
+
}
|
|
1710
|
+
const body = this.form({
|
|
1711
|
+
sr: input.subreddit,
|
|
1712
|
+
title: input.title,
|
|
1713
|
+
kind,
|
|
1714
|
+
text: kind === "self" ? input.text : undefined,
|
|
1715
|
+
url: kind === "link" ? input.url : undefined,
|
|
1716
|
+
api_type: "json"
|
|
1717
|
+
});
|
|
1718
|
+
const res = await jsonRequest(this.fetchImpl, `${BASE}/api/submit`, {
|
|
1719
|
+
method: "POST",
|
|
1720
|
+
headers: this.headers({ "Content-Type": "application/x-www-form-urlencoded" }),
|
|
1721
|
+
body,
|
|
1722
|
+
errorLabel: "Reddit"
|
|
1723
|
+
});
|
|
1724
|
+
const errors = res.json?.errors ?? [];
|
|
1725
|
+
if (errors.length > 0) {
|
|
1726
|
+
throw new Error(`Reddit submit failed: ${JSON.stringify(errors)}`);
|
|
1727
|
+
}
|
|
1728
|
+
const data = res.json?.data ?? {};
|
|
1729
|
+
return { id: data.id ?? data.name ?? "", url: data.url };
|
|
1730
|
+
}
|
|
1731
|
+
async postDelete(input) {
|
|
1732
|
+
await jsonRequest(this.fetchImpl, `${BASE}/api/del`, {
|
|
1733
|
+
method: "POST",
|
|
1734
|
+
headers: this.headers({ "Content-Type": "application/x-www-form-urlencoded" }),
|
|
1735
|
+
body: this.form({ id: fullname(input.id) }),
|
|
1736
|
+
errorLabel: "Reddit"
|
|
1737
|
+
});
|
|
1738
|
+
return { id: input.id, deleted: true };
|
|
1739
|
+
}
|
|
1740
|
+
async mediaUpload(_input) {
|
|
1741
|
+
throw new ConnectorOperationNotSupported("reddit", "media.upload (image uploads require the media asset-lease flow; not supported by this SDK)");
|
|
1742
|
+
}
|
|
1743
|
+
async mentionsList(input = {}) {
|
|
1744
|
+
const res = await jsonRequest(this.fetchImpl, `${BASE}/message/inbox`, {
|
|
1745
|
+
headers: this.headers(),
|
|
1746
|
+
query: { limit: input.limit, after: input.sinceId },
|
|
1747
|
+
errorLabel: "Reddit"
|
|
1748
|
+
});
|
|
1749
|
+
const items = (res.data?.children ?? []).map((c) => ({
|
|
1750
|
+
id: c.data.name ?? c.data.id,
|
|
1751
|
+
text: c.data.body ?? "",
|
|
1752
|
+
authorId: c.data.author_fullname,
|
|
1753
|
+
authorHandle: c.data.author,
|
|
1754
|
+
createdAt: c.data.created_utc ? new Date(c.data.created_utc * 1000).toISOString() : undefined
|
|
1755
|
+
}));
|
|
1756
|
+
return { items };
|
|
1757
|
+
}
|
|
1758
|
+
async analyticsPost(input) {
|
|
1759
|
+
const res = await jsonRequest(this.fetchImpl, `${BASE}/api/info`, {
|
|
1760
|
+
headers: this.headers(),
|
|
1761
|
+
query: { id: fullname(input.id) },
|
|
1762
|
+
errorLabel: "Reddit"
|
|
1763
|
+
});
|
|
1764
|
+
const post = res.data?.children?.[0]?.data;
|
|
1765
|
+
if (!post) {
|
|
1766
|
+
throw new ConnectorOperationNotSupported("reddit", `analytics.post (post not found: ${input.id})`);
|
|
1767
|
+
}
|
|
1768
|
+
return {
|
|
1769
|
+
metrics: {
|
|
1770
|
+
ups: post.ups ?? 0,
|
|
1771
|
+
downs: post.downs ?? 0,
|
|
1772
|
+
score: post.score ?? 0,
|
|
1773
|
+
numComments: post.num_comments ?? 0,
|
|
1774
|
+
upvoteRatio: post.upvote_ratio ?? 0
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// src/social/tiktok.ts
|
|
1781
|
+
var BASE2 = "https://open.tiktokapis.com";
|
|
1782
|
+
|
|
1783
|
+
class TikTokAdapter {
|
|
1784
|
+
accessToken;
|
|
1785
|
+
fetchImpl;
|
|
1786
|
+
constructor(creds, fetchImpl) {
|
|
1787
|
+
if (!creds || !creds.accessToken) {
|
|
1788
|
+
throw new Error("tiktok credentials require `accessToken`");
|
|
1789
|
+
}
|
|
1790
|
+
this.accessToken = creds.accessToken;
|
|
1791
|
+
this.fetchImpl = resolveFetch(fetchImpl);
|
|
1792
|
+
}
|
|
1793
|
+
static fromCredentials(creds, fetchImpl) {
|
|
1794
|
+
return new TikTokAdapter(creds, fetchImpl);
|
|
1795
|
+
}
|
|
1796
|
+
headers(extra) {
|
|
1797
|
+
return { Authorization: `Bearer ${this.accessToken}`, ...extra ?? {} };
|
|
1798
|
+
}
|
|
1799
|
+
async accountMe() {
|
|
1800
|
+
const res = await jsonRequest(this.fetchImpl, `${BASE2}/v2/user/info/`, {
|
|
1801
|
+
headers: this.headers(),
|
|
1802
|
+
query: { fields: "open_id,union_id,display_name,profile_deep_link" },
|
|
1803
|
+
errorLabel: "TikTok"
|
|
1804
|
+
});
|
|
1805
|
+
const user = res.data?.user ?? {};
|
|
1806
|
+
return {
|
|
1807
|
+
id: user.open_id ?? user.union_id ?? "",
|
|
1808
|
+
username: user.display_name,
|
|
1809
|
+
displayName: user.display_name,
|
|
1810
|
+
url: user.profile_deep_link
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
async postCreate(input) {
|
|
1814
|
+
const videoId = input.mediaIds?.[0];
|
|
1815
|
+
if (!videoId) {
|
|
1816
|
+
throw new ConnectorOperationNotSupported("tiktok", "post.create (TikTok is video-only: provide a video media id in `mediaIds` via media.upload)");
|
|
1817
|
+
}
|
|
1818
|
+
const body = {
|
|
1819
|
+
post_info: {
|
|
1820
|
+
title: input.text ?? "",
|
|
1821
|
+
privacy_level: input.privacyLevel ?? "PUBLIC_TO_EVERYONE"
|
|
1822
|
+
},
|
|
1823
|
+
source_info: {
|
|
1824
|
+
source: "FILE_UPLOAD",
|
|
1825
|
+
video_id: videoId
|
|
1826
|
+
}
|
|
1827
|
+
};
|
|
1828
|
+
const res = await jsonRequest(this.fetchImpl, `${BASE2}/v2/post/publish/video/init/`, { method: "POST", headers: this.headers({ "Content-Type": "application/json" }), body, errorLabel: "TikTok" });
|
|
1829
|
+
return { id: res.data?.publish_id ?? videoId };
|
|
1830
|
+
}
|
|
1831
|
+
async postDelete(_input) {
|
|
1832
|
+
throw new ConnectorOperationNotSupported("tiktok", "post.delete (the Content Posting API does not expose post deletion)");
|
|
1833
|
+
}
|
|
1834
|
+
async mediaUpload(input) {
|
|
1835
|
+
const buffer = decodeBase64(input.dataBase64);
|
|
1836
|
+
const size = buffer.byteLength;
|
|
1837
|
+
const init = await jsonRequest(this.fetchImpl, `${BASE2}/v2/post/publish/inbox/video/init/`, {
|
|
1838
|
+
method: "POST",
|
|
1839
|
+
headers: this.headers({ "Content-Type": "application/json" }),
|
|
1840
|
+
body: {
|
|
1841
|
+
source_info: {
|
|
1842
|
+
source: "FILE_UPLOAD",
|
|
1843
|
+
video_size: size,
|
|
1844
|
+
chunk_size: size,
|
|
1845
|
+
total_chunk_count: 1
|
|
1846
|
+
}
|
|
1847
|
+
},
|
|
1848
|
+
errorLabel: "TikTok"
|
|
1849
|
+
});
|
|
1850
|
+
const uploadUrl = init.data?.upload_url;
|
|
1851
|
+
const publishId = init.data?.publish_id;
|
|
1852
|
+
if (!uploadUrl || !publishId) {
|
|
1853
|
+
throw new Error("TikTok media.upload: init did not return an upload_url/publish_id");
|
|
1854
|
+
}
|
|
1855
|
+
const bytes = Uint8Array.from(buffer);
|
|
1856
|
+
const put = await this.fetchImpl(uploadUrl, {
|
|
1857
|
+
method: "PUT",
|
|
1858
|
+
headers: {
|
|
1859
|
+
"Content-Type": input.mimeType,
|
|
1860
|
+
"Content-Range": `bytes 0-${size - 1}/${size}`
|
|
1861
|
+
},
|
|
1862
|
+
body: bytes
|
|
1863
|
+
});
|
|
1864
|
+
if (!put.ok) {
|
|
1865
|
+
throw new Error(`TikTok ${put.status}: video upload failed`);
|
|
1866
|
+
}
|
|
1867
|
+
return { mediaId: publishId };
|
|
1868
|
+
}
|
|
1869
|
+
async mentionsList(_input = {}) {
|
|
1870
|
+
throw new ConnectorOperationNotSupported("tiktok", "mentions.list (no mentions endpoint in the Content Posting / Display API)");
|
|
1871
|
+
}
|
|
1872
|
+
async analyticsPost(_input) {
|
|
1873
|
+
throw new ConnectorOperationNotSupported("tiktok", "analytics.post (per-post metrics require the Research/Business API, not available here)");
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// src/social/youtube.ts
|
|
1878
|
+
var API = "https://www.googleapis.com/youtube/v3";
|
|
1879
|
+
var UPLOAD = "https://www.googleapis.com/upload/youtube/v3";
|
|
1880
|
+
|
|
1881
|
+
class YouTubeAdapter {
|
|
1882
|
+
accessToken;
|
|
1883
|
+
fetchImpl;
|
|
1884
|
+
constructor(creds, fetchImpl) {
|
|
1885
|
+
if (!creds || !creds.accessToken) {
|
|
1886
|
+
throw new Error("youtube credentials require `accessToken`");
|
|
1887
|
+
}
|
|
1888
|
+
this.accessToken = creds.accessToken;
|
|
1889
|
+
this.fetchImpl = resolveFetch(fetchImpl);
|
|
1890
|
+
}
|
|
1891
|
+
static fromCredentials(creds, fetchImpl) {
|
|
1892
|
+
return new YouTubeAdapter(creds, fetchImpl);
|
|
1893
|
+
}
|
|
1894
|
+
headers(extra) {
|
|
1895
|
+
return { Authorization: `Bearer ${this.accessToken}`, ...extra ?? {} };
|
|
1896
|
+
}
|
|
1897
|
+
async accountMe() {
|
|
1898
|
+
const res = await jsonRequest(this.fetchImpl, `${API}/channels`, {
|
|
1899
|
+
headers: this.headers(),
|
|
1900
|
+
query: { part: "snippet", mine: "true" },
|
|
1901
|
+
errorLabel: "YouTube"
|
|
1902
|
+
});
|
|
1903
|
+
const channel = res.items?.[0];
|
|
1904
|
+
if (!channel) {
|
|
1905
|
+
throw new Error("YouTube account.me: no channel found for the authenticated user");
|
|
1906
|
+
}
|
|
1907
|
+
return {
|
|
1908
|
+
id: channel.id,
|
|
1909
|
+
username: channel.snippet?.customUrl ?? channel.snippet?.title,
|
|
1910
|
+
displayName: channel.snippet?.title,
|
|
1911
|
+
url: `https://www.youtube.com/channel/${channel.id}`
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
async postCreate(input) {
|
|
1915
|
+
const videoId = input.mediaIds?.[0];
|
|
1916
|
+
if (!videoId) {
|
|
1917
|
+
throw new ConnectorOperationNotSupported("youtube", "post.create (YouTube is video-only: upload a video via media.upload and pass its id in `mediaIds`)");
|
|
1918
|
+
}
|
|
1919
|
+
const title = input.title ?? input.text ?? "";
|
|
1920
|
+
const description = input.description ?? input.text ?? "";
|
|
1921
|
+
await jsonRequest(this.fetchImpl, `${API}/videos`, {
|
|
1922
|
+
method: "PUT",
|
|
1923
|
+
headers: this.headers({ "Content-Type": "application/json" }),
|
|
1924
|
+
query: { part: "snippet" },
|
|
1925
|
+
body: {
|
|
1926
|
+
id: videoId,
|
|
1927
|
+
snippet: { title, description, categoryId: input.categoryId ?? "22" }
|
|
1928
|
+
},
|
|
1929
|
+
errorLabel: "YouTube"
|
|
1930
|
+
});
|
|
1931
|
+
return { id: videoId, url: `https://www.youtube.com/watch?v=${videoId}` };
|
|
1932
|
+
}
|
|
1933
|
+
async postDelete(input) {
|
|
1934
|
+
await jsonRequest(this.fetchImpl, `${API}/videos`, {
|
|
1935
|
+
method: "DELETE",
|
|
1936
|
+
headers: this.headers(),
|
|
1937
|
+
query: { id: input.id },
|
|
1938
|
+
errorLabel: "YouTube"
|
|
1939
|
+
});
|
|
1940
|
+
return { id: input.id, deleted: true };
|
|
1941
|
+
}
|
|
1942
|
+
async mediaUpload(input) {
|
|
1943
|
+
const buffer = decodeBase64(input.dataBase64);
|
|
1944
|
+
const size = buffer.byteLength;
|
|
1945
|
+
const start = await this.fetchImpl(`${UPLOAD}/videos?uploadType=resumable&part=snippet,status`, {
|
|
1946
|
+
method: "POST",
|
|
1947
|
+
headers: this.headers({
|
|
1948
|
+
"Content-Type": "application/json",
|
|
1949
|
+
"X-Upload-Content-Type": input.mimeType,
|
|
1950
|
+
"X-Upload-Content-Length": String(size)
|
|
1951
|
+
}),
|
|
1952
|
+
body: JSON.stringify({
|
|
1953
|
+
snippet: { title: input.title ?? input.altText ?? "Untitled", description: input.description ?? "" },
|
|
1954
|
+
status: { privacyStatus: input.privacyStatus ?? "private" }
|
|
1955
|
+
})
|
|
1956
|
+
});
|
|
1957
|
+
if (!start.ok) {
|
|
1958
|
+
throw new Error(`YouTube ${start.status}: resumable upload init failed`);
|
|
1959
|
+
}
|
|
1960
|
+
const location = start.headers?.get("location");
|
|
1961
|
+
if (!location) {
|
|
1962
|
+
throw new Error("YouTube media.upload: resumable session did not return a Location header");
|
|
1963
|
+
}
|
|
1964
|
+
const bytes = Uint8Array.from(buffer);
|
|
1965
|
+
const put = await this.fetchImpl(location, {
|
|
1966
|
+
method: "PUT",
|
|
1967
|
+
headers: { "Content-Type": input.mimeType, "Content-Length": String(size) },
|
|
1968
|
+
body: bytes
|
|
1969
|
+
});
|
|
1970
|
+
const text = await put.text();
|
|
1971
|
+
const data = text ? JSON.parse(text) : {};
|
|
1972
|
+
if (!put.ok) {
|
|
1973
|
+
throw new Error(`YouTube ${put.status}: video upload failed`);
|
|
1974
|
+
}
|
|
1975
|
+
return { mediaId: String(data.id) };
|
|
1976
|
+
}
|
|
1977
|
+
async mentionsList(_input = {}) {
|
|
1978
|
+
throw new ConnectorOperationNotSupported("youtube", "mentions.list (no mentions endpoint in the YouTube Data API)");
|
|
1979
|
+
}
|
|
1980
|
+
async analyticsPost(input) {
|
|
1981
|
+
const res = await jsonRequest(this.fetchImpl, `${API}/videos`, {
|
|
1982
|
+
headers: this.headers(),
|
|
1983
|
+
query: { part: "statistics", id: input.id },
|
|
1984
|
+
errorLabel: "YouTube"
|
|
1985
|
+
});
|
|
1986
|
+
const stats = res.items?.[0]?.statistics;
|
|
1987
|
+
if (!stats) {
|
|
1988
|
+
throw new ConnectorOperationNotSupported("youtube", `analytics.post (video not found: ${input.id})`);
|
|
1989
|
+
}
|
|
1990
|
+
return {
|
|
1991
|
+
metrics: {
|
|
1992
|
+
viewCount: Number(stats.viewCount ?? 0),
|
|
1993
|
+
likeCount: Number(stats.likeCount ?? 0),
|
|
1994
|
+
commentCount: Number(stats.commentCount ?? 0),
|
|
1995
|
+
favoriteCount: Number(stats.favoriteCount ?? 0)
|
|
1996
|
+
}
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// src/social/pinterest.ts
|
|
2002
|
+
var BASE3 = "https://api.pinterest.com";
|
|
2003
|
+
|
|
2004
|
+
class PinterestAdapter {
|
|
2005
|
+
accessToken;
|
|
2006
|
+
defaultBoardId;
|
|
2007
|
+
fetchImpl;
|
|
2008
|
+
constructor(creds, fetchImpl) {
|
|
2009
|
+
if (!creds || !creds.accessToken) {
|
|
2010
|
+
throw new Error("pinterest credentials require `accessToken`");
|
|
2011
|
+
}
|
|
2012
|
+
this.accessToken = creds.accessToken;
|
|
2013
|
+
this.defaultBoardId = creds.boardId;
|
|
2014
|
+
this.fetchImpl = resolveFetch(fetchImpl);
|
|
2015
|
+
}
|
|
2016
|
+
static fromCredentials(creds, fetchImpl) {
|
|
2017
|
+
return new PinterestAdapter(creds, fetchImpl);
|
|
2018
|
+
}
|
|
2019
|
+
headers(extra) {
|
|
2020
|
+
return { Authorization: `Bearer ${this.accessToken}`, ...extra ?? {} };
|
|
2021
|
+
}
|
|
2022
|
+
async accountMe() {
|
|
2023
|
+
const acct = await jsonRequest(this.fetchImpl, `${BASE3}/v5/user_account`, { headers: this.headers(), errorLabel: "Pinterest" });
|
|
2024
|
+
return {
|
|
2025
|
+
id: acct.username ?? "",
|
|
2026
|
+
username: acct.username,
|
|
2027
|
+
displayName: acct.username,
|
|
2028
|
+
url: acct.username ? `https://www.pinterest.com/${acct.username}/` : undefined
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
async postCreate(input) {
|
|
2032
|
+
const boardId = input.boardId ?? this.defaultBoardId;
|
|
2033
|
+
if (!boardId) {
|
|
2034
|
+
throw new Error("pinterest post.create requires `boardId` (in input or credentials)");
|
|
2035
|
+
}
|
|
2036
|
+
const mediaId = input.mediaIds?.[0];
|
|
2037
|
+
let mediaSource;
|
|
2038
|
+
if (mediaId) {
|
|
2039
|
+
mediaSource = { source_type: "image_upload", media_id: mediaId };
|
|
2040
|
+
} else if (input.imageUrl) {
|
|
2041
|
+
mediaSource = { source_type: "image_url", url: input.imageUrl };
|
|
2042
|
+
} else {
|
|
2043
|
+
throw new Error("pinterest post.create requires an image: provide a `mediaIds` entry or `imageUrl`");
|
|
2044
|
+
}
|
|
2045
|
+
const body = {
|
|
2046
|
+
board_id: boardId,
|
|
2047
|
+
description: input.text,
|
|
2048
|
+
media_source: mediaSource
|
|
2049
|
+
};
|
|
2050
|
+
if (input.title)
|
|
2051
|
+
body.title = input.title;
|
|
2052
|
+
if (input.link)
|
|
2053
|
+
body.link = input.link;
|
|
2054
|
+
const pin = await jsonRequest(this.fetchImpl, `${BASE3}/v5/pins`, {
|
|
2055
|
+
method: "POST",
|
|
2056
|
+
headers: this.headers({ "Content-Type": "application/json" }),
|
|
2057
|
+
body,
|
|
2058
|
+
errorLabel: "Pinterest"
|
|
2059
|
+
});
|
|
2060
|
+
return { id: pin.id, url: `https://www.pinterest.com/pin/${pin.id}/` };
|
|
2061
|
+
}
|
|
2062
|
+
async postDelete(input) {
|
|
2063
|
+
await jsonRequest(this.fetchImpl, `${BASE3}/v5/pins/${encodeURIComponent(input.id)}`, {
|
|
2064
|
+
method: "DELETE",
|
|
2065
|
+
headers: this.headers(),
|
|
2066
|
+
errorLabel: "Pinterest"
|
|
2067
|
+
});
|
|
2068
|
+
return { id: input.id, deleted: true };
|
|
2069
|
+
}
|
|
2070
|
+
async mediaUpload(input) {
|
|
2071
|
+
const register = await jsonRequest(this.fetchImpl, `${BASE3}/v5/media`, {
|
|
2072
|
+
method: "POST",
|
|
2073
|
+
headers: this.headers({ "Content-Type": "application/json" }),
|
|
2074
|
+
body: { media_type: "image" },
|
|
2075
|
+
errorLabel: "Pinterest"
|
|
2076
|
+
});
|
|
2077
|
+
const buffer = decodeBase64(input.dataBase64);
|
|
2078
|
+
const bytes = Uint8Array.from(buffer);
|
|
2079
|
+
const form = new FormData;
|
|
2080
|
+
for (const [k, v] of Object.entries(register.upload_parameters ?? {})) {
|
|
2081
|
+
form.append(k, v);
|
|
2082
|
+
}
|
|
2083
|
+
form.append("file", new Blob([bytes], { type: input.mimeType }));
|
|
2084
|
+
const put = await this.fetchImpl(register.upload_url, { method: "POST", body: form });
|
|
2085
|
+
if (!put.ok) {
|
|
2086
|
+
throw new Error(`Pinterest ${put.status}: media upload failed`);
|
|
2087
|
+
}
|
|
2088
|
+
return { mediaId: register.media_id };
|
|
2089
|
+
}
|
|
2090
|
+
async mentionsList(_input = {}) {
|
|
2091
|
+
throw new ConnectorOperationNotSupported("pinterest", "mentions.list (no mentions endpoint in the Pinterest API)");
|
|
2092
|
+
}
|
|
2093
|
+
async analyticsPost(input) {
|
|
2094
|
+
const res = await jsonRequest(this.fetchImpl, `${BASE3}/v5/pins/${encodeURIComponent(input.id)}/analytics`, {
|
|
2095
|
+
headers: this.headers(),
|
|
2096
|
+
query: { metric_types: "IMPRESSION,PIN_CLICK,SAVE,OUTBOUND_CLICK" },
|
|
2097
|
+
errorLabel: "Pinterest"
|
|
2098
|
+
});
|
|
2099
|
+
const summary = res.all?.["DAILY"]?.summary_metrics ?? res.all?.["TOTAL"]?.summary_metrics ?? {};
|
|
2100
|
+
const metrics = {};
|
|
2101
|
+
for (const [k, v] of Object.entries(summary)) {
|
|
2102
|
+
metrics[k] = typeof v === "number" ? v : Number(v) || 0;
|
|
2103
|
+
}
|
|
2104
|
+
return { metrics };
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// src/social/googlebusinessprofile.ts
|
|
2109
|
+
var ACCT_MGMT = "https://mybusinessaccountmanagement.googleapis.com/v1";
|
|
2110
|
+
var V4 = "https://mybusiness.googleapis.com/v4";
|
|
2111
|
+
|
|
2112
|
+
class GoogleBusinessProfileAdapter {
|
|
2113
|
+
accessToken;
|
|
2114
|
+
defaultAccountId;
|
|
2115
|
+
defaultLocationId;
|
|
2116
|
+
fetchImpl;
|
|
2117
|
+
constructor(creds, fetchImpl) {
|
|
2118
|
+
if (!creds || !creds.accessToken) {
|
|
2119
|
+
throw new Error("googlebusinessprofile credentials require `accessToken`");
|
|
2120
|
+
}
|
|
2121
|
+
this.accessToken = creds.accessToken;
|
|
2122
|
+
this.defaultAccountId = creds.accountId;
|
|
2123
|
+
this.defaultLocationId = creds.locationId;
|
|
2124
|
+
this.fetchImpl = resolveFetch(fetchImpl);
|
|
2125
|
+
}
|
|
2126
|
+
static fromCredentials(creds, fetchImpl) {
|
|
2127
|
+
return new GoogleBusinessProfileAdapter(creds, fetchImpl);
|
|
2128
|
+
}
|
|
2129
|
+
headers(extra) {
|
|
2130
|
+
return { Authorization: `Bearer ${this.accessToken}`, ...extra ?? {} };
|
|
2131
|
+
}
|
|
2132
|
+
resolveIds(input) {
|
|
2133
|
+
const accountId = input.accountId ?? this.defaultAccountId;
|
|
2134
|
+
const locationId = input.locationId ?? this.defaultLocationId;
|
|
2135
|
+
if (!accountId)
|
|
2136
|
+
throw new Error("googlebusinessprofile requires `accountId` (in input or credentials)");
|
|
2137
|
+
if (!locationId)
|
|
2138
|
+
throw new Error("googlebusinessprofile requires `locationId` (in input or credentials)");
|
|
2139
|
+
return { accountId, locationId };
|
|
2140
|
+
}
|
|
2141
|
+
async accountMe() {
|
|
2142
|
+
const res = await jsonRequest(this.fetchImpl, `${ACCT_MGMT}/accounts`, { headers: this.headers(), errorLabel: "GoogleBusinessProfile" });
|
|
2143
|
+
const account = res.accounts?.[0];
|
|
2144
|
+
if (!account) {
|
|
2145
|
+
throw new Error("googlebusinessprofile account.me: no accounts found for the authenticated user");
|
|
2146
|
+
}
|
|
2147
|
+
const id = (account.name ?? "").replace(/^accounts\//, "");
|
|
2148
|
+
return {
|
|
2149
|
+
id,
|
|
2150
|
+
username: account.accountName,
|
|
2151
|
+
displayName: account.accountName,
|
|
2152
|
+
url: undefined
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
async postCreate(input) {
|
|
2156
|
+
const { accountId, locationId } = this.resolveIds(input);
|
|
2157
|
+
const parent = `accounts/${accountId}/locations/${locationId}`;
|
|
2158
|
+
const body = {
|
|
2159
|
+
languageCode: "en-US",
|
|
2160
|
+
summary: input.text,
|
|
2161
|
+
topicType: "STANDARD"
|
|
2162
|
+
};
|
|
2163
|
+
if (input.cta) {
|
|
2164
|
+
body.callToAction = { actionType: input.cta.actionType, url: input.cta.url };
|
|
2165
|
+
}
|
|
2166
|
+
if (input.mediaIds && input.mediaIds.length > 0) {
|
|
2167
|
+
body.media = input.mediaIds.map((m) => ({ mediaFormat: "PHOTO", sourceUrl: m }));
|
|
2168
|
+
}
|
|
2169
|
+
const post = await jsonRequest(this.fetchImpl, `${V4}/${parent}/localPosts`, { method: "POST", headers: this.headers({ "Content-Type": "application/json" }), body, errorLabel: "GoogleBusinessProfile" });
|
|
2170
|
+
return { id: post.name, url: post.searchUrl };
|
|
2171
|
+
}
|
|
2172
|
+
async postDelete(input) {
|
|
2173
|
+
await jsonRequest(this.fetchImpl, `${V4}/${input.id}`, {
|
|
2174
|
+
method: "DELETE",
|
|
2175
|
+
headers: this.headers(),
|
|
2176
|
+
errorLabel: "GoogleBusinessProfile"
|
|
2177
|
+
});
|
|
2178
|
+
return { id: input.id, deleted: true };
|
|
2179
|
+
}
|
|
2180
|
+
async mediaUpload(input) {
|
|
2181
|
+
if (!input.sourceUrl) {
|
|
2182
|
+
throw new ConnectorOperationNotSupported("googlebusinessprofile", "media.upload (provide a public `sourceUrl`; raw binary upload uses a separate resumable service)");
|
|
2183
|
+
}
|
|
2184
|
+
const { accountId, locationId } = this.resolveIds(input);
|
|
2185
|
+
const parent = `accounts/${accountId}/locations/${locationId}`;
|
|
2186
|
+
const media = await jsonRequest(this.fetchImpl, `${V4}/${parent}/media`, {
|
|
2187
|
+
method: "POST",
|
|
2188
|
+
headers: this.headers({ "Content-Type": "application/json" }),
|
|
2189
|
+
body: {
|
|
2190
|
+
mediaFormat: "PHOTO",
|
|
2191
|
+
locationAssociation: { category: "ADDITIONAL" },
|
|
2192
|
+
sourceUrl: input.sourceUrl
|
|
2193
|
+
},
|
|
2194
|
+
errorLabel: "GoogleBusinessProfile"
|
|
2195
|
+
});
|
|
2196
|
+
return { mediaId: media.name };
|
|
2197
|
+
}
|
|
2198
|
+
async mentionsList(_input = {}) {
|
|
2199
|
+
throw new ConnectorOperationNotSupported("googlebusinessprofile", "mentions.list (no mentions concept; reviews are a separate surface)");
|
|
2200
|
+
}
|
|
2201
|
+
async analyticsPost(_input) {
|
|
2202
|
+
throw new ConnectorOperationNotSupported("googlebusinessprofile", "analytics.post (per-localPost insights are not exposed; use location-level Performance API)");
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
1470
2206
|
// src/social/index.ts
|
|
1471
|
-
var SUPPORTED_CONNECTORS = [
|
|
2207
|
+
var SUPPORTED_CONNECTORS = [
|
|
2208
|
+
"x",
|
|
2209
|
+
"mastodon",
|
|
2210
|
+
"bluesky",
|
|
2211
|
+
"linkedin",
|
|
2212
|
+
"reddit",
|
|
2213
|
+
"tiktok",
|
|
2214
|
+
"youtube",
|
|
2215
|
+
"pinterest",
|
|
2216
|
+
"googlebusinessprofile"
|
|
2217
|
+
];
|
|
2218
|
+
var ALL_OPS = [
|
|
2219
|
+
"account.me",
|
|
2220
|
+
"post.create",
|
|
2221
|
+
"post.delete",
|
|
2222
|
+
"media.upload",
|
|
2223
|
+
"mentions.list",
|
|
2224
|
+
"analytics.post"
|
|
2225
|
+
];
|
|
1472
2226
|
var SUPPORTED_OPERATIONS = {
|
|
1473
|
-
x: [
|
|
1474
|
-
mastodon: [
|
|
1475
|
-
bluesky: [
|
|
2227
|
+
x: [...ALL_OPS],
|
|
2228
|
+
mastodon: [...ALL_OPS],
|
|
2229
|
+
bluesky: [...ALL_OPS],
|
|
2230
|
+
linkedin: ["account.me", "post.create", "post.delete", "media.upload"],
|
|
2231
|
+
reddit: ["account.me", "post.create", "post.delete", "mentions.list", "analytics.post"],
|
|
2232
|
+
tiktok: ["account.me", "post.create", "media.upload"],
|
|
2233
|
+
youtube: ["account.me", "post.create", "post.delete", "media.upload", "analytics.post"],
|
|
2234
|
+
pinterest: ["account.me", "post.create", "post.delete", "media.upload", "analytics.post"],
|
|
2235
|
+
googlebusinessprofile: ["account.me", "post.create", "post.delete", "media.upload"]
|
|
1476
2236
|
};
|
|
1477
2237
|
function listSocialConnectors() {
|
|
1478
2238
|
return [...SUPPORTED_CONNECTORS];
|
|
@@ -1492,6 +2252,18 @@ function buildAdapter(connector, credentials) {
|
|
|
1492
2252
|
return MastodonAdapter.fromCredentials(credentials);
|
|
1493
2253
|
case "bluesky":
|
|
1494
2254
|
return BlueskyAdapter.fromCredentials(credentials);
|
|
2255
|
+
case "linkedin":
|
|
2256
|
+
return LinkedInAdapter.fromCredentials(credentials);
|
|
2257
|
+
case "reddit":
|
|
2258
|
+
return RedditAdapter.fromCredentials(credentials);
|
|
2259
|
+
case "tiktok":
|
|
2260
|
+
return TikTokAdapter.fromCredentials(credentials);
|
|
2261
|
+
case "youtube":
|
|
2262
|
+
return YouTubeAdapter.fromCredentials(credentials);
|
|
2263
|
+
case "pinterest":
|
|
2264
|
+
return PinterestAdapter.fromCredentials(credentials);
|
|
2265
|
+
case "googlebusinessprofile":
|
|
2266
|
+
return GoogleBusinessProfileAdapter.fromCredentials(credentials);
|
|
1495
2267
|
default:
|
|
1496
2268
|
throw new ConnectorOperationNotSupported(connector, "*");
|
|
1497
2269
|
}
|