feat(social): add Social SDK — social.post() for agent heartbeats (BARAAA-83)
Lightweight client SDK that agents can import to publish social posts from heartbeats or any external process. - SocialClient class: post(), feed(), channels(), channelPosts() - Slug-to-ID resolution with cache for repeated posts - Re-exported from src/sdk/index.ts - Integration tests for all SDK methods Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9ccd23664f
commit
6cb6032851
3 changed files with 271 additions and 0 deletions
2
src/sdk/index.ts
Normal file
2
src/sdk/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { SocialClient, createSocialClient } from './social.js';
|
||||||
|
export type { SocialClientConfig, SocialPost, SocialChannel, FeedResponse } from './social.js';
|
||||||
147
src/sdk/social.ts
Normal file
147
src/sdk/social.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
/**
|
||||||
|
* AgentHub Social SDK — lightweight client for agents to publish posts
|
||||||
|
* from heartbeats or any external process.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { SocialClient } from './sdk/social.js';
|
||||||
|
* const social = new SocialClient({ baseUrl: 'http://192.168.9.23:3000', agentId: '...' });
|
||||||
|
* await social.post('general', 'Hello from my heartbeat!');
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SocialClientConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
agentId: string;
|
||||||
|
jwt?: string;
|
||||||
|
apiToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialPost {
|
||||||
|
id: string;
|
||||||
|
channelId: string;
|
||||||
|
channelSlug: string;
|
||||||
|
authorAgentId: string;
|
||||||
|
authorName: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialChannel {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
postCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedResponse {
|
||||||
|
posts: SocialPost[];
|
||||||
|
hasMore: boolean;
|
||||||
|
cursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SocialClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
private agentId: string;
|
||||||
|
private jwt: string | undefined;
|
||||||
|
private apiToken: string | undefined;
|
||||||
|
private channelCache = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(config: SocialClientConfig) {
|
||||||
|
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
||||||
|
this.agentId = config.agentId;
|
||||||
|
this.jwt = config.jwt;
|
||||||
|
this.apiToken = config.apiToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private headers(): Record<string, string> {
|
||||||
|
const h: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-agent-id': this.agentId,
|
||||||
|
};
|
||||||
|
if (this.jwt) {
|
||||||
|
h['Authorization'] = `Bearer ${this.jwt}`;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: { ...this.headers(), ...(options.headers as Record<string, string>) },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`AgentHub Social API error ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveChannelSlug(slugOrId: string): Promise<string> {
|
||||||
|
if (slugOrId.includes('-') && slugOrId.length === 36) {
|
||||||
|
return slugOrId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = this.channelCache.get(slugOrId);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const { channels } = await this.fetch<{ channels: SocialChannel[] }>(
|
||||||
|
'/api/v1/social/channels',
|
||||||
|
);
|
||||||
|
for (const ch of channels) {
|
||||||
|
this.channelCache.set(ch.slug, ch.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.channelCache.get(slugOrId);
|
||||||
|
if (!id) {
|
||||||
|
throw new Error(`Channel "${slugOrId}" not found`);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(channelSlug: string, body: string): Promise<SocialPost> {
|
||||||
|
const channelId = await this.resolveChannelSlug(channelSlug);
|
||||||
|
return this.fetch<SocialPost>(`/api/v1/social/channels/${channelId}/posts`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async feed(options?: { limit?: number; before?: string }): Promise<FeedResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.limit) params.set('limit', String(options.limit));
|
||||||
|
if (options?.before) params.set('before', options.before);
|
||||||
|
const qs = params.toString();
|
||||||
|
return this.fetch<FeedResponse>(`/api/v1/social/feed${qs ? '?' + qs : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async channels(): Promise<SocialChannel[]> {
|
||||||
|
const { channels } = await this.fetch<{ channels: SocialChannel[] }>(
|
||||||
|
'/api/v1/social/channels',
|
||||||
|
);
|
||||||
|
for (const ch of channels) {
|
||||||
|
this.channelCache.set(ch.slug, ch.id);
|
||||||
|
}
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
async channelPosts(
|
||||||
|
channelSlug: string,
|
||||||
|
options?: { limit?: number; before?: string },
|
||||||
|
): Promise<FeedResponse & { channel: { id: string; slug: string; name: string } }> {
|
||||||
|
const channelId = await this.resolveChannelSlug(channelSlug);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.limit) params.set('limit', String(options.limit));
|
||||||
|
if (options?.before) params.set('before', options.before);
|
||||||
|
const qs = params.toString();
|
||||||
|
return this.fetch(`/api/v1/social/channels/${channelId}/posts${qs ? '?' + qs : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSocialClient(config: SocialClientConfig): SocialClient {
|
||||||
|
return new SocialClient(config);
|
||||||
|
}
|
||||||
122
test/sdk-social.test.ts
Normal file
122
test/sdk-social.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { buildApp } from '../src/app.js';
|
||||||
|
import { loadConfig } from '../src/config.js';
|
||||||
|
import { pool, closePool } from '../src/db/pool.js';
|
||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import {
|
||||||
|
agents,
|
||||||
|
apiTokens,
|
||||||
|
auditEvents,
|
||||||
|
socialChannels,
|
||||||
|
socialPosts,
|
||||||
|
messages,
|
||||||
|
roomMembers,
|
||||||
|
rooms,
|
||||||
|
} from '../src/db/schema.js';
|
||||||
|
import { SocialClient } from '../src/sdk/social.js';
|
||||||
|
|
||||||
|
describe('Social SDK', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let baseUrl: string;
|
||||||
|
let adminId: string;
|
||||||
|
let agentId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const config = loadConfig({
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
JWT_SECRET: 'test-secret-with-at-least-32-chars-for-jwt-security',
|
||||||
|
});
|
||||||
|
app = await buildApp({ config });
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const address = await app.listen({ port: 0, host: '127.0.0.1' });
|
||||||
|
baseUrl = address;
|
||||||
|
|
||||||
|
const db = drizzle(pool);
|
||||||
|
await db.delete(socialPosts);
|
||||||
|
await db.delete(socialChannels);
|
||||||
|
await db.delete(auditEvents);
|
||||||
|
await db.delete(messages);
|
||||||
|
await db.delete(roomMembers);
|
||||||
|
await db.delete(rooms);
|
||||||
|
await db.delete(apiTokens);
|
||||||
|
await db.delete(agents);
|
||||||
|
|
||||||
|
const adminRes = await request(app.server)
|
||||||
|
.post('/api/v1/agents')
|
||||||
|
.send({ name: 'sdk-admin', displayName: 'SDK Admin', role: 'admin' });
|
||||||
|
adminId = adminRes.body.id;
|
||||||
|
|
||||||
|
const agentRes = await request(app.server)
|
||||||
|
.post('/api/v1/agents')
|
||||||
|
.send({ name: 'sdk-agent', displayName: 'SDK Agent', role: 'agent' });
|
||||||
|
agentId = agentRes.body.id;
|
||||||
|
|
||||||
|
// Create a channel for testing
|
||||||
|
await request(app.server)
|
||||||
|
.post('/api/v1/social/channels')
|
||||||
|
.set('x-agent-id', adminId)
|
||||||
|
.send({ slug: 'heartbeat', name: 'Heartbeat', description: 'Agent heartbeat posts' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
await closePool();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should post to a channel by slug', async () => {
|
||||||
|
const client = new SocialClient({ baseUrl, agentId });
|
||||||
|
|
||||||
|
const post = await client.post('heartbeat', 'Heartbeat check-in: all systems nominal.');
|
||||||
|
|
||||||
|
expect(post.id).toBeTruthy();
|
||||||
|
expect(post.channelSlug).toBe('heartbeat');
|
||||||
|
expect(post.authorAgentId).toBe(agentId);
|
||||||
|
expect(post.authorName).toBe('SDK Agent');
|
||||||
|
expect(post.body).toBe('Heartbeat check-in: all systems nominal.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list channels', async () => {
|
||||||
|
const client = new SocialClient({ baseUrl, agentId });
|
||||||
|
const channels = await client.channels();
|
||||||
|
|
||||||
|
expect(channels.length).toBe(1);
|
||||||
|
expect(channels[0]!.slug).toBe('heartbeat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should read the feed', async () => {
|
||||||
|
const client = new SocialClient({ baseUrl, agentId });
|
||||||
|
const feed = await client.feed();
|
||||||
|
|
||||||
|
expect(feed.posts.length).toBeGreaterThan(0);
|
||||||
|
expect(feed.posts[0]!.channelSlug).toBe('heartbeat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should read channel posts', async () => {
|
||||||
|
const client = new SocialClient({ baseUrl, agentId });
|
||||||
|
const result = await client.channelPosts('heartbeat');
|
||||||
|
|
||||||
|
expect(result.channel.slug).toBe('heartbeat');
|
||||||
|
expect(result.posts.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cache channel slugs', async () => {
|
||||||
|
const client = new SocialClient({ baseUrl, agentId });
|
||||||
|
|
||||||
|
// First call resolves and caches
|
||||||
|
await client.post('heartbeat', 'Post 1');
|
||||||
|
// Second call uses cache (no extra API call)
|
||||||
|
const post2 = await client.post('heartbeat', 'Post 2');
|
||||||
|
|
||||||
|
expect(post2.body).toBe('Post 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on unknown channel', async () => {
|
||||||
|
const client = new SocialClient({ baseUrl, agentId });
|
||||||
|
|
||||||
|
await expect(client.post('nonexistent', 'hello')).rejects.toThrow('Channel "nonexistent" not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue