@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.
Files changed (52) hide show
  1. package/README.md +48 -0
  2. package/package.json +43 -0
  3. package/src/drivers/gbp/gbp_client.ts +155 -0
  4. package/src/drivers/gbp/gbp_config.ts +48 -0
  5. package/src/drivers/gbp/gbp_driver.ts +246 -0
  6. package/src/drivers/gbp/gbp_provider.ts +35 -0
  7. package/src/drivers/gbp/index.ts +23 -0
  8. package/src/drivers/meta/index.ts +21 -0
  9. package/src/drivers/meta/meta_client.ts +103 -0
  10. package/src/drivers/meta/meta_config.ts +45 -0
  11. package/src/drivers/meta/meta_driver.ts +313 -0
  12. package/src/drivers/meta/meta_provider.ts +45 -0
  13. package/src/drivers/meta/meta_webhook_ops.ts +227 -0
  14. package/src/drivers/wordpress/index.ts +24 -0
  15. package/src/drivers/wordpress/wordpress_client.ts +185 -0
  16. package/src/drivers/wordpress/wordpress_config.ts +51 -0
  17. package/src/drivers/wordpress/wordpress_driver.ts +277 -0
  18. package/src/drivers/wordpress/wordpress_provider.ts +37 -0
  19. package/src/drivers/wordpress/wordpress_validate.ts +122 -0
  20. package/src/dto/post_status.ts +14 -0
  21. package/src/dto/publish_input.ts +38 -0
  22. package/src/dto/publish_result.ts +23 -0
  23. package/src/dto/publish_target.ts +15 -0
  24. package/src/errors.ts +122 -0
  25. package/src/herald_capabilities.ts +37 -0
  26. package/src/herald_config.ts +32 -0
  27. package/src/herald_driver.ts +86 -0
  28. package/src/herald_manager.ts +169 -0
  29. package/src/herald_provider.ts +44 -0
  30. package/src/index.ts +65 -0
  31. package/src/ledger/apply_publication_migration.ts +53 -0
  32. package/src/ledger/index.ts +11 -0
  33. package/src/ledger/publication.ts +30 -0
  34. package/src/ledger/publication_repository.ts +172 -0
  35. package/src/ledger/publication_schema.ts +70 -0
  36. package/src/onboarding/complete_channel_connect.ts +154 -0
  37. package/src/onboarding/connect_channel_callback.ts +138 -0
  38. package/src/onboarding/index.ts +26 -0
  39. package/src/onboarding/types.ts +67 -0
  40. package/src/tenanted/apply_tenanted_publication_migration.ts +46 -0
  41. package/src/tenanted/index.ts +18 -0
  42. package/src/tenanted/tenanted_publication.ts +28 -0
  43. package/src/tenanted/tenanted_publication_repository.ts +145 -0
  44. package/src/tenanted/tenanted_publication_schema.ts +35 -0
  45. package/src/webhook/apply_herald_webhook_event_migration.ts +42 -0
  46. package/src/webhook/herald_event.ts +139 -0
  47. package/src/webhook/herald_webhook.ts +149 -0
  48. package/src/webhook/herald_webhook_event.ts +25 -0
  49. package/src/webhook/herald_webhook_event_repository.ts +65 -0
  50. package/src/webhook/herald_webhook_event_schema.ts +37 -0
  51. package/src/webhook/herald_webhook_registry.ts +65 -0
  52. 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'