@opengeni/api-router 0.15.1 → 0.15.4
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/dist/app.d.ts +1 -0
- package/dist/app.js +3 -1
- package/dist/{chunk-UVL2KH56.js → chunk-ICRZC3JH.js} +10233 -8946
- package/dist/chunk-ICRZC3JH.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/integrations/slack-bot.d.ts +9 -1
- package/dist/integrations/slack-interactions.d.ts +18 -1
- package/dist/integrations/social-api.d.ts +91 -0
- package/dist/integrations/social-oauth.d.ts +84 -0
- package/package.json +10 -10
- package/src/app.ts +56 -0
- package/src/http/auth.ts +5 -0
- package/src/integrations/oauth-client.ts +27 -10
- package/src/integrations/slack-bot.ts +20 -3
- package/src/integrations/slack-interactions.ts +204 -27
- package/src/integrations/social-api.ts +504 -0
- package/src/integrations/social-oauth.ts +745 -0
- package/src/mcp/server.ts +174 -0
- package/src/routes/social.ts +67 -2
- package/dist/chunk-UVL2KH56.js.map +0 -1
package/src/mcp/server.ts
CHANGED
|
@@ -57,6 +57,8 @@ import {
|
|
|
57
57
|
listRigVersionMonitoringSummaries,
|
|
58
58
|
listSocialConnections,
|
|
59
59
|
listSocialPosts,
|
|
60
|
+
recordAuditEvent,
|
|
61
|
+
recordSyncedSocialPosts,
|
|
60
62
|
listVariableSets,
|
|
61
63
|
MEMORY_CORRECT_TOOL_DESCRIPTION,
|
|
62
64
|
MEMORY_SAVE_TOOL_DESCRIPTION,
|
|
@@ -109,6 +111,13 @@ import {
|
|
|
109
111
|
listWorkspaceGitHubRepositories,
|
|
110
112
|
} from "../github-access";
|
|
111
113
|
import { githubBrowserBaseUrl, githubBrowserGrantClaims } from "../github-browser-flow";
|
|
114
|
+
import {
|
|
115
|
+
socialMentionsLive,
|
|
116
|
+
socialOwnPostsLive,
|
|
117
|
+
socialPostReply,
|
|
118
|
+
socialSearchLive,
|
|
119
|
+
socialThreadLive,
|
|
120
|
+
} from "../integrations/social-api";
|
|
112
121
|
import {
|
|
113
122
|
promoteVerifiedDefinitionEditChangeForApi,
|
|
114
123
|
proposeRigChangeForApi,
|
|
@@ -228,6 +237,15 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
|
|
|
228
237
|
social_connections_list: { allOf: ["connections:read"] },
|
|
229
238
|
social_posts_recent: { allOf: ["connections:read"] },
|
|
230
239
|
social_daily_analysis_context: { allOf: ["connections:read"] },
|
|
240
|
+
social_search_live: { allOf: ["connections:read"] },
|
|
241
|
+
social_mentions_live: { allOf: ["connections:read"] },
|
|
242
|
+
social_thread_fetch: { allOf: ["connections:read"] },
|
|
243
|
+
// Writes the social_posts store, so it takes the write scope like the REST
|
|
244
|
+
// equivalent (POST /social/posts is workspace:admin).
|
|
245
|
+
social_posts_sync: { allOf: ["connections:write"] },
|
|
246
|
+
// Publishes under the user's identity: connections:write keeps it out of the
|
|
247
|
+
// default agent permission set, unlike the read-only social tools above.
|
|
248
|
+
social_post_reply: { allOf: ["connections:write"] },
|
|
231
249
|
scheduled_tasks_list: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
|
|
232
250
|
scheduled_tasks_get: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
|
|
233
251
|
scheduled_tasks_create: { allOf: ["scheduled_tasks:manage"] },
|
|
@@ -562,6 +580,162 @@ export function buildOpenGeniMcpServer(
|
|
|
562
580
|
});
|
|
563
581
|
},
|
|
564
582
|
);
|
|
583
|
+
|
|
584
|
+
// Live provider reads (X / Reddit). Tokens are resolved and used entirely
|
|
585
|
+
// host-side; the agent only ever sees normalized post payloads.
|
|
586
|
+
server.registerTool(
|
|
587
|
+
"social_search_live",
|
|
588
|
+
{
|
|
589
|
+
description:
|
|
590
|
+
"Search live conversations on a connected social account (X recent search or Reddit search). Use social_connections_list first to find the connectionId. For Reddit, pass subreddit to scope the search.",
|
|
591
|
+
inputSchema: {
|
|
592
|
+
connectionId: z4.string().uuid(),
|
|
593
|
+
query: z4.string().min(1).max(512),
|
|
594
|
+
subreddit: z4.string().min(1).max(100).optional(),
|
|
595
|
+
limit: z4.number().int().positive().optional(),
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
async ({ connectionId, query, subreddit, limit }) => {
|
|
599
|
+
const result = await socialSearchLive(
|
|
600
|
+
deps,
|
|
601
|
+
{ workspaceId: grant.workspaceId, connectionId },
|
|
602
|
+
{ query, subreddit, limit },
|
|
603
|
+
);
|
|
604
|
+
return json({ provider: result.connection.provider, posts: result.posts });
|
|
605
|
+
},
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
server.registerTool(
|
|
609
|
+
"social_mentions_live",
|
|
610
|
+
{
|
|
611
|
+
description:
|
|
612
|
+
"Fetch live mentions of the connected account (X mentions timeline, or the Reddit inbox with username mentions and comment replies).",
|
|
613
|
+
inputSchema: {
|
|
614
|
+
connectionId: z4.string().uuid(),
|
|
615
|
+
sinceId: z4.string().optional(),
|
|
616
|
+
limit: z4.number().int().positive().optional(),
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
async ({ connectionId, sinceId, limit }) => {
|
|
620
|
+
const result = await socialMentionsLive(
|
|
621
|
+
deps,
|
|
622
|
+
{ workspaceId: grant.workspaceId, connectionId },
|
|
623
|
+
{ sinceId, limit },
|
|
624
|
+
);
|
|
625
|
+
return json({ provider: result.connection.provider, posts: result.posts });
|
|
626
|
+
},
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
server.registerTool(
|
|
630
|
+
"social_thread_fetch",
|
|
631
|
+
{
|
|
632
|
+
description:
|
|
633
|
+
"Fetch a live conversation thread: for X pass a tweet id (returns the conversation), for Reddit pass a post id or t3_ fullname (returns the post plus top comments).",
|
|
634
|
+
inputSchema: {
|
|
635
|
+
connectionId: z4.string().uuid(),
|
|
636
|
+
id: z4.string().min(1).max(100),
|
|
637
|
+
limit: z4.number().int().positive().optional(),
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
async ({ connectionId, id, limit }) => {
|
|
641
|
+
const result = await socialThreadLive(
|
|
642
|
+
deps,
|
|
643
|
+
{ workspaceId: grant.workspaceId, connectionId },
|
|
644
|
+
{ id, limit },
|
|
645
|
+
);
|
|
646
|
+
return json({ provider: result.connection.provider, posts: result.posts });
|
|
647
|
+
},
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Writes are gated on connections:write (never in the default first-party
|
|
652
|
+
// agent permission set) so scheduled tasks must opt in, and deployments can
|
|
653
|
+
// additionally wrap posting in a requireApproval policy.
|
|
654
|
+
if (!toolspaceMode || can("connections:write")) {
|
|
655
|
+
server.registerTool(
|
|
656
|
+
"social_posts_sync",
|
|
657
|
+
{
|
|
658
|
+
description:
|
|
659
|
+
"Sync the connected account's own recent posts from the provider into OpenGeni's social_posts store (idempotent), so social_posts_recent and daily analysis see fresh data.",
|
|
660
|
+
inputSchema: {
|
|
661
|
+
connectionId: z4.string().uuid(),
|
|
662
|
+
limit: z4.number().int().positive().optional(),
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
async ({ connectionId, limit }) => {
|
|
666
|
+
const result = await socialOwnPostsLive(
|
|
667
|
+
deps,
|
|
668
|
+
{ workspaceId: grant.workspaceId, connectionId },
|
|
669
|
+
{ limit },
|
|
670
|
+
);
|
|
671
|
+
// A post without a provider timestamp is skipped rather than recorded
|
|
672
|
+
// at sync time: publishedAt drives analysis windows, and the dedup
|
|
673
|
+
// index would freeze a fabricated date forever.
|
|
674
|
+
const datedPosts = result.posts.filter((post) => post.createdAt !== null);
|
|
675
|
+
const synced = await recordSyncedSocialPosts(deps.db, {
|
|
676
|
+
accountId: grant.accountId,
|
|
677
|
+
workspaceId: grant.workspaceId,
|
|
678
|
+
connectionId,
|
|
679
|
+
posts: datedPosts.map((post) => ({
|
|
680
|
+
externalPostId: post.id,
|
|
681
|
+
url: post.url,
|
|
682
|
+
authorHandle: post.author,
|
|
683
|
+
text: post.text,
|
|
684
|
+
publishedAt: new Date(post.createdAt!),
|
|
685
|
+
metrics: post.metrics,
|
|
686
|
+
})),
|
|
687
|
+
});
|
|
688
|
+
return json({
|
|
689
|
+
provider: result.connection.provider,
|
|
690
|
+
fetched: result.posts.length,
|
|
691
|
+
inserted: synced.inserted,
|
|
692
|
+
skipped: synced.skipped,
|
|
693
|
+
skippedMissingDate: result.posts.length - datedPosts.length,
|
|
694
|
+
});
|
|
695
|
+
},
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
server.registerTool(
|
|
699
|
+
"social_post_reply",
|
|
700
|
+
{
|
|
701
|
+
description:
|
|
702
|
+
"Publish a reply from the connected social account. X: inReplyToId is the tweet id to reply to. Reddit: inReplyToId is a fullname (t3_<post> or t1_<comment>). Draft and get approval before calling this — it posts publicly and immediately.",
|
|
703
|
+
inputSchema: {
|
|
704
|
+
connectionId: z4.string().uuid(),
|
|
705
|
+
inReplyToId: z4.string().min(1).max(100),
|
|
706
|
+
text: z4.string().min(1).max(10000),
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
async ({ connectionId, inReplyToId, text }) => {
|
|
710
|
+
const result = await socialPostReply(
|
|
711
|
+
deps,
|
|
712
|
+
{ workspaceId: grant.workspaceId, connectionId },
|
|
713
|
+
{ inReplyToId, text },
|
|
714
|
+
);
|
|
715
|
+
// Outbound publishes leave a durable, secret-free receipt (house
|
|
716
|
+
// pattern: the Slack bot post audit), so who posted what where stays
|
|
717
|
+
// answerable after the session is gone.
|
|
718
|
+
await recordAuditEvent(deps.db, {
|
|
719
|
+
accountId: grant.accountId,
|
|
720
|
+
workspaceId: grant.workspaceId,
|
|
721
|
+
subjectId: grant.subjectId,
|
|
722
|
+
action: "social.post_reply",
|
|
723
|
+
targetType: "social_connection",
|
|
724
|
+
targetId: connectionId,
|
|
725
|
+
metadata: {
|
|
726
|
+
provider: result.connection.provider,
|
|
727
|
+
inReplyToId,
|
|
728
|
+
postedId: result.postedId,
|
|
729
|
+
url: result.url,
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
return json({
|
|
733
|
+
provider: result.connection.provider,
|
|
734
|
+
postedId: result.postedId,
|
|
735
|
+
url: result.url,
|
|
736
|
+
});
|
|
737
|
+
},
|
|
738
|
+
);
|
|
565
739
|
}
|
|
566
740
|
|
|
567
741
|
if (!toolspaceMode || can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
|
package/src/routes/social.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CreateSocialConnectionRequest,
|
|
3
|
+
CreateSocialPostRequest,
|
|
4
|
+
OAuthStartResponse,
|
|
5
|
+
SocialOAuthStartRequest,
|
|
6
|
+
} from "@opengeni/contracts";
|
|
2
7
|
import {
|
|
3
8
|
createSocialConnection,
|
|
4
9
|
createSocialPost,
|
|
5
10
|
listSocialConnections,
|
|
6
11
|
listSocialPosts,
|
|
12
|
+
updateSocialConnectionCredential,
|
|
7
13
|
} from "@opengeni/db";
|
|
8
14
|
import type { Hono } from "hono";
|
|
9
15
|
import { HTTPException } from "hono/http-exception";
|
|
@@ -11,9 +17,10 @@ import { z } from "zod";
|
|
|
11
17
|
import { requireAccessGrant } from "@opengeni/core";
|
|
12
18
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
13
19
|
import { boundedLimit } from "../http/common";
|
|
20
|
+
import { completeSocialOAuthCallback, startSocialOAuth } from "../integrations/social-oauth";
|
|
14
21
|
|
|
15
22
|
export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
16
|
-
const { db } = deps;
|
|
23
|
+
const { db, settings, observability } = deps;
|
|
17
24
|
|
|
18
25
|
app.get("/v1/workspaces/:workspaceId/social/connections", async (c) => {
|
|
19
26
|
const workspaceId = c.req.param("workspaceId");
|
|
@@ -47,6 +54,64 @@ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
47
54
|
}
|
|
48
55
|
});
|
|
49
56
|
|
|
57
|
+
// Disconnect: drop the stored OAuth credential and disable the connection.
|
|
58
|
+
// The row stays (posts reference it and the audit trail needs the identity);
|
|
59
|
+
// reconnecting via the OAuth flow revives it.
|
|
60
|
+
app.delete("/v1/workspaces/:workspaceId/social/connections/:connectionId", async (c) => {
|
|
61
|
+
const workspaceId = c.req.param("workspaceId");
|
|
62
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
63
|
+
const connection = await updateSocialConnectionCredential(db, {
|
|
64
|
+
workspaceId,
|
|
65
|
+
connectionId: c.req.param("connectionId"),
|
|
66
|
+
credentialEncrypted: null,
|
|
67
|
+
status: "disabled",
|
|
68
|
+
tokenMetadata: {},
|
|
69
|
+
});
|
|
70
|
+
if (!connection) {
|
|
71
|
+
throw new HTTPException(404, { message: "social connection not found" });
|
|
72
|
+
}
|
|
73
|
+
return c.json(connection);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// First-party social OAuth (X / Reddit). Distinct from the MCP integrations
|
|
77
|
+
// flow: providers are pinned, tokens land in social_connections, and the
|
|
78
|
+
// callback is unauthenticated (browser redirect) but bound by signed state.
|
|
79
|
+
app.post("/v1/workspaces/:workspaceId/social/oauth/start", async (c) => {
|
|
80
|
+
const workspaceId = c.req.param("workspaceId");
|
|
81
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
82
|
+
const parsed = SocialOAuthStartRequest.safeParse(await c.req.json());
|
|
83
|
+
if (!parsed.success) {
|
|
84
|
+
throw new HTTPException(400, {
|
|
85
|
+
message: parsed.error.issues[0]?.message ?? "invalid social OAuth start request",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const payload = parsed.data;
|
|
89
|
+
const result = await startSocialOAuth(
|
|
90
|
+
{ db, settings, observability },
|
|
91
|
+
{
|
|
92
|
+
accountId: grant.accountId,
|
|
93
|
+
workspaceId,
|
|
94
|
+
subjectId: grant.subjectId,
|
|
95
|
+
requestUrl: c.req.url,
|
|
96
|
+
payload,
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
return c.json(OAuthStartResponse.parse(result));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
app.get("/v1/social/oauth/callback", async (c) => {
|
|
103
|
+
const result = await completeSocialOAuthCallback(
|
|
104
|
+
{ db, settings, observability },
|
|
105
|
+
{
|
|
106
|
+
code: c.req.query("code"),
|
|
107
|
+
state: c.req.query("state"),
|
|
108
|
+
error: c.req.query("error"),
|
|
109
|
+
requestUrl: c.req.url,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
return c.redirect(result.redirectTo, 302);
|
|
113
|
+
});
|
|
114
|
+
|
|
50
115
|
app.get("/v1/workspaces/:workspaceId/social/posts", async (c) => {
|
|
51
116
|
const workspaceId = c.req.param("workspaceId");
|
|
52
117
|
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|