Feat/s3 storage #2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-kernel-client",
|
"name": "@insignia/iios-kernel-client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ export interface MessageSocketConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Framework-agnostic facade over the `/message` Socket.io namespace (ports the
|
* Framework-agnostic facade over the `/message` Socket.io namespace. No socket.io
|
||||||
* support-sdk MessageClient). No socket.io types leak out; RPCs use emitWithAck.
|
* types leak out; RPCs use emitWithAck. It tracks EVERY joined thread and re-opens
|
||||||
* On reconnect it re-opens the current thread so subscriptions resume with no
|
* all of them on reconnect (the docs' disconnect/reconnect requirement), so a UI
|
||||||
* lost messages (the docs' disconnect/reconnect requirement).
|
* that watches multiple conversations keeps receiving live messages after a drop.
|
||||||
*/
|
*/
|
||||||
export class MessageSocket {
|
export class MessageSocket {
|
||||||
private readonly socket: SocketLike;
|
private readonly socket: SocketLike;
|
||||||
private currentThreadId: string | null = null;
|
private readonly joined = new Set<string>();
|
||||||
|
|
||||||
constructor(config: MessageSocketConfig, socket?: SocketLike) {
|
constructor(config: MessageSocketConfig, socket?: SocketLike) {
|
||||||
this.socket =
|
this.socket =
|
||||||
@@ -26,9 +26,9 @@ export class MessageSocket {
|
|||||||
autoConnect: config.autoConnect ?? true,
|
autoConnect: config.autoConnect ?? true,
|
||||||
}) as unknown as SocketLike);
|
}) as unknown as SocketLike);
|
||||||
|
|
||||||
// Re-open the active thread after a reconnect.
|
// Re-subscribe to every joined thread after a reconnect.
|
||||||
this.socket.on('connect', () => {
|
this.socket.on('connect', () => {
|
||||||
if (this.currentThreadId) void this.socket.emitWithAck('open_thread', { threadId: this.currentThreadId });
|
for (const id of this.joined) void this.socket.emitWithAck('open_thread', { threadId: id });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,27 +40,70 @@ export class MessageSocket {
|
|||||||
this.socket.disconnect();
|
this.socket.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Run `handler` on every (re)connect, and immediately if already connected. */
|
||||||
|
onConnected(handler: () => void): () => void {
|
||||||
|
this.socket.on('connect', handler);
|
||||||
|
if (this.socket.connected) handler();
|
||||||
|
return () => this.socket.off('connect', handler);
|
||||||
|
}
|
||||||
|
|
||||||
/** Subscribe to a server event; returns an unsubscribe fn. */
|
/** Subscribe to a server event; returns an unsubscribe fn. */
|
||||||
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void {
|
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void {
|
||||||
const fn = handler as (...args: unknown[]) => void;
|
const fn = handler as (...args: unknown[]) => void;
|
||||||
this.socket.on(event, fn);
|
this.socket.on(event as string, fn);
|
||||||
return () => this.socket.off(event, fn);
|
return () => this.socket.off(event as string, fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
async openThread(threadId?: string): Promise<OpenThreadResult> {
|
async openThread(
|
||||||
const result = (await this.socket.emitWithAck('open_thread', { threadId })) as OpenThreadResult;
|
threadId?: string,
|
||||||
this.currentThreadId = result.threadId;
|
opts?: { membership?: string; creatorRole?: string; subject?: string },
|
||||||
|
): Promise<OpenThreadResult> {
|
||||||
|
// Timeout (when the transport supports it) so a server error that never acks
|
||||||
|
// can't hang the caller forever.
|
||||||
|
const ack = this.socket.timeout ? this.socket.timeout(8000) : this.socket;
|
||||||
|
const result = (await ack.emitWithAck('open_thread', { threadId, ...opts })) as OpenThreadResult & { error?: string };
|
||||||
|
if (result?.error) throw new Error(result.error);
|
||||||
|
this.joined.add(result.threadId);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(threadId: string, content: string, opts?: { contentRef?: string }): Promise<Message> {
|
async sendMessage(
|
||||||
|
threadId: string,
|
||||||
|
content: string,
|
||||||
|
opts?: {
|
||||||
|
contentRef?: string;
|
||||||
|
parentInteractionId?: string;
|
||||||
|
mentions?: string[];
|
||||||
|
attachment?: { contentRef: string; mimeType: string; sizeBytes: number; checksumSha256?: string };
|
||||||
|
},
|
||||||
|
): Promise<Message> {
|
||||||
return (await this.socket.emitWithAck('send_message', {
|
return (await this.socket.emitWithAck('send_message', {
|
||||||
threadId,
|
threadId,
|
||||||
content,
|
content,
|
||||||
contentRef: opts?.contentRef,
|
contentRef: opts?.attachment?.contentRef ?? opts?.contentRef,
|
||||||
|
mimeType: opts?.attachment?.mimeType,
|
||||||
|
sizeBytes: opts?.attachment?.sizeBytes,
|
||||||
|
checksumSha256: opts?.attachment?.checksumSha256,
|
||||||
|
parentInteractionId: opts?.parentInteractionId,
|
||||||
|
mentions: opts?.mentions, // opaque userId notify-list; the app parses "@", not the kernel
|
||||||
})) as Message;
|
})) as Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pin a message in the thread (shared, generic annotation type "pin"). */
|
||||||
|
async pin(threadId: string, interactionId: string): Promise<void> {
|
||||||
|
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'pin', value: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save a message for myself (personal, generic annotation type "save"). */
|
||||||
|
async save(threadId: string, interactionId: string): Promise<void> {
|
||||||
|
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'save', value: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle an emoji reaction on a message (a generic annotation of type "reaction"). */
|
||||||
|
async react(threadId: string, interactionId: string, value: string): Promise<void> {
|
||||||
|
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'reaction', value });
|
||||||
|
}
|
||||||
|
|
||||||
async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> {
|
async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> {
|
||||||
return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean };
|
return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean };
|
||||||
}
|
}
|
||||||
@@ -68,4 +111,9 @@ export class MessageSocket {
|
|||||||
typing(threadId: string): void {
|
typing(threadId: string): void {
|
||||||
this.socket.emit('typing', { threadId });
|
this.socket.emit('typing', { threadId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tell the server which thread is in the foreground (or null when blurred) — drives presence. */
|
||||||
|
focus(threadId: string | null): void {
|
||||||
|
this.socket.emit('focus_thread', { threadId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
||||||
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem } from './types';
|
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types';
|
||||||
|
|
||||||
export interface RestConfig {
|
export interface RestConfig {
|
||||||
serviceUrl: string;
|
serviceUrl: string;
|
||||||
@@ -57,6 +57,57 @@ export class RestClient {
|
|||||||
return (await r.json()) as InboxItem;
|
return (await r.json()) as InboxItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── auth + threads (app surface) ─────────────────────────────
|
||||||
|
/** Dev IdP login (POST /v1/dev/login). A real IdP issues the same JWT — this is the swap point. */
|
||||||
|
async login(username: string, password: string): Promise<LoginResult> {
|
||||||
|
const r = await fetch(this.url('/v1/dev/login'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (r.status === 401) throw new Error('invalid username or password');
|
||||||
|
if (!r.ok) throw new Error(`login failed (${r.status}) — is the service running with IIOS_DEV_TOKENS=1?`);
|
||||||
|
return (await r.json()) as LoginResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dev directory: known usernames. A real app validates against its user store / MDM. */
|
||||||
|
async listUsers(): Promise<string[]> {
|
||||||
|
const r = await fetch(this.url('/v1/dev/users'), { headers: this.headers() });
|
||||||
|
if (!r.ok) return [];
|
||||||
|
return ((await r.json()) as { users: string[] }).users;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-authoritative conversation list (works cross-device, shows unread + members). */
|
||||||
|
async listThreads(): Promise<ThreadSummary[]> {
|
||||||
|
const r = await fetch(this.url('/v1/threads'), { headers: this.headers() });
|
||||||
|
if (!r.ok) throw new Error(`listThreads ${r.status}`);
|
||||||
|
return (await r.json()) as ThreadSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a thread with generic app attributes (membership hint, creator role, subject/name). */
|
||||||
|
async createThread(opts: { membership?: string; creatorRole?: string; subject?: string }): Promise<{ threadId: string }> {
|
||||||
|
return this.post<{ threadId: string }>('/v1/threads', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** My saved messages (personal bookmarks), newest first, with thread context. */
|
||||||
|
async listSaved(): Promise<SavedItem[]> {
|
||||||
|
const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() });
|
||||||
|
if (!r.ok) throw new Error(`listSaved ${r.status}`);
|
||||||
|
return (await r.json()) as SavedItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Governed add-participant. A policy 403 (e.g. DM cap) surfaces its reason. */
|
||||||
|
async addParticipant(threadId: string, userId: string): Promise<void> {
|
||||||
|
const r = await fetch(this.url(`/v1/threads/${threadId}/participants`), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.headers(),
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
});
|
||||||
|
if (r.ok) return;
|
||||||
|
const body = (await r.json().catch(() => ({}))) as { message?: string };
|
||||||
|
throw new Error(body.message ?? `could not add member (${r.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── support ──────────────────────────────────────────────────
|
// ─── support ──────────────────────────────────────────────────
|
||||||
async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise<Ticket> {
|
async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise<Ticket> {
|
||||||
return this.post<Ticket>('/v1/support/tickets', body);
|
return this.post<Ticket>('/v1/support/tickets', body);
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
|
/** A media/file part attached to a message (contentRef points at object storage). */
|
||||||
|
export interface Attachment {
|
||||||
|
contentRef: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
kind: 'image' | 'video' | 'audio' | 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A generic annotation aggregate on a message (e.g. type "reaction", value = emoji). */
|
||||||
|
export interface AnnotationGroup {
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
users: string[]; // usernames who applied it
|
||||||
|
}
|
||||||
|
|
||||||
/** Wire shapes the kernel emits over socket / returns over REST (align with service). */
|
/** Wire shapes the kernel emits over socket / returns over REST (align with service). */
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
senderActorId: string;
|
senderActorId: string;
|
||||||
|
senderId: string; // sender's email/username — reliable "is this mine?" check
|
||||||
|
senderName: string;
|
||||||
content: string;
|
content: string;
|
||||||
contentRef?: string;
|
contentRef?: string;
|
||||||
|
attachment?: Attachment;
|
||||||
|
parentInteractionId?: string;
|
||||||
|
annotations?: AnnotationGroup[];
|
||||||
traceId?: string;
|
traceId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -26,10 +46,48 @@ export interface TypingEvent {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Broadcast when someone toggles an annotation — carries the refreshed user list. */
|
||||||
|
export interface AnnotationEvent {
|
||||||
|
threadId: string;
|
||||||
|
interactionId: string;
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
op: 'add' | 'remove';
|
||||||
|
users: string[];
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageEvents {
|
export interface MessageEvents {
|
||||||
message: (m: Message) => void;
|
message: (m: Message) => void;
|
||||||
receipt: (e: ReceiptEvent) => void;
|
receipt: (e: ReceiptEvent) => void;
|
||||||
typing: (e: TypingEvent) => void;
|
typing: (e: TypingEvent) => void;
|
||||||
|
annotation: (e: AnnotationEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A "my threads" entry from GET /v1/threads (server-authoritative). */
|
||||||
|
export interface ThreadSummary {
|
||||||
|
threadId: string;
|
||||||
|
subject: string | null;
|
||||||
|
membership?: string; // 'dm' | 'group' (opaque app attribute)
|
||||||
|
participants: string[]; // member usernames
|
||||||
|
participantCount: number;
|
||||||
|
unread: number;
|
||||||
|
muted?: boolean;
|
||||||
|
lastMessage?: string;
|
||||||
|
lastAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A personally-saved message with its thread context (GET /v1/threads/my-annotations?type=save). */
|
||||||
|
export interface SavedItem {
|
||||||
|
message: Message;
|
||||||
|
threadId: string;
|
||||||
|
threadSubject: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of the dev IdP login (POST /v1/dev/login). A real IdP issues the same claims. */
|
||||||
|
export interface LoginResult {
|
||||||
|
token: string;
|
||||||
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
|
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
|
||||||
@@ -188,4 +246,8 @@ export interface SocketLike {
|
|||||||
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
|
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
|
||||||
connect(): unknown;
|
connect(): unknown;
|
||||||
disconnect(): unknown;
|
disconnect(): unknown;
|
||||||
|
/** True while the underlying transport is connected (socket.io exposes this). */
|
||||||
|
connected?: boolean;
|
||||||
|
/** Per-call ack timeout (socket.io). Optional so fakes can omit it. */
|
||||||
|
timeout?(ms: number): { emitWithAck(event: string, ...args: unknown[]): Promise<unknown> };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user