@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MetaGraphClient` — thin wrapper over `graph.facebook.com`.
|
|
3
|
+
*
|
|
4
|
+
* Adds `access_token` + (when an `appSecret` is provided) the
|
|
5
|
+
* `appsecret_proof` parameter to every request — Meta strongly
|
|
6
|
+
* recommends it for hardened apps and it's mandatory when "Require
|
|
7
|
+
* App Secret" is on.
|
|
8
|
+
*
|
|
9
|
+
* Errors come back as `{ error: { message, code, type, ... } }`;
|
|
10
|
+
* the client maps them to `HeraldProviderError` with the original
|
|
11
|
+
* payload on `context.responseBody`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHmac } from 'node:crypto'
|
|
15
|
+
import { HeraldProviderError } from '../../errors.ts'
|
|
16
|
+
|
|
17
|
+
export interface GraphResponse {
|
|
18
|
+
id?: string
|
|
19
|
+
post_id?: string
|
|
20
|
+
permalink_url?: string
|
|
21
|
+
permalink?: string
|
|
22
|
+
[key: string]: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class MetaGraphClient {
|
|
26
|
+
private readonly baseUrl: string
|
|
27
|
+
private readonly accessToken: string
|
|
28
|
+
private readonly appSecret: string
|
|
29
|
+
private readonly fetcher: typeof fetch
|
|
30
|
+
|
|
31
|
+
constructor(input: {
|
|
32
|
+
accessToken: string
|
|
33
|
+
appSecret: string
|
|
34
|
+
apiVersion?: string
|
|
35
|
+
fetch?: typeof fetch
|
|
36
|
+
}) {
|
|
37
|
+
this.accessToken = input.accessToken
|
|
38
|
+
this.appSecret = input.appSecret
|
|
39
|
+
this.baseUrl = `https://graph.facebook.com/${input.apiVersion ?? 'v21.0'}`
|
|
40
|
+
this.fetcher = input.fetch ?? fetch
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Hex-encoded HMAC-SHA256(appSecret, accessToken) — required for hardened Graph calls. */
|
|
44
|
+
appsecretProof(): string {
|
|
45
|
+
return createHmac('sha256', this.appSecret).update(this.accessToken).digest('hex')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async get<T = GraphResponse>(path: string, query: Record<string, unknown> = {}): Promise<T> {
|
|
49
|
+
return this.request<T>('GET', path, query, undefined)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async post<T = GraphResponse>(
|
|
53
|
+
path: string,
|
|
54
|
+
body: Record<string, unknown>,
|
|
55
|
+
): Promise<T> {
|
|
56
|
+
return this.request<T>('POST', path, {}, body)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async delete<T = GraphResponse>(path: string): Promise<T> {
|
|
60
|
+
return this.request<T>('DELETE', path, {}, undefined)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async request<T>(
|
|
64
|
+
method: 'GET' | 'POST' | 'DELETE',
|
|
65
|
+
path: string,
|
|
66
|
+
query: Record<string, unknown>,
|
|
67
|
+
body: Record<string, unknown> | undefined,
|
|
68
|
+
): Promise<T> {
|
|
69
|
+
const url = new URL(`${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`)
|
|
70
|
+
url.searchParams.set('access_token', this.accessToken)
|
|
71
|
+
url.searchParams.set('appsecret_proof', this.appsecretProof())
|
|
72
|
+
for (const [k, v] of Object.entries(query)) {
|
|
73
|
+
if (v !== undefined && v !== null) url.searchParams.set(k, String(v))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const init: RequestInit = { method }
|
|
77
|
+
if (body !== undefined) {
|
|
78
|
+
init.headers = { 'Content-Type': 'application/json' }
|
|
79
|
+
init.body = JSON.stringify(body)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const res = await this.fetcher(url.toString(), init)
|
|
83
|
+
const text = await res.text()
|
|
84
|
+
let parsed: unknown
|
|
85
|
+
try {
|
|
86
|
+
parsed = text ? JSON.parse(text) : {}
|
|
87
|
+
} catch {
|
|
88
|
+
parsed = { raw: text }
|
|
89
|
+
}
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
throw new HeraldProviderError(
|
|
92
|
+
`Meta Graph: ${method} ${path} failed (${res.status}).`,
|
|
93
|
+
{
|
|
94
|
+
provider: 'meta',
|
|
95
|
+
operation: `${method} ${path}`,
|
|
96
|
+
status: 502,
|
|
97
|
+
context: { responseBody: parsed },
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return parsed as T
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MetaProviderConfig` — `config.herald.providers['...']` shape for
|
|
3
|
+
* the Meta Graph driver. One instance serves one platform — apps
|
|
4
|
+
* publishing to both Facebook Pages and Instagram Business declare
|
|
5
|
+
* two providers, each with its own `platform` setting:
|
|
6
|
+
*
|
|
7
|
+
* config.herald.providers = {
|
|
8
|
+
* 'facebook': { driver: 'meta', platform: 'facebook', ... },
|
|
9
|
+
* 'instagram': { driver: 'meta', platform: 'instagram', ... },
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* Auth model:
|
|
13
|
+
*
|
|
14
|
+
* - **Per-instance `appSecret`** — constant per Meta app. Used
|
|
15
|
+
* for `appsecret_proof` on hardened Graph calls and for webhook
|
|
16
|
+
* signature verification. Apps load it once from env.
|
|
17
|
+
*
|
|
18
|
+
* - **Per-account `accessToken`** — long-lived Page token (FB) or
|
|
19
|
+
* IG-User token (IG), one per connected account. The
|
|
20
|
+
* `credentialsFor(accountId)` resolver returns it; apps
|
|
21
|
+
* typically read from `@strav/social`'s `social_account` ledger.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type MetaPlatform = 'facebook' | 'instagram'
|
|
25
|
+
|
|
26
|
+
export interface MetaCredentials {
|
|
27
|
+
/** Long-lived Page access token (FB) or IG-User access token (IG). */
|
|
28
|
+
accessToken: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type MetaCredentialsResolver = (
|
|
32
|
+
accountId: string,
|
|
33
|
+
) => Promise<MetaCredentials | null> | MetaCredentials | null
|
|
34
|
+
|
|
35
|
+
export interface MetaProviderConfig {
|
|
36
|
+
driver: 'meta'
|
|
37
|
+
platform: MetaPlatform
|
|
38
|
+
/** Meta app secret — constant per Meta app. */
|
|
39
|
+
appSecret: string
|
|
40
|
+
credentialsFor: MetaCredentialsResolver
|
|
41
|
+
/** Graph API version. Default `v21.0`. */
|
|
42
|
+
apiVersion?: string
|
|
43
|
+
/** Optional `fetch` override for tests. */
|
|
44
|
+
fetch?: typeof fetch
|
|
45
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MetaDriver` — `HeraldDriver` implementation for Meta's Graph API.
|
|
3
|
+
*
|
|
4
|
+
* One instance serves **one platform** (`facebook` or `instagram`).
|
|
5
|
+
* Apps publishing to both declare two `config.herald.providers`
|
|
6
|
+
* entries — same driver class, different `platform` setting.
|
|
7
|
+
*
|
|
8
|
+
* Facebook Pages (synchronous):
|
|
9
|
+
*
|
|
10
|
+
* - Text-only / text-with-link → `POST /{page-id}/feed` with
|
|
11
|
+
* `message` (+ optional `link`).
|
|
12
|
+
* - With image → `POST /{page-id}/photos` with `url` (FB hosts
|
|
13
|
+
* the image). Multi-image posts are deferred to a follow-up
|
|
14
|
+
* slice (would require unpublished photo IDs + attached_media).
|
|
15
|
+
* - Scheduled (`scheduledFor`) → `published=false` +
|
|
16
|
+
* `scheduled_publish_time` (unix seconds).
|
|
17
|
+
* - Update: NOT supported (FB removed feed edits via API).
|
|
18
|
+
* - Delete: `DELETE /{post-id}`.
|
|
19
|
+
*
|
|
20
|
+
* Instagram Business (two-step container publish):
|
|
21
|
+
*
|
|
22
|
+
* - Step 1: `POST /{ig-user-id}/media` with `image_url` +
|
|
23
|
+
* `caption` → returns `creation_id`.
|
|
24
|
+
* - Step 2: `POST /{ig-user-id}/media_publish` with `creation_id`
|
|
25
|
+
* → returns the IG media `id`.
|
|
26
|
+
* - Image-only in v1. Videos require status polling; carousels
|
|
27
|
+
* require child containers — both deferred.
|
|
28
|
+
* - Scheduled posts are NOT supported on the IG Content Publishing
|
|
29
|
+
* API; `scheduledFor` throws `ProviderUnsupportedError`.
|
|
30
|
+
* - Update + delete: NOT supported via API.
|
|
31
|
+
*
|
|
32
|
+
* Webhook ops delegated to `MetaWebhookOps`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { PostStatus } from '../../dto/post_status.ts'
|
|
36
|
+
import type { PublishInput } from '../../dto/publish_input.ts'
|
|
37
|
+
import type { PublishResult } from '../../dto/publish_result.ts'
|
|
38
|
+
import type { PublishTarget } from '../../dto/publish_target.ts'
|
|
39
|
+
import { HeraldProviderError, ProviderUnsupportedError } from '../../errors.ts'
|
|
40
|
+
import type { HeraldCapability } from '../../herald_capabilities.ts'
|
|
41
|
+
import type { HeraldDriver, WebhookOps } from '../../herald_driver.ts'
|
|
42
|
+
import { MetaGraphClient } from './meta_client.ts'
|
|
43
|
+
import type {
|
|
44
|
+
MetaCredentialsResolver,
|
|
45
|
+
MetaPlatform,
|
|
46
|
+
MetaProviderConfig,
|
|
47
|
+
} from './meta_config.ts'
|
|
48
|
+
import { MetaWebhookOps } from './meta_webhook_ops.ts'
|
|
49
|
+
|
|
50
|
+
export interface MetaDriverOptions {
|
|
51
|
+
instanceName: string
|
|
52
|
+
platform: MetaPlatform
|
|
53
|
+
/** Meta app secret — constant per Meta app. Required for `appsecret_proof` + webhook verification. */
|
|
54
|
+
appSecret: string
|
|
55
|
+
credentialsFor: MetaCredentialsResolver
|
|
56
|
+
apiVersion?: string
|
|
57
|
+
fetch?: typeof fetch
|
|
58
|
+
/** Optional webhook ops override (mainly for tests). */
|
|
59
|
+
webhook?: WebhookOps
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const FACEBOOK_CAPS: ReadonlySet<HeraldCapability> = new Set<HeraldCapability>([
|
|
63
|
+
'post.create',
|
|
64
|
+
'post.delete',
|
|
65
|
+
'post.get',
|
|
66
|
+
'post.schedule',
|
|
67
|
+
'media.image',
|
|
68
|
+
'media.link',
|
|
69
|
+
'webhook.signature',
|
|
70
|
+
'webhook.parse',
|
|
71
|
+
'webhook.engagement.comment',
|
|
72
|
+
'webhook.engagement.review',
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
const INSTAGRAM_CAPS: ReadonlySet<HeraldCapability> = new Set<HeraldCapability>([
|
|
76
|
+
'post.create',
|
|
77
|
+
'post.get',
|
|
78
|
+
'media.image',
|
|
79
|
+
'text.hashtags',
|
|
80
|
+
'text.mentions',
|
|
81
|
+
'webhook.signature',
|
|
82
|
+
'webhook.parse',
|
|
83
|
+
'webhook.engagement.comment',
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
export class MetaDriver implements HeraldDriver {
|
|
87
|
+
readonly name = 'meta'
|
|
88
|
+
readonly instanceName: string
|
|
89
|
+
readonly platform: MetaPlatform
|
|
90
|
+
readonly capabilities: ReadonlySet<HeraldCapability>
|
|
91
|
+
readonly webhook: WebhookOps
|
|
92
|
+
|
|
93
|
+
private readonly appSecret: string
|
|
94
|
+
private readonly resolver: MetaCredentialsResolver
|
|
95
|
+
private readonly apiVersion: string | undefined
|
|
96
|
+
private readonly fetcher: typeof fetch
|
|
97
|
+
|
|
98
|
+
constructor(options: MetaDriverOptions) {
|
|
99
|
+
this.instanceName = options.instanceName
|
|
100
|
+
this.platform = options.platform
|
|
101
|
+
this.capabilities = options.platform === 'facebook' ? FACEBOOK_CAPS : INSTAGRAM_CAPS
|
|
102
|
+
this.appSecret = options.appSecret
|
|
103
|
+
this.resolver = options.credentialsFor
|
|
104
|
+
this.apiVersion = options.apiVersion
|
|
105
|
+
this.fetcher = options.fetch ?? fetch
|
|
106
|
+
this.webhook =
|
|
107
|
+
options.webhook ??
|
|
108
|
+
new MetaWebhookOps({ appSecret: options.appSecret, driverName: 'meta' })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static fromConfig(instanceName: string, config: MetaProviderConfig): MetaDriver {
|
|
112
|
+
return new MetaDriver({
|
|
113
|
+
instanceName,
|
|
114
|
+
platform: config.platform,
|
|
115
|
+
appSecret: config.appSecret,
|
|
116
|
+
credentialsFor: config.credentialsFor,
|
|
117
|
+
...(config.apiVersion ? { apiVersion: config.apiVersion } : {}),
|
|
118
|
+
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async publish(target: PublishTarget, input: PublishInput): Promise<PublishResult> {
|
|
123
|
+
const client = await this.clientFor(target.accountId)
|
|
124
|
+
return this.platform === 'facebook'
|
|
125
|
+
? this.publishFacebook(target.accountId, client, input)
|
|
126
|
+
: this.publishInstagram(target.accountId, client, input)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async delete(target: PublishTarget, providerPostId: string): Promise<void> {
|
|
130
|
+
if (this.platform !== 'facebook') {
|
|
131
|
+
throw new ProviderUnsupportedError('meta-instagram', 'delete', {
|
|
132
|
+
reason: 'Instagram Graph API does not support post deletion.',
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
const client = await this.clientFor(target.accountId)
|
|
136
|
+
await client.delete(`/${providerPostId}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async get(target: PublishTarget, providerPostId: string): Promise<PostStatus> {
|
|
140
|
+
const client = await this.clientFor(target.accountId)
|
|
141
|
+
try {
|
|
142
|
+
await client.get(`/${providerPostId}`, { fields: 'id' })
|
|
143
|
+
return 'published'
|
|
144
|
+
} catch (cause) {
|
|
145
|
+
if (cause instanceof HeraldProviderError) return 'deleted'
|
|
146
|
+
throw cause
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── platform implementations ────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
private async publishFacebook(
|
|
153
|
+
accountId: string,
|
|
154
|
+
client: MetaGraphClient,
|
|
155
|
+
input: PublishInput,
|
|
156
|
+
): Promise<PublishResult> {
|
|
157
|
+
const raw = (input.raw as Record<string, unknown> | undefined) ?? {}
|
|
158
|
+
const message = buildMessage(input)
|
|
159
|
+
const image = (input.attachments ?? []).find((a) => a.type === 'image')
|
|
160
|
+
const link = (input.attachments ?? []).find((a) => a.type === 'link')
|
|
161
|
+
const scheduled = !!input.scheduledFor
|
|
162
|
+
|
|
163
|
+
if (image) {
|
|
164
|
+
const body: Record<string, unknown> = {
|
|
165
|
+
url: image.url,
|
|
166
|
+
...(message ? { caption: message } : {}),
|
|
167
|
+
...raw,
|
|
168
|
+
}
|
|
169
|
+
applySchedule(body, input.scheduledFor)
|
|
170
|
+
const res = await client.post<{ id?: string; post_id?: string }>(
|
|
171
|
+
`/${accountId}/photos`,
|
|
172
|
+
body,
|
|
173
|
+
)
|
|
174
|
+
const postId = res.post_id ?? res.id
|
|
175
|
+
if (!postId) {
|
|
176
|
+
throw new HeraldProviderError(
|
|
177
|
+
'Meta (Facebook): photos endpoint returned no post_id / id.',
|
|
178
|
+
{ provider: 'meta', operation: 'publishFacebook', status: 502, context: { raw: res } },
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
return this.facebookResult(postId, scheduled, res)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const body: Record<string, unknown> = {
|
|
185
|
+
...(message ? { message } : {}),
|
|
186
|
+
...(link ? { link: link.url } : {}),
|
|
187
|
+
...raw,
|
|
188
|
+
}
|
|
189
|
+
applySchedule(body, input.scheduledFor)
|
|
190
|
+
const res = await client.post<{ id?: string }>(`/${accountId}/feed`, body)
|
|
191
|
+
if (!res.id) {
|
|
192
|
+
throw new HeraldProviderError(
|
|
193
|
+
'Meta (Facebook): feed endpoint returned no id.',
|
|
194
|
+
{ provider: 'meta', operation: 'publishFacebook', status: 502, context: { raw: res } },
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
return this.facebookResult(res.id, scheduled, res)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private facebookResult(
|
|
201
|
+
postId: string,
|
|
202
|
+
scheduled: boolean,
|
|
203
|
+
raw: unknown,
|
|
204
|
+
): PublishResult {
|
|
205
|
+
return {
|
|
206
|
+
provider: 'meta',
|
|
207
|
+
providerPostId: postId,
|
|
208
|
+
status: scheduled ? 'pending' : 'published',
|
|
209
|
+
raw,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private async publishInstagram(
|
|
214
|
+
accountId: string,
|
|
215
|
+
client: MetaGraphClient,
|
|
216
|
+
input: PublishInput,
|
|
217
|
+
): Promise<PublishResult> {
|
|
218
|
+
if (input.scheduledFor) {
|
|
219
|
+
throw new ProviderUnsupportedError('meta-instagram', 'post.schedule', {
|
|
220
|
+
reason: 'IG Content Publishing API does not support scheduled posts.',
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
const image = (input.attachments ?? []).find((a) => a.type === 'image')
|
|
224
|
+
if (!image) {
|
|
225
|
+
throw new HeraldProviderError(
|
|
226
|
+
'Meta (Instagram): publish requires an image attachment in v1 (video + carousel deferred).',
|
|
227
|
+
{ provider: 'meta', operation: 'publishInstagram', status: 400 },
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const caption = buildCaption(input)
|
|
232
|
+
const raw = (input.raw as Record<string, unknown> | undefined) ?? {}
|
|
233
|
+
|
|
234
|
+
// Step 1 — create media container
|
|
235
|
+
const container = await client.post<{ id?: string }>(`/${accountId}/media`, {
|
|
236
|
+
image_url: image.url,
|
|
237
|
+
...(caption ? { caption } : {}),
|
|
238
|
+
...raw,
|
|
239
|
+
})
|
|
240
|
+
if (!container.id) {
|
|
241
|
+
throw new HeraldProviderError(
|
|
242
|
+
'Meta (Instagram): /media did not return a creation id.',
|
|
243
|
+
{ provider: 'meta', operation: 'publishInstagram', status: 502, context: { raw: container } },
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Step 2 — publish container
|
|
248
|
+
const published = await client.post<{ id?: string }>(`/${accountId}/media_publish`, {
|
|
249
|
+
creation_id: container.id,
|
|
250
|
+
})
|
|
251
|
+
if (!published.id) {
|
|
252
|
+
throw new HeraldProviderError(
|
|
253
|
+
'Meta (Instagram): /media_publish did not return an id.',
|
|
254
|
+
{ provider: 'meta', operation: 'publishInstagram', status: 502, context: { raw: published } },
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
provider: 'meta',
|
|
260
|
+
providerPostId: published.id,
|
|
261
|
+
status: 'published',
|
|
262
|
+
raw: { container, published },
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── internals ───────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
private async clientFor(accountId: string): Promise<MetaGraphClient> {
|
|
269
|
+
const resolved = await this.resolver(accountId)
|
|
270
|
+
if (!resolved) {
|
|
271
|
+
throw new HeraldProviderError(
|
|
272
|
+
`Meta driver: no credentials registered for account "${accountId}".`,
|
|
273
|
+
{ provider: 'meta', operation: 'clientFor', status: 500 },
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
return new MetaGraphClient({
|
|
277
|
+
accessToken: resolved.accessToken,
|
|
278
|
+
appSecret: this.appSecret,
|
|
279
|
+
...(this.apiVersion ? { apiVersion: this.apiVersion } : {}),
|
|
280
|
+
fetch: this.fetcher,
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function buildMessage(input: PublishInput): string {
|
|
286
|
+
const parts: string[] = []
|
|
287
|
+
if (input.text) parts.push(input.text)
|
|
288
|
+
if (input.hashtags && input.hashtags.length > 0) {
|
|
289
|
+
parts.push(input.hashtags.map((h) => `#${h}`).join(' '))
|
|
290
|
+
}
|
|
291
|
+
return parts.join('\n\n')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function buildCaption(input: PublishInput): string {
|
|
295
|
+
// IG captions accept hashtags + emoji freely; same shape as the
|
|
296
|
+
// FB message for now. Links in captions aren't clickable on IG
|
|
297
|
+
// but are commonly included anyway.
|
|
298
|
+
const parts: string[] = []
|
|
299
|
+
if (input.text) parts.push(input.text)
|
|
300
|
+
for (const att of input.attachments ?? []) {
|
|
301
|
+
if (att.type === 'link') parts.push(att.url)
|
|
302
|
+
}
|
|
303
|
+
if (input.hashtags && input.hashtags.length > 0) {
|
|
304
|
+
parts.push(input.hashtags.map((h) => `#${h}`).join(' '))
|
|
305
|
+
}
|
|
306
|
+
return parts.join('\n\n')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function applySchedule(body: Record<string, unknown>, scheduledFor: Date | undefined): void {
|
|
310
|
+
if (!scheduledFor) return
|
|
311
|
+
body['published'] = false
|
|
312
|
+
body['scheduled_publish_time'] = Math.floor(scheduledFor.getTime() / 1000)
|
|
313
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MetaHeraldProvider` — `ServiceProvider` that registers the Meta
|
|
3
|
+
* driver factory on the `HeraldManager`.
|
|
4
|
+
*
|
|
5
|
+
* Apps list this AFTER `HeraldProvider`. One factory registration
|
|
6
|
+
* covers every Meta-driven instance — the per-instance `platform`
|
|
7
|
+
* field (`facebook` | `instagram`) flips behaviour inside the driver.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type Application, ServiceProvider } from '@strav/kernel'
|
|
11
|
+
import { HeraldConfigError } from '../../errors.ts'
|
|
12
|
+
import { HeraldManager } from '../../herald_manager.ts'
|
|
13
|
+
import type { MetaProviderConfig } from './meta_config.ts'
|
|
14
|
+
import { MetaDriver } from './meta_driver.ts'
|
|
15
|
+
|
|
16
|
+
export class MetaHeraldProvider extends ServiceProvider {
|
|
17
|
+
override readonly name = 'herald-meta'
|
|
18
|
+
override readonly dependencies = ['herald']
|
|
19
|
+
|
|
20
|
+
override register(app: Application): void {
|
|
21
|
+
const manager = app.resolve(HeraldManager)
|
|
22
|
+
manager.extend('meta', ({ instanceName, config }) => {
|
|
23
|
+
const cfg = config as unknown as MetaProviderConfig
|
|
24
|
+
if (!cfg.platform || (cfg.platform !== 'facebook' && cfg.platform !== 'instagram')) {
|
|
25
|
+
throw new HeraldConfigError(
|
|
26
|
+
`MetaHeraldProvider: provider "${instanceName}" needs \`platform: 'facebook' | 'instagram'\`.`,
|
|
27
|
+
{ context: { instanceName, platform: cfg.platform } },
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
if (!cfg.appSecret) {
|
|
31
|
+
throw new HeraldConfigError(
|
|
32
|
+
`MetaHeraldProvider: provider "${instanceName}" needs \`appSecret\` (the Meta app secret).`,
|
|
33
|
+
{ context: { instanceName } },
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
if (!cfg.credentialsFor) {
|
|
37
|
+
throw new HeraldConfigError(
|
|
38
|
+
`MetaHeraldProvider: provider "${instanceName}" needs a \`credentialsFor\` resolver.`,
|
|
39
|
+
{ context: { instanceName } },
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
return MetaDriver.fromConfig(instanceName, cfg)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|