diff --git a/src/sdk/index.ts b/src/sdk/index.ts new file mode 100644 index 0000000..04f9c78 --- /dev/null +++ b/src/sdk/index.ts @@ -0,0 +1,2 @@ +export { SocialClient, createSocialClient } from './social.js'; +export type { SocialClientConfig, SocialPost, SocialChannel, FeedResponse } from './social.js'; diff --git a/src/sdk/social.ts b/src/sdk/social.ts new file mode 100644 index 0000000..8917aea --- /dev/null +++ b/src/sdk/social.ts @@ -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(); + + constructor(config: SocialClientConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, ''); + this.agentId = config.agentId; + this.jwt = config.jwt; + this.apiToken = config.apiToken; + } + + private headers(): Record { + const h: Record = { + 'Content-Type': 'application/json', + 'x-agent-id': this.agentId, + }; + if (this.jwt) { + h['Authorization'] = `Bearer ${this.jwt}`; + } + return h; + } + + private async fetch(path: string, options: RequestInit = {}): Promise { + const url = `${this.baseUrl}${path}`; + const response = await fetch(url, { + ...options, + headers: { ...this.headers(), ...(options.headers as Record) }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`AgentHub Social API error ${response.status}: ${text}`); + } + + return response.json() as Promise; + } + + private async resolveChannelSlug(slugOrId: string): Promise { + 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 { + const channelId = await this.resolveChannelSlug(channelSlug); + return this.fetch(`/api/v1/social/channels/${channelId}/posts`, { + method: 'POST', + body: JSON.stringify({ body }), + }); + } + + async feed(options?: { limit?: number; before?: string }): Promise { + 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/feed${qs ? '?' + qs : ''}`); + } + + async channels(): Promise { + 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 { + 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); +} diff --git a/test/sdk-social.test.ts b/test/sdk-social.test.ts new file mode 100644 index 0000000..d9bae80 --- /dev/null +++ b/test/sdk-social.test.ts @@ -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'); + }); +});