Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ All notable changes to this project are documented here. The format follows
for an account, and `resubscribeWebhook` puts a lapsed subscription back.
- `inbox().passThreadControl`, `takeThreadControl` and `handover` move a Messenger thread
between Meta apps (`inbox` scope, plus `publish`).
- `knowledge()`: the workspace knowledge base — `list`, `create` (plus the
`createText`, `createUrl` and `createFile` shorthands), `update`, `delete`,
`sync` and `search`, with the `KnowledgeSource` and `KnowledgeMatch` records.
A source is an FAQ, a note, a URL on your own site or a plain-text/CSV media
item; `search` returns the passages closest to a question, and is what grounds
a drafted inbox reply in your own answers. Needs the `inbox` scope.

- `accounts()`: `listSlackChannels`, `listSlackMembers`, `getSlackIdentity` and
`updateSlackIdentity` (`UpdateSlackIdentityParams`) for a Slack account. A webhook-connected
account answers 409 `webhook_connection`.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac
| `media()` | `list`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` |
| `ai()` | `credits`, `generateCaption`, `rewrite`, `repurposeUrl` |
| `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `passThreadControl`, `takeThreadControl`, `handover`, `listApprovals`, `approveReply`, `rejectReply` |
| `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `listApprovals`, `approveReply`, `rejectReply` |
| `knowledge()` | `list`, `create`, `createText`, `createUrl`, `createFile`, `update`, `delete`, `sync`, `search` |
| `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `authorizeMeta`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage` |
| `validate()` | `post`, `length`, `media` |

Expand Down
8 changes: 8 additions & 0 deletions src/main/java/com/fopost/sdk/FoPost.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.fopost.sdk.resource.AnalyticsResource;
import com.fopost.sdk.resource.AutomationsResource;
import com.fopost.sdk.resource.InboxResource;
import com.fopost.sdk.resource.KnowledgeResource;
import com.fopost.sdk.resource.LabelsResource;
import com.fopost.sdk.resource.MediaResource;
import com.fopost.sdk.resource.PostsResource;
Expand Down Expand Up @@ -60,6 +61,7 @@ public final class FoPost {
private final AccountsResource accounts;
private final AccountGroupsResource accountGroups;
private final WorkspacesResource workspaces;
private final KnowledgeResource knowledge;
private final LabelsResource labels;
private final WebhooksResource webhooks;
private final AnalyticsResource analytics;
Expand All @@ -76,6 +78,7 @@ private FoPost(ApiClient http) {
this.accounts = new AccountsResource(http);
this.accountGroups = new AccountGroupsResource(http);
this.workspaces = new WorkspacesResource(http);
this.knowledge = new KnowledgeResource(http);
this.labels = new LabelsResource(http);
this.webhooks = new WebhooksResource(http);
this.analytics = new AnalyticsResource(http);
Expand Down Expand Up @@ -116,6 +119,11 @@ public WorkspacesResource workspaces() {
return workspaces;
}

/** The workspace knowledge base, which grounds drafted replies. */
public KnowledgeResource knowledge() {
return knowledge;
}

public LabelsResource labels() {
return labels;
}
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/fopost/sdk/internal/ApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ public JsonNode put(String path, Object body) {
return request("PUT", path, body, null);
}

public JsonNode patch(String path, Object body) {
return request("PATCH", path, body, null);
}

public JsonNode delete(String path) {
return request("DELETE", path, null, null);
}
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/fopost/sdk/model/KnowledgeMatch.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.fopost.sdk.model;

/**
* One retrieved passage, with the source it came from so a reply can cite it.
*
* @param score similarity to the question, 0-1
*/
public record KnowledgeMatch(
String sourceId,
String sourceTitle,
String sourceKind,
String sourceUrl,
String text,
Double score) {}
32 changes: 32 additions & 0 deletions src/main/java/com/fopost/sdk/model/KnowledgeSource.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.fopost.sdk.model;

import java.time.Instant;

/**
* One thing the workspace has told FoPost about itself: an FAQ, a note, a page
* on its own site, or a plain-text/CSV file from the media library.
*
* @param kind {@code faq}, {@code text}, {@code url} or {@code file}
* @param status {@code pending}, {@code syncing}, {@code ready} or {@code failed};
* only a ready source is searched
* @param statusMessage why the last sync failed, in plain words
* @param url set for {@code url} sources
* @param mediaId set for {@code file} sources: the media library item read
* @param brandVoiceId null means the source serves the whole workspace
* @param chunkCount searchable passages the last sync produced
* @param content the typed text, for {@code faq} and {@code text} sources only
*/
public record KnowledgeSource(
String id,
String kind,
String title,
String status,
String statusMessage,
String url,
String mediaId,
String brandVoiceId,
Integer chunkCount,
String content,
Instant lastSyncedAt,
Instant createdAt,
Instant updatedAt) {}
137 changes: 137 additions & 0 deletions src/main/java/com/fopost/sdk/resource/KnowledgeResource.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package com.fopost.sdk.resource;

import com.fopost.sdk.internal.ApiClient;
import com.fopost.sdk.model.KnowledgeMatch;
import com.fopost.sdk.model.KnowledgeSource;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* The workspace knowledge base: what the workspace has told FoPost about
* itself.
*
* <p>A source is an FAQ, a note, a page on your own site, or a plain-text/CSV
* item from the media library. Retrieval over these is what grounds a drafted
* inbox reply in your own answers instead of an invented one. Needs the {@code
* inbox} scope.
*/
public final class KnowledgeResource {

private final ApiClient http;

public KnowledgeResource(ApiClient http) {
this.http = http;
}

public List<KnowledgeSource> list() {
return list(null);
}

/** Every source in the workspace. Only a {@code ready} one is searched. */
public List<KnowledgeSource> list(String workspaceId) {
Map<String, Object> query = new LinkedHashMap<>();
if (workspaceId != null) {
query.put("workspace_id", workspaceId);
}
return http.convertList(
ApiClient.unwrap(http.get("/v1/knowledge/sources", query)), KnowledgeSource.class);
}

/**
* Adds a source and queues it for indexing, so it comes back {@code pending}.
*
* <p>{@code kind} is {@code faq}, {@code text}, {@code url} or {@code file}. An {@code faq} or
* {@code text} source needs {@code content}, a {@code url} source needs {@code url}, and a
* {@code file} source needs {@code mediaId} pointing at a plain-text or CSV item in the same
* workspace. Pass null for whatever the kind does not use.
*/
public KnowledgeSource create(
String kind,
String title,
String content,
String url,
String mediaId,
String brandVoiceId,
String workspaceId) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("kind", kind);
body.put("title", title);
putIfPresent(body, "content", content);
putIfPresent(body, "url", url);
putIfPresent(body, "media_id", mediaId);
putIfPresent(body, "brand_voice_id", brandVoiceId);
putIfPresent(body, "workspace_id", workspaceId);
return http.convert(
ApiClient.unwrap(http.post("/v1/knowledge/sources", body)), KnowledgeSource.class);
}

/** An FAQ or a note, whose text you type here. */
public KnowledgeSource createText(String kind, String title, String content) {
return create(kind, title, content, null, null, null, null);
}

/** A page on your own site, re-read whenever you sync it. */
public KnowledgeSource createUrl(String title, String url) {
return create("url", title, null, url, null, null, null);
}

/** A plain-text or CSV item already in the media library. */
public KnowledgeSource createFile(String title, String mediaId) {
return create("file", title, null, null, mediaId, null, null);
}

/**
* Partial update: only the non-null fields are sent. Changing the content or the URL returns
* the source to {@code pending} and re-indexes it.
*/
public KnowledgeSource update(
String sourceId, String title, String content, String url, String brandVoiceId) {
Map<String, Object> body = new LinkedHashMap<>();
putIfPresent(body, "title", title);
putIfPresent(body, "content", content);
putIfPresent(body, "url", url);
putIfPresent(body, "brand_voice_id", brandVoiceId);
return http.convert(
ApiClient.unwrap(http.patch("/v1/knowledge/sources/" + sourceId, body)),
KnowledgeSource.class);
}

/** Removes the source and every passage indexed from it. */
public void delete(String sourceId) {
http.delete("/v1/knowledge/sources/" + sourceId);
}

/**
* Reads the source again — a {@code url} source is re-fetched. Returns once the re-index is
* queued, not once it has finished.
*/
public void sync(String sourceId) {
http.post("/v1/knowledge/sources/" + sourceId + "/sync", new LinkedHashMap<String, Object>());
}

public List<KnowledgeMatch> search(String q) {
return search(q, null, null, null);
}

/**
* The passages closest to a question, best first. An empty list is the honest answer when
* nothing stored answers it. {@code topK} defaults to 5 and caps at 20.
*/
public List<KnowledgeMatch> search(
String q, Integer topK, String brandVoiceId, String workspaceId) {
Map<String, Object> query = new LinkedHashMap<>();
query.put("q", q);
putIfPresent(query, "top_k", topK);
putIfPresent(query, "brand_voice_id", brandVoiceId);
putIfPresent(query, "workspace_id", workspaceId);
return http.convertList(
ApiClient.unwrap(http.get("/v1/knowledge/search", query)), KnowledgeMatch.class);
}

private static void putIfPresent(Map<String, Object> target, String key, Object value) {
if (value != null) {
target.put(key, value);
}
}
}
90 changes: 90 additions & 0 deletions src/test/java/com/fopost/sdk/KnowledgeTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.fopost.sdk;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;

/**
* The knowledge base: the path, the query casing, the snake_case request body,
* and that a camelCase response reads back into the record.
*/
class KnowledgeTest {

private static final String SOURCE = "{\"id\":\"know_1\",\"kind\":\"url\",\"title\":\"Refund policy\","
+ "\"status\":\"ready\",\"statusMessage\":null,\"url\":\"https://yourbrand.com/help/refunds\","
+ "\"mediaId\":null,\"brandVoiceId\":null,\"chunkCount\":3,\"content\":null,"
+ "\"lastSyncedAt\":\"2026-09-20T00:00:00.000Z\",\"createdAt\":\"2026-09-19T00:00:00.000Z\","
+ "\"updatedAt\":\"2026-09-20T00:00:00.000Z\"}";

@Test
void listSendsTheWorkspaceFilterAndReadsCamelCaseFields() {
FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":[" + SOURCE + "]}");

var sources = TestSupport.client(transport).knowledge().list("w1");

assertEquals(
"https://api.fopost.test/v1/knowledge/sources?workspace_id=w1", transport.last().url());
assertEquals(1, sources.size());
assertEquals("ready", sources.get(0).status());
assertEquals(3, sources.get(0).chunkCount());
}

@Test
void createSendsASnakeCaseBodyAndOmitsWhatTheKindDoesNotUse() {
FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":" + SOURCE + "}");

TestSupport.client(transport).knowledge().createFile("Price list", "media_1");

assertEquals("POST", transport.last().method());
assertEquals("https://api.fopost.test/v1/knowledge/sources", transport.last().url());
String body = transport.lastBody();
assertTrue(body.contains("\"media_id\":\"media_1\""), body);
assertTrue(body.contains("\"kind\":\"file\""), body);
// Nothing the kind does not use reaches the wire.
assertFalse(body.contains("\"url\""), body);
assertFalse(body.contains("\"content\""), body);
}

@Test
void updateSendsOnlyTheFieldsThatWereGiven() {
FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":" + SOURCE + "}");

TestSupport.client(transport).knowledge().update("know_1", "Refunds", null, null, null);

assertEquals("PATCH", transport.last().method());
assertEquals("https://api.fopost.test/v1/knowledge/sources/know_1", transport.last().url());
assertEquals("{\"title\":\"Refunds\"}", transport.lastBody());
}

@Test
void searchSendsTopKAndReadsTheMatches() {
FakeTransport transport = new FakeTransport()
.enqueue(
200,
"{\"data\":[{\"sourceId\":\"know_1\",\"sourceTitle\":\"Refund policy\","
+ "\"sourceKind\":\"url\",\"sourceUrl\":null,"
+ "\"text\":\"We refund within 30 days.\",\"score\":0.82}]}");

var matches = TestSupport.client(transport).knowledge().search("refunds", 3, null, null);

assertTrue(transport.last().url().contains("q=refunds"), transport.last().url());
assertTrue(transport.last().url().contains("top_k=3"), transport.last().url());
assertEquals(1, matches.size());
assertEquals("Refund policy", matches.get(0).sourceTitle());
assertEquals(0.82, matches.get(0).score(), 0.0001);
}

@Test
void syncPostsToTheSourcesSyncPath() {
FakeTransport transport =
new FakeTransport().enqueue(200, "{\"data\":{\"id\":\"know_1\",\"status\":\"pending\"}}");

TestSupport.client(transport).knowledge().sync("know_1");

assertEquals("POST", transport.last().method());
assertEquals(
"https://api.fopost.test/v1/knowledge/sources/know_1/sync", transport.last().url());
}
}
Loading