Files
zeekay ffe37ab57d feat(slack): Hanzo AI Slack app (@hanzo/slack)
Bolt app bringing Hanzo AI into Slack, built on the shared foundation:
@hanzo/ai (headless client), @hanzo/iam (identity), @hanzo/cards (UI).

- /hanzo slash command → completion, replies with a @hanzo/cards panel
- App Home assistant panel (model picker, quick actions, prompt, sign-in)
  via toSlackBlocks — no hand-written Block Kit
- Interactivity for the stable card action ids (model select, quick
  actions, prompt submit, sign in)
- 'Ask Hanzo' message shortcut + app_mention: pull thread/message context
  via conversations.replies/history, fence it into the prompt
- OAuth install (/slack/install, /slack/oauth_redirect) with a per-workspace
  bot token + per-workspace Hanzo token, server-side only
- Quick actions: Summarize, Draft reply, Explain, Action items
- Endpoints via api.hanzo.ai /v1 only; secrets from env; /health + graceful
  shutdown for ingress/k8s
- One path from Slack to the model (complete); pure modules unit-tested
- README with Slack app manifest + registration steps

Tests: 68 passing. tsc --noEmit clean. Build emits dist/.
2026-07-03 19:15:01 -07:00

74 lines
2.6 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
threadToText,
truncateMessage,
isContentMessage,
renderMessage,
type SlackMessage,
} from '../src/thread.js';
describe('isContentMessage', () => {
it('keeps messages with non-empty text', () => {
expect(isContentMessage({ text: 'hi' })).toBe(true);
expect(isContentMessage({ text: ' ' })).toBe(false);
expect(isContentMessage({})).toBe(false);
});
it('drops join/leave and other noise subtypes', () => {
expect(isContentMessage({ text: 'joined', subtype: 'channel_join' })).toBe(false);
expect(isContentMessage({ text: 'left', subtype: 'channel_leave' })).toBe(false);
});
});
describe('renderMessage', () => {
it('labels by display name when provided, else raw id', () => {
expect(renderMessage({ user: 'U1', text: 'hello' }, { U1: 'Ada' })).toBe('Ada: hello');
expect(renderMessage({ user: 'U2', text: 'hi' })).toBe('U2: hi');
expect(renderMessage({ bot_id: 'B1', text: 'beep' })).toBe('bot: beep');
});
});
describe('threadToText', () => {
const thread: SlackMessage[] = [
{ user: 'U1', text: 'What is our filing deadline?', ts: '1' },
{ user: 'U2', text: 'The motion is due Friday.', ts: '2' },
{ user: 'U1', text: 'joined', subtype: 'channel_join', ts: '3' },
{ user: 'U1', text: 'Thanks, I will draft it.', ts: '4' },
];
it('renders oldest→newest, dropping noise, with name mapping', () => {
const text = threadToText(thread, { names: { U1: 'Ada', U2: 'Lin' } });
expect(text).toBe(
'Ada: What is our filing deadline?\nLin: The motion is due Friday.\nAda: Thanks, I will draft it.',
);
});
it('returns empty string when nothing is content', () => {
expect(threadToText([{ subtype: 'channel_join', text: 'x' }])).toBe('');
expect(threadToText([])).toBe('');
});
it('keeps the MOST RECENT messages when over the char budget', () => {
const many: SlackMessage[] = Array.from({ length: 10 }, (_, i) => ({
user: 'U1',
text: `line ${i}`,
}));
const out = threadToText(many, { maxChars: 30 });
// Oldest omitted; a marker is prepended; newest line is present.
expect(out).toMatch(/earlier messages omitted/);
expect(out).toContain('line 9');
expect(out).not.toContain('line 0');
});
});
describe('truncateMessage', () => {
it('leaves short text untouched (trimmed)', () => {
expect(truncateMessage(' hi ')).toBe('hi');
});
it('cuts and marks long text', () => {
const out = truncateMessage('x'.repeat(100), 10);
expect(out).toBe(`${'x'.repeat(10)}…(truncated)`);
});
});