@strav/herald 1.0.0-alpha.42
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/README.md +48 -0
- package/package.json +43 -0
- package/src/drivers/gbp/gbp_client.ts +155 -0
- package/src/drivers/gbp/gbp_config.ts +48 -0
- package/src/drivers/gbp/gbp_driver.ts +246 -0
- package/src/drivers/gbp/gbp_provider.ts +35 -0
- package/src/drivers/gbp/index.ts +23 -0
- package/src/drivers/meta/index.ts +21 -0
- package/src/drivers/meta/meta_client.ts +103 -0
- package/src/drivers/meta/meta_config.ts +45 -0
- package/src/drivers/meta/meta_driver.ts +313 -0
- package/src/drivers/meta/meta_provider.ts +45 -0
- package/src/drivers/meta/meta_webhook_ops.ts +227 -0
- package/src/drivers/wordpress/index.ts +24 -0
- package/src/drivers/wordpress/wordpress_client.ts +185 -0
- package/src/drivers/wordpress/wordpress_config.ts +51 -0
- package/src/drivers/wordpress/wordpress_driver.ts +277 -0
- package/src/drivers/wordpress/wordpress_provider.ts +37 -0
- package/src/drivers/wordpress/wordpress_validate.ts +122 -0
- package/src/dto/post_status.ts +14 -0
- package/src/dto/publish_input.ts +38 -0
- package/src/dto/publish_result.ts +23 -0
- package/src/dto/publish_target.ts +15 -0
- package/src/errors.ts +122 -0
- package/src/herald_capabilities.ts +37 -0
- package/src/herald_config.ts +32 -0
- package/src/herald_driver.ts +86 -0
- package/src/herald_manager.ts +169 -0
- package/src/herald_provider.ts +44 -0
- package/src/index.ts +65 -0
- package/src/ledger/apply_publication_migration.ts +53 -0
- package/src/ledger/index.ts +11 -0
- package/src/ledger/publication.ts +30 -0
- package/src/ledger/publication_repository.ts +172 -0
- package/src/ledger/publication_schema.ts +70 -0
- package/src/onboarding/complete_channel_connect.ts +154 -0
- package/src/onboarding/connect_channel_callback.ts +138 -0
- package/src/onboarding/index.ts +26 -0
- package/src/onboarding/types.ts +67 -0
- package/src/tenanted/apply_tenanted_publication_migration.ts +46 -0
- package/src/tenanted/index.ts +18 -0
- package/src/tenanted/tenanted_publication.ts +28 -0
- package/src/tenanted/tenanted_publication_repository.ts +145 -0
- package/src/tenanted/tenanted_publication_schema.ts +35 -0
- package/src/webhook/apply_herald_webhook_event_migration.ts +42 -0
- package/src/webhook/herald_event.ts +139 -0
- package/src/webhook/herald_webhook.ts +149 -0
- package/src/webhook/herald_webhook_event.ts +25 -0
- package/src/webhook/herald_webhook_event_repository.ts +65 -0
- package/src/webhook/herald_webhook_event_schema.ts +37 -0
- package/src/webhook/herald_webhook_registry.ts +65 -0
- package/src/webhook/index.ts +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @strav/herald
|
|
2
|
+
|
|
3
|
+
Provider-agnostic **micro-publishing** for Strav apps.
|
|
4
|
+
|
|
5
|
+
`@strav/herald` is the publish-side counterpart to `@strav/instant` (chat) and
|
|
6
|
+
`@strav/notification` (per-user delivery). It lets an app publish a post to one
|
|
7
|
+
or more external platforms (Google Business Profile, Facebook Page, Instagram,
|
|
8
|
+
WordPress, …) behind a single normalized API, then receive engagement events
|
|
9
|
+
(reviews, comments, reactions) back through a typed webhook registry.
|
|
10
|
+
|
|
11
|
+
> **Status:** alpha — scaffold (skeleton + driver contract). Drivers ship in
|
|
12
|
+
> follow-up slices: WordPress first, then Meta, then GBP.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bun add @strav/herald
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Minimal example
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { HeraldManager } from '@strav/herald'
|
|
24
|
+
|
|
25
|
+
const herald = app.resolve(HeraldManager)
|
|
26
|
+
|
|
27
|
+
const result = await herald.publish(
|
|
28
|
+
{ provider: 'wordpress', accountId: 'wp-tenant-42' },
|
|
29
|
+
{
|
|
30
|
+
text: "We just opened our garden patio — come by for the sunset!",
|
|
31
|
+
attachments: [{ type: 'image', url: 'https://cdn.example.com/patio.jpg' }],
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
console.log(result.providerPostId, result.url)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Drivers (planned subpaths)
|
|
39
|
+
|
|
40
|
+
| Subpath | Status | Platform |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `@strav/herald/wordpress` | planned | WordPress REST |
|
|
43
|
+
| `@strav/herald/meta` | planned | Facebook Page + Instagram (Graph API) |
|
|
44
|
+
| `@strav/herald/gbp` | planned | Google Business Profile |
|
|
45
|
+
| `@strav/herald/x` | future | X (Twitter) |
|
|
46
|
+
| `@strav/herald/tiktok` | future | TikTok |
|
|
47
|
+
|
|
48
|
+
See `docs/herald/` for setup, OAuth scopes, and webhook wiring.
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/herald",
|
|
3
|
+
"version": "1.0.0-alpha.42",
|
|
4
|
+
"description": "Strav micro-publishing — provider-agnostic abstraction for publishing posts to social platforms (Google Business Profile, Meta/Facebook + Instagram, WordPress). Normalized PublishInput + capability-flagged drivers + inbound webhook registry for engagement events (reviews, comments, reactions). Drivers ship as subpath imports (`@strav/herald/gbp`, `@strav/herald/meta`, `@strav/herald/wordpress`). X (Twitter), TikTok, LinkedIn, Threads come in later releases.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./onboarding": "./src/onboarding/index.ts",
|
|
11
|
+
"./tenanted": "./src/tenanted/index.ts",
|
|
12
|
+
"./gbp": "./src/drivers/gbp/index.ts",
|
|
13
|
+
"./meta": "./src/drivers/meta/index.ts",
|
|
14
|
+
"./wordpress": "./src/drivers/wordpress/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"bun": ">=1.3.14"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@strav/database": "1.0.0-alpha.42",
|
|
28
|
+
"@strav/http": "1.0.0-alpha.42",
|
|
29
|
+
"@strav/kernel": "1.0.0-alpha.42"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@strav/social": "1.0.0-alpha.42"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@strav/social": "1.0.0-alpha.42",
|
|
36
|
+
"@types/bun": ">=1.3.14"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"@strav/social": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GbpClient` — thin wrapper over `mybusiness.googleapis.com/v4/...`.
|
|
3
|
+
*
|
|
4
|
+
* The Posts surface lives at
|
|
5
|
+
* `/v4/accounts/{aid}/locations/{lid}/localPosts[/{postId}]` —
|
|
6
|
+
* callers pass the location resource path (`accountId` from
|
|
7
|
+
* `PublishTarget`) and the client appends the relative leaf.
|
|
8
|
+
*
|
|
9
|
+
* Auth is OAuth2 bearer; the token comes from the driver's per-call
|
|
10
|
+
* `credentialsFor()` resolution. The client doesn't refresh — that's
|
|
11
|
+
* the app's job inside the resolver.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { HeraldProviderError } from '../../errors.ts'
|
|
15
|
+
import type { GbpTopicType } from './gbp_config.ts'
|
|
16
|
+
|
|
17
|
+
export interface GbpMedia {
|
|
18
|
+
mediaFormat: 'PHOTO' | 'VIDEO'
|
|
19
|
+
sourceUrl: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GbpCallToAction {
|
|
23
|
+
actionType:
|
|
24
|
+
| 'BOOK'
|
|
25
|
+
| 'ORDER'
|
|
26
|
+
| 'SHOP'
|
|
27
|
+
| 'LEARN_MORE'
|
|
28
|
+
| 'SIGN_UP'
|
|
29
|
+
| 'CALL'
|
|
30
|
+
| 'GET_OFFER'
|
|
31
|
+
url?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface GbpLocalPostBody {
|
|
35
|
+
languageCode: string
|
|
36
|
+
summary: string
|
|
37
|
+
topicType: GbpTopicType
|
|
38
|
+
media?: GbpMedia[]
|
|
39
|
+
callToAction?: GbpCallToAction
|
|
40
|
+
[key: string]: unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface GbpLocalPost {
|
|
44
|
+
name: string
|
|
45
|
+
languageCode: string
|
|
46
|
+
summary: string
|
|
47
|
+
topicType: GbpTopicType
|
|
48
|
+
state: 'LOCAL_POST_STATE_UNSPECIFIED' | 'REJECTED' | 'LIVE' | 'PROCESSING'
|
|
49
|
+
searchUrl?: string
|
|
50
|
+
createTime?: string
|
|
51
|
+
updateTime?: string
|
|
52
|
+
[key: string]: unknown
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class GbpClient {
|
|
56
|
+
private readonly fetcher: typeof fetch
|
|
57
|
+
private readonly accessToken: string
|
|
58
|
+
private readonly baseUrl = 'https://mybusiness.googleapis.com/v4'
|
|
59
|
+
|
|
60
|
+
constructor(input: { accessToken: string; fetch?: typeof fetch }) {
|
|
61
|
+
this.accessToken = input.accessToken
|
|
62
|
+
this.fetcher = input.fetch ?? fetch
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async createLocalPost(location: string, body: GbpLocalPostBody): Promise<GbpLocalPost> {
|
|
66
|
+
return this.json<GbpLocalPost>('POST', `/${trimSlash(location)}/localPosts`, body)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async updateLocalPost(
|
|
70
|
+
postName: string,
|
|
71
|
+
body: Partial<GbpLocalPostBody>,
|
|
72
|
+
updateMask: readonly string[],
|
|
73
|
+
): Promise<GbpLocalPost> {
|
|
74
|
+
const mask = encodeURIComponent(updateMask.join(','))
|
|
75
|
+
return this.json<GbpLocalPost>(
|
|
76
|
+
'PATCH',
|
|
77
|
+
`/${trimSlash(postName)}?updateMask=${mask}`,
|
|
78
|
+
body,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async deleteLocalPost(postName: string): Promise<void> {
|
|
83
|
+
await this.bare('DELETE', `/${trimSlash(postName)}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async getLocalPost(postName: string): Promise<GbpLocalPost> {
|
|
87
|
+
return this.json<GbpLocalPost>('GET', `/${trimSlash(postName)}`, undefined)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async json<T>(
|
|
91
|
+
method: 'GET' | 'POST' | 'PATCH',
|
|
92
|
+
path: string,
|
|
93
|
+
body: unknown,
|
|
94
|
+
): Promise<T> {
|
|
95
|
+
const res = await this.fetcher(`${this.baseUrl}${path}`, {
|
|
96
|
+
method,
|
|
97
|
+
headers: {
|
|
98
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
|
+
Accept: 'application/json',
|
|
101
|
+
},
|
|
102
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
103
|
+
})
|
|
104
|
+
const text = await res.text()
|
|
105
|
+
let parsed: unknown
|
|
106
|
+
try {
|
|
107
|
+
parsed = text ? JSON.parse(text) : {}
|
|
108
|
+
} catch {
|
|
109
|
+
parsed = { raw: text }
|
|
110
|
+
}
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
throw new HeraldProviderError(
|
|
113
|
+
`GBP: ${method} ${path} failed (${res.status}).`,
|
|
114
|
+
{
|
|
115
|
+
provider: 'gbp',
|
|
116
|
+
operation: `${method} ${path}`,
|
|
117
|
+
status: 502,
|
|
118
|
+
context: { responseBody: parsed },
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
return parsed as T
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async bare(method: 'DELETE', path: string): Promise<void> {
|
|
126
|
+
const res = await this.fetcher(`${this.baseUrl}${path}`, {
|
|
127
|
+
method,
|
|
128
|
+
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
129
|
+
})
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
const text = await safeText(res)
|
|
132
|
+
throw new HeraldProviderError(
|
|
133
|
+
`GBP: ${method} ${path} failed (${res.status}).`,
|
|
134
|
+
{
|
|
135
|
+
provider: 'gbp',
|
|
136
|
+
operation: `${method} ${path}`,
|
|
137
|
+
status: 502,
|
|
138
|
+
context: { responseBody: text },
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function trimSlash(s: string): string {
|
|
146
|
+
return s.replace(/^\/+/, '').replace(/\/+$/, '')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function safeText(res: Response): Promise<string> {
|
|
150
|
+
try {
|
|
151
|
+
return await res.text()
|
|
152
|
+
} catch {
|
|
153
|
+
return ''
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GbpProviderConfig` — `config.herald.providers['...']` shape for the
|
|
3
|
+
* Google Business Profile driver.
|
|
4
|
+
*
|
|
5
|
+
* GBP posts hang off a **location** (a single physical place a SME
|
|
6
|
+
* operates). One Google account often manages many locations, and a
|
|
7
|
+
* single SaaS app may manage thousands. The driver addresses
|
|
8
|
+
* locations via `PublishTarget.accountId` carrying the full GBP
|
|
9
|
+
* resource path — `accounts/{accountId}/locations/{locationId}` — so
|
|
10
|
+
* the resolver only needs to look up tokens by that path.
|
|
11
|
+
*
|
|
12
|
+
* Auth model:
|
|
13
|
+
*
|
|
14
|
+
* - **Per-account `accessToken`** — short-lived (~1h). The
|
|
15
|
+
* `credentialsFor(accountId)` resolver is the app's hook to
|
|
16
|
+
* refresh from a stored `refresh_token` before returning. The
|
|
17
|
+
* driver does NOT refresh on its own to keep `@strav/herald`
|
|
18
|
+
* dependency-free; apps using `@strav/social`'s Google driver
|
|
19
|
+
* call `socialDriver.refresh(refreshToken)` inside their
|
|
20
|
+
* resolver and persist the rotated access token.
|
|
21
|
+
*
|
|
22
|
+
* - **Default `languageCode`** — GBP requires every post to carry
|
|
23
|
+
* a BCP-47 language code. Apps publishing in one language set it
|
|
24
|
+
* once on config; multi-language apps override per call via
|
|
25
|
+
* `raw.languageCode`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type GbpTopicType = 'STANDARD' | 'EVENT' | 'OFFER' | 'ALERT'
|
|
29
|
+
|
|
30
|
+
export interface GbpCredentials {
|
|
31
|
+
/** Short-lived OAuth2 access token with `business.manage` scope. */
|
|
32
|
+
accessToken: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type GbpCredentialsResolver = (
|
|
36
|
+
accountId: string,
|
|
37
|
+
) => Promise<GbpCredentials | null> | GbpCredentials | null
|
|
38
|
+
|
|
39
|
+
export interface GbpProviderConfig {
|
|
40
|
+
driver: 'gbp'
|
|
41
|
+
credentialsFor: GbpCredentialsResolver
|
|
42
|
+
/** BCP-47 default language stamped on every post unless `raw.languageCode` overrides. */
|
|
43
|
+
defaultLanguageCode: string
|
|
44
|
+
/** Default topic type for `STANDARD` posts. Apps reach for `raw.topicType` to set `EVENT` / `OFFER` / `ALERT`. */
|
|
45
|
+
defaultTopicType?: GbpTopicType
|
|
46
|
+
/** Optional `fetch` override for tests. */
|
|
47
|
+
fetch?: typeof fetch
|
|
48
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GbpDriver` — `HeraldDriver` implementation for Google Business
|
|
3
|
+
* Profile localPosts.
|
|
4
|
+
*
|
|
5
|
+
* Synchronous publish: GBP returns the created `LocalPost` resource
|
|
6
|
+
* with its `name` + `state` in the same response. State semantics:
|
|
7
|
+
*
|
|
8
|
+
* - `LIVE` → `published`
|
|
9
|
+
* - `PROCESSING` → `pending` (moderation pending; very rare for
|
|
10
|
+
* well-formed posts)
|
|
11
|
+
* - `REJECTED` → `failed`
|
|
12
|
+
*
|
|
13
|
+
* `PublishTarget.accountId` carries the **location resource path**:
|
|
14
|
+
* `accounts/{accountId}/locations/{locationId}`. The driver appends
|
|
15
|
+
* `/localPosts[/{id}]` to address posts.
|
|
16
|
+
*
|
|
17
|
+
* Content mapping (LCD `PublishInput` → GBP):
|
|
18
|
+
*
|
|
19
|
+
* - `text` → `summary` (max 1500 chars; the driver lets
|
|
20
|
+
* GBP enforce — it returns a 400 with a clear
|
|
21
|
+
* message).
|
|
22
|
+
* - first `image` → `media: [{ mediaFormat: 'PHOTO', sourceUrl }]`.
|
|
23
|
+
* Additional images are ignored — apps wanting
|
|
24
|
+
* multi-image posts reach for `raw.media`.
|
|
25
|
+
* - first `link` → `callToAction: { actionType: 'LEARN_MORE',
|
|
26
|
+
* url }`. Apps wanting `BOOK` / `ORDER` /
|
|
27
|
+
* `SHOP` etc. set `raw.callToAction.actionType`.
|
|
28
|
+
* - `hashtags` → NOT mapped — GBP doesn't surface hashtags.
|
|
29
|
+
* Apps that want them in-line append manually
|
|
30
|
+
* via `text`.
|
|
31
|
+
* - `scheduledFor` → throws `ProviderUnsupportedError` (GBP API
|
|
32
|
+
* has no scheduling).
|
|
33
|
+
*
|
|
34
|
+
* Updates: GBP requires an explicit `updateMask` query param. The
|
|
35
|
+
* driver derives the mask from the keys present in the `PublishInput`
|
|
36
|
+
* (`summary` always; `media` when an image attachment is present;
|
|
37
|
+
* `callToAction` when a link is present). Apps that want narrower
|
|
38
|
+
* masks reach for `raw` to suppress fields.
|
|
39
|
+
*
|
|
40
|
+
* Webhooks: GBP doesn't ship HTTP webhooks — review notifications
|
|
41
|
+
* arrive via Google Cloud Pub/Sub subscription. `webhook.*` throws
|
|
42
|
+
* `ProviderUnsupportedError`. Apps that want review events wire
|
|
43
|
+
* their own Pub/Sub listener and call into the registry directly.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import type { PostStatus } from '../../dto/post_status.ts'
|
|
47
|
+
import type { PublishInput } from '../../dto/publish_input.ts'
|
|
48
|
+
import type { PublishResult } from '../../dto/publish_result.ts'
|
|
49
|
+
import type { PublishTarget } from '../../dto/publish_target.ts'
|
|
50
|
+
import { HeraldProviderError, ProviderUnsupportedError } from '../../errors.ts'
|
|
51
|
+
import type { HeraldCapability } from '../../herald_capabilities.ts'
|
|
52
|
+
import type { HeraldDriver, WebhookOps } from '../../herald_driver.ts'
|
|
53
|
+
import type { HeraldWebhookEvent } from '../../webhook/herald_event.ts'
|
|
54
|
+
import {
|
|
55
|
+
GbpClient,
|
|
56
|
+
type GbpCallToAction,
|
|
57
|
+
type GbpLocalPost,
|
|
58
|
+
type GbpLocalPostBody,
|
|
59
|
+
type GbpMedia,
|
|
60
|
+
} from './gbp_client.ts'
|
|
61
|
+
import type {
|
|
62
|
+
GbpCredentialsResolver,
|
|
63
|
+
GbpProviderConfig,
|
|
64
|
+
GbpTopicType,
|
|
65
|
+
} from './gbp_config.ts'
|
|
66
|
+
|
|
67
|
+
export interface GbpDriverOptions {
|
|
68
|
+
instanceName: string
|
|
69
|
+
credentialsFor: GbpCredentialsResolver
|
|
70
|
+
defaultLanguageCode: string
|
|
71
|
+
defaultTopicType?: GbpTopicType
|
|
72
|
+
fetch?: typeof fetch
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const CAPABILITIES: ReadonlySet<HeraldCapability> = new Set<HeraldCapability>([
|
|
76
|
+
'post.create',
|
|
77
|
+
'post.update',
|
|
78
|
+
'post.delete',
|
|
79
|
+
'post.get',
|
|
80
|
+
'media.image',
|
|
81
|
+
'media.link',
|
|
82
|
+
])
|
|
83
|
+
|
|
84
|
+
const UNSUPPORTED_WEBHOOK: WebhookOps = {
|
|
85
|
+
verifySignature(): boolean {
|
|
86
|
+
throw new ProviderUnsupportedError('gbp', 'webhook.verifySignature', {
|
|
87
|
+
reason: 'GBP delivers review notifications via Google Cloud Pub/Sub, not HTTP webhooks.',
|
|
88
|
+
})
|
|
89
|
+
},
|
|
90
|
+
parse(): HeraldWebhookEvent[] {
|
|
91
|
+
throw new ProviderUnsupportedError('gbp', 'webhook.parse', {
|
|
92
|
+
reason: 'GBP delivers review notifications via Google Cloud Pub/Sub, not HTTP webhooks.',
|
|
93
|
+
})
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class GbpDriver implements HeraldDriver {
|
|
98
|
+
readonly name = 'gbp'
|
|
99
|
+
readonly instanceName: string
|
|
100
|
+
readonly capabilities = CAPABILITIES
|
|
101
|
+
readonly webhook: WebhookOps = UNSUPPORTED_WEBHOOK
|
|
102
|
+
|
|
103
|
+
private readonly resolver: GbpCredentialsResolver
|
|
104
|
+
private readonly defaultLanguageCode: string
|
|
105
|
+
private readonly defaultTopicType: GbpTopicType
|
|
106
|
+
private readonly fetcher: typeof fetch
|
|
107
|
+
|
|
108
|
+
constructor(options: GbpDriverOptions) {
|
|
109
|
+
this.instanceName = options.instanceName
|
|
110
|
+
this.resolver = options.credentialsFor
|
|
111
|
+
this.defaultLanguageCode = options.defaultLanguageCode
|
|
112
|
+
this.defaultTopicType = options.defaultTopicType ?? 'STANDARD'
|
|
113
|
+
this.fetcher = options.fetch ?? fetch
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static fromConfig(instanceName: string, config: GbpProviderConfig): GbpDriver {
|
|
117
|
+
return new GbpDriver({
|
|
118
|
+
instanceName,
|
|
119
|
+
credentialsFor: config.credentialsFor,
|
|
120
|
+
defaultLanguageCode: config.defaultLanguageCode,
|
|
121
|
+
...(config.defaultTopicType ? { defaultTopicType: config.defaultTopicType } : {}),
|
|
122
|
+
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async publish(target: PublishTarget, input: PublishInput): Promise<PublishResult> {
|
|
127
|
+
if (input.scheduledFor) {
|
|
128
|
+
throw new ProviderUnsupportedError('gbp', 'post.schedule', {
|
|
129
|
+
reason: 'GBP Posts API does not support scheduled publishing.',
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
const client = await this.clientFor(target.accountId)
|
|
133
|
+
const body = this.buildBody(input)
|
|
134
|
+
const created = await client.createLocalPost(target.accountId, body)
|
|
135
|
+
return this.toResult(created)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async update(
|
|
139
|
+
target: PublishTarget,
|
|
140
|
+
providerPostId: string,
|
|
141
|
+
input: PublishInput,
|
|
142
|
+
): Promise<PublishResult> {
|
|
143
|
+
const client = await this.clientFor(target.accountId)
|
|
144
|
+
const body = this.buildBody(input)
|
|
145
|
+
const mask = this.updateMask(input)
|
|
146
|
+
const updated = await client.updateLocalPost(providerPostId, body, mask)
|
|
147
|
+
return this.toResult(updated)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async delete(_target: PublishTarget, providerPostId: string): Promise<void> {
|
|
151
|
+
const client = await this.clientFor(_target.accountId)
|
|
152
|
+
await client.deleteLocalPost(providerPostId)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async get(target: PublishTarget, providerPostId: string): Promise<PostStatus> {
|
|
156
|
+
const client = await this.clientFor(target.accountId)
|
|
157
|
+
const post = await client.getLocalPost(providerPostId)
|
|
158
|
+
return mapState(post.state)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── internals ───────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
private async clientFor(accountId: string): Promise<GbpClient> {
|
|
164
|
+
const creds = await this.resolver(accountId)
|
|
165
|
+
if (!creds) {
|
|
166
|
+
throw new HeraldProviderError(
|
|
167
|
+
`GBP driver: no credentials registered for location "${accountId}".`,
|
|
168
|
+
{ provider: 'gbp', operation: 'clientFor', status: 500 },
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
return new GbpClient({ accessToken: creds.accessToken, fetch: this.fetcher })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private buildBody(input: PublishInput): GbpLocalPostBody {
|
|
175
|
+
const raw = (input.raw as Partial<GbpLocalPostBody> | undefined) ?? {}
|
|
176
|
+
|
|
177
|
+
const summary = raw.summary ?? input.text ?? ''
|
|
178
|
+
if (!summary) {
|
|
179
|
+
throw new HeraldProviderError(
|
|
180
|
+
'GBP driver: post requires a non-empty `text` or `raw.summary`.',
|
|
181
|
+
{ provider: 'gbp', operation: 'buildBody', status: 400 },
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const media = raw.media ?? deriveMedia(input)
|
|
186
|
+
const callToAction = raw.callToAction ?? deriveCta(input)
|
|
187
|
+
|
|
188
|
+
const body: GbpLocalPostBody = {
|
|
189
|
+
languageCode: raw.languageCode ?? this.defaultLanguageCode,
|
|
190
|
+
summary,
|
|
191
|
+
topicType: raw.topicType ?? this.defaultTopicType,
|
|
192
|
+
...(media && media.length > 0 ? { media } : {}),
|
|
193
|
+
...(callToAction ? { callToAction } : {}),
|
|
194
|
+
...raw,
|
|
195
|
+
}
|
|
196
|
+
return body
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private updateMask(input: PublishInput): string[] {
|
|
200
|
+
const raw = (input.raw as Partial<GbpLocalPostBody> | undefined) ?? {}
|
|
201
|
+
const mask = new Set<string>(['summary'])
|
|
202
|
+
if (raw.media || (input.attachments ?? []).some((a) => a.type === 'image')) {
|
|
203
|
+
mask.add('media')
|
|
204
|
+
}
|
|
205
|
+
if (raw.callToAction || (input.attachments ?? []).some((a) => a.type === 'link')) {
|
|
206
|
+
mask.add('callToAction')
|
|
207
|
+
}
|
|
208
|
+
if (raw.topicType) mask.add('topicType')
|
|
209
|
+
if (raw.languageCode) mask.add('languageCode')
|
|
210
|
+
return [...mask]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private toResult(post: GbpLocalPost): PublishResult {
|
|
214
|
+
return {
|
|
215
|
+
provider: 'gbp',
|
|
216
|
+
providerPostId: post.name,
|
|
217
|
+
...(post.searchUrl ? { url: post.searchUrl } : {}),
|
|
218
|
+
status: mapState(post.state),
|
|
219
|
+
raw: post,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function deriveMedia(input: PublishInput): GbpMedia[] | undefined {
|
|
225
|
+
const firstImage = (input.attachments ?? []).find((a) => a.type === 'image')
|
|
226
|
+
if (!firstImage) return undefined
|
|
227
|
+
return [{ mediaFormat: 'PHOTO', sourceUrl: firstImage.url }]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function deriveCta(input: PublishInput): GbpCallToAction | undefined {
|
|
231
|
+
const firstLink = (input.attachments ?? []).find((a) => a.type === 'link')
|
|
232
|
+
if (!firstLink) return undefined
|
|
233
|
+
return { actionType: 'LEARN_MORE', url: firstLink.url }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function mapState(state: GbpLocalPost['state']): PostStatus {
|
|
237
|
+
switch (state) {
|
|
238
|
+
case 'LIVE':
|
|
239
|
+
return 'published'
|
|
240
|
+
case 'PROCESSING':
|
|
241
|
+
case 'LOCAL_POST_STATE_UNSPECIFIED':
|
|
242
|
+
return 'pending'
|
|
243
|
+
case 'REJECTED':
|
|
244
|
+
return 'failed'
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GbpHeraldProvider` — `ServiceProvider` that registers the GBP
|
|
3
|
+
* driver factory on the `HeraldManager`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { type Application, ServiceProvider } from '@strav/kernel'
|
|
7
|
+
import { HeraldConfigError } from '../../errors.ts'
|
|
8
|
+
import { HeraldManager } from '../../herald_manager.ts'
|
|
9
|
+
import type { GbpProviderConfig } from './gbp_config.ts'
|
|
10
|
+
import { GbpDriver } from './gbp_driver.ts'
|
|
11
|
+
|
|
12
|
+
export class GbpHeraldProvider extends ServiceProvider {
|
|
13
|
+
override readonly name = 'herald-gbp'
|
|
14
|
+
override readonly dependencies = ['herald']
|
|
15
|
+
|
|
16
|
+
override register(app: Application): void {
|
|
17
|
+
const manager = app.resolve(HeraldManager)
|
|
18
|
+
manager.extend('gbp', ({ instanceName, config }) => {
|
|
19
|
+
const cfg = config as unknown as GbpProviderConfig
|
|
20
|
+
if (!cfg.credentialsFor) {
|
|
21
|
+
throw new HeraldConfigError(
|
|
22
|
+
`GbpHeraldProvider: provider "${instanceName}" needs a \`credentialsFor\` resolver.`,
|
|
23
|
+
{ context: { instanceName } },
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
if (!cfg.defaultLanguageCode) {
|
|
27
|
+
throw new HeraldConfigError(
|
|
28
|
+
`GbpHeraldProvider: provider "${instanceName}" needs a \`defaultLanguageCode\` (BCP-47, e.g. "en" or "th").`,
|
|
29
|
+
{ context: { instanceName } },
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
return GbpDriver.fromConfig(instanceName, cfg)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Public API of `@strav/herald/gbp`.
|
|
2
|
+
//
|
|
3
|
+
// Google Business Profile driver — `mybusiness.googleapis.com/v4`
|
|
4
|
+
// localPosts. STANDARD posts in v1 (EVENT / OFFER / ALERT via
|
|
5
|
+
// `raw.topicType`). Per-account OAuth2 access token (apps refresh
|
|
6
|
+
// inside their resolver). No scheduling and no HTTP webhooks (GBP
|
|
7
|
+
// uses Pub/Sub for review notifications).
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
GbpClient,
|
|
11
|
+
type GbpCallToAction,
|
|
12
|
+
type GbpLocalPost,
|
|
13
|
+
type GbpLocalPostBody,
|
|
14
|
+
type GbpMedia,
|
|
15
|
+
} from './gbp_client.ts'
|
|
16
|
+
export type {
|
|
17
|
+
GbpCredentials,
|
|
18
|
+
GbpCredentialsResolver,
|
|
19
|
+
GbpProviderConfig,
|
|
20
|
+
GbpTopicType,
|
|
21
|
+
} from './gbp_config.ts'
|
|
22
|
+
export { GbpDriver, type GbpDriverOptions } from './gbp_driver.ts'
|
|
23
|
+
export { GbpHeraldProvider } from './gbp_provider.ts'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Public API of `@strav/herald/meta`.
|
|
2
|
+
//
|
|
3
|
+
// Meta Graph driver for Facebook Pages + Instagram Business. One
|
|
4
|
+
// instance per platform (`platform: 'facebook' | 'instagram'` on
|
|
5
|
+
// config). Auth: per-app `appSecret` + per-account `accessToken`
|
|
6
|
+
// resolver. Webhooks: HMAC-SHA256 verification + normalization of
|
|
7
|
+
// page-feed comments, page ratings, IG comments.
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
MetaGraphClient,
|
|
11
|
+
type GraphResponse,
|
|
12
|
+
} from './meta_client.ts'
|
|
13
|
+
export type {
|
|
14
|
+
MetaCredentials,
|
|
15
|
+
MetaCredentialsResolver,
|
|
16
|
+
MetaPlatform,
|
|
17
|
+
MetaProviderConfig,
|
|
18
|
+
} from './meta_config.ts'
|
|
19
|
+
export { MetaDriver, type MetaDriverOptions } from './meta_driver.ts'
|
|
20
|
+
export { MetaHeraldProvider } from './meta_provider.ts'
|
|
21
|
+
export { MetaWebhookOps, type MetaWebhookOpsOptions } from './meta_webhook_ops.ts'
|