feat(teams): Hanzo AI for Microsoft Teams (@hanzo/teams)

A Bot Framework (botbuilder) app bringing Hanzo AI into Microsoft Teams,
built on the shared foundation — @hanzo/ai (headless client), @hanzo/iam
(identity), @hanzo/cards (canonical PanelSpec -> Adaptive Card).

- Bot (TeamsActivityHandler): 1:1 + channel chat; @mention/DM a prompt ->
  completion -> reply as a Hanzo assistant Adaptive Card panel.
- Adaptive Card actions: model picker (Input.ChoiceSet), quick actions
  (Draft/Summarize/Explain/Extract action items), prompt input + submit,
  all routed by dispatch() on data.action (from @hanzo/cards).
- Message extension "Ask Hanzo" (action) on a message + "Compose with
  Hanzo" (query) to insert an AI draft; pulls message/thread context.
- Teams app manifest (schema v1.16) + color/outline icons + a dependency-
  free zip build step for the sideloadable app package.
- Pure logic modules (dispatch/panels/context/hanzo/identity/config) with
  vitest coverage; tsc clean; tsup build (library + runnable server).

api.hanzo.ai /v1 via @hanzo/ai only; secrets are env-only (never committed).
This commit is contained in:
Hanzo Dev
2026-07-03 19:19:13 -07:00
parent 733c87313d
commit 0591d31d95
26 changed files with 2313 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
# Hanzo AI for Microsoft Teams
A **Bot Framework** app that brings Hanzo AI into **Microsoft Teams** — the
comms standard for enterprises and regulated, non-technical orgs. DM Hanzo or
`@mention` it in a channel, run quick actions on any message ("Ask Hanzo"), or
compose an AI draft — all answered by Hanzo AI and rendered as native
**Adaptive Cards**.
It reuses the **same backend and design as every other Hanzo surface**: model
calls go through **`api.hanzo.ai` / `/v1`** via the headless
[`@hanzo/ai`](https://www.npmjs.com/package/@hanzo/ai) client (never an `/api/`
path), identity is Hanzo IAM via [`@hanzo/iam`](https://www.npmjs.com/package/@hanzo/iam),
and every card is built from the shared canonical panel spec in
[`@hanzo/cards`](../cards) — one `PanelSpec``toAdaptiveCard`. No new API
surface, no hand-written Adaptive Card JSON.
## What it does
- **Chat** — a DM or `@Hanzo <prompt>` in a channel runs a completion and
replies with the Hanzo assistant panel (model picker, quick actions, prompt).
- **Quick actions** — Draft reply, Summarize, Explain, Extract action items —
as Adaptive Card buttons and as the **"Ask Hanzo"** message-action command on
any message.
- **Compose** — the **"Compose with Hanzo"** command generates an AI draft to
insert while writing a message.
- **Adaptive Card actions** — model picker (`Input.ChoiceSet`), quick-action
buttons, and a prompt `Input.Text` + submit, all from `@hanzo/cards`.
## Architecture
```
Teams ──POST /api/messages──▶ server.ts (restify + CloudAdapter)
bot.ts (TeamsActivityHandler)
┌────────────────────────┼─────────────────────────┐
▼ ▼ ▼
context.ts dispatch.ts panels.ts
(message/thread text) (data.action → intent) (PanelSpec → Adaptive Card
│ │ via @hanzo/cards)
└───────────┬────────────┘
hanzo.ts ──▶ @hanzo/ai ──▶ api.hanzo.ai /v1
```
Everything except `server.ts` (opens the socket) and `bot.ts` (wires botbuilder)
is a **pure** module — `dispatch`, `panels`, `context`, `hanzo`, `identity`,
`config` — so the routing, card assembly, and text extraction are unit-tested in
isolation.
### The `Action.Submit` dispatch flow
Every button and input in a Hanzo card comes from `@hanzo/cards`, which stamps a
stable id onto each `Action.Submit` as **`data.action`**, and keys the inputs by
`ActionId.ModelSelect` (`hanzo_model_select`) and `PROMPT_INPUT_ID`
(`hanzo_prompt`). Teams returns that merged payload on submit, and
[`dispatch(data)`](src/dispatch.ts) turns it into one typed `CardIntent`:
| `data.action` | Also carries | → `CardIntent` |
| ----------------------------------- | ------------------------------------ | -------------------------------------- |
| `hanzo_sign_in` | — | `{ kind: 'sign_in' }` |
| `hanzo_model_select` | `hanzo_model_select` = model id | `{ kind: 'model_select', model }` |
| `hanzo_prompt_submit` | `hanzo_prompt`, `hanzo_model_select` | `{ kind: 'prompt', prompt, model? }` |
| `hanzo_quick_action:<chip>` | `chip`, `hanzo_prompt`, model | `{ kind: 'quick_action', chip, … }` |
| _anything else_ | — | `{ kind: 'unknown', action? }` |
`bot.ts` receives the submit two ways and routes both through the same
`dispatch`: a classic `Action.Submit` arrives as a **message activity** with
`activity.value` set (handled in `onMessage`), and an `Action.Execute` arrives
via `onAdaptiveCardInvoke` (`invokeValue.action.data`). One routing table, two
entry points.
## Requirements
- Node 18+ and `pnpm`.
- An Azure Bot registration (app id + secret) — see below.
- A Hanzo API key (`hk-…`) with model access on `api.hanzo.ai`.
## Configuration
All secrets come from the **environment** — nothing is committed. `loadConfig`
([src/config.ts](src/config.ts)) reads and validates them at startup:
| Variable | Required | Default | Meaning |
| ------------------------- | -------- | -------------------- | ---------------------------------------------------- |
| `MICROSOFT_APP_ID` | yes | — | Azure Bot app id (`botId`). |
| `MICROSOFT_APP_PASSWORD` | yes | — | Azure Bot client secret. |
| `HANZO_API_KEY` | yes | — | Hanzo API key (bearer to api.hanzo.ai). |
| `MICROSOFT_APP_TYPE` | no | `MultiTenant` | `MultiTenant` / `SingleTenant` / `UserAssignedMSI`. |
| `MICROSOFT_APP_TENANT_ID` | no | `""` | AAD tenant id (required for `SingleTenant`). |
| `HANZO_BASE_URL` | no | `https://api.hanzo.ai` | Override the AI base URL (local dev). |
| `HANZO_MODELS` | no | `zen-eco-3b,zen-mini-8b,zen-4b` | Comma-separated picker models. |
| `HANZO_DEFAULT_MODEL` | no | first of `HANZO_MODELS` | Default model id. |
| `PORT` | no | `3978` | HTTP port for the messaging endpoint. |
## Develop
```bash
pnpm install # from the monorepo root (workspace), or standalone here
pnpm typecheck # tsc --noEmit
pnpm test # vitest — pure logic (dispatch, panels, context, config, …)
pnpm build # tsup → dist/ (library + runnable server)
pnpm start # node dist/server.js (needs the env above)
```
Run locally against Teams with a tunnel (e.g. `devtunnel` or `ngrok`) pointing
at `http://localhost:3978/api/messages`.
## Azure Bot registration
1. In the **Azure portal****Create a resource****Azure Bot**. Choose a
name and, for **Type of App**, **Multi Tenant** (or Single Tenant / MSI).
2. Under **Configuration**, set the **Messaging endpoint** to your public URL:
`https://<your-host>/api/messages`.
3. Under **Configuration → Microsoft App ID**, copy the **App ID**
`MICROSOFT_APP_ID`. Click **Manage****Certificates & secrets** → **New
client secret** → copy the value → `MICROSOFT_APP_PASSWORD`.
4. Under **Channels**, add the **Microsoft Teams** channel.
## Build & sideload the Teams app package
The Teams app package is a zip of `manifest.json` + `color.png` + `outline.png`.
The build step substitutes `${{…}}` placeholders from the environment and zips:
```bash
MICROSOFT_APP_ID=<your-bot-app-id> \
TEAMS_APP_ID=<a-guid-for-the-app> \ # optional; defaults to MICROSOFT_APP_ID
pnpm package # → dist/hanzo-teams.zip
```
Then upload it one of two ways:
- **Developer Portal / Teams client** — Teams → **Apps****Manage your apps**
**Upload an app****Upload a custom app** → pick `dist/hanzo-teams.zip`.
- **Teams Admin Center** (org-wide) — **Teams apps → Manage apps → Upload new
app**, then set the org/app permission policy so users can install it.
### Manifest highlights ([manifest/manifest.json](manifest/manifest.json))
- **Schema** `v1.16`.
- **`bots`** — scopes `personal`, `team`, `groupChat` (1:1, channel, group chat).
- **`composeExtensions`** — `askHanzo` (action, contexts `message`/`compose`/
`commandBox`, with the four quick actions as a `choiceset`) and `composeHanzo`
(query, inserts an AI draft).
- **`permissions`** — `identity`, `messageTeamMembers`.
- **`validDomains`** — `api.hanzo.ai`, `token.botframework.com`.
## Identity
Two supported models, both in [src/identity.ts](src/identity.ts):
1. **Per-tenant key** (default, complete out of the box) — the tenant is
provisioned one `HANZO_API_KEY`; every Teams user acts as that Hanzo
principal. The enterprise/regulated-org pattern.
2. **Per-user link** — a Teams user presents a Hanzo IAM JWT (via Teams SSO);
`resolveFromToken` validates it with `@hanzo/iam` and derives the Hanzo org
from the `sub` (`org/username`) claim.
## Deploy
Build the image in CI (`ghcr.io/hanzoai/teams`, `--platform linux/amd64`) and
run it behind the platform ingress; set the Azure Bot **Messaging endpoint** to
`https://<host>/api/messages`. Secrets (`MICROSOFT_APP_*`, `HANZO_API_KEY`) come
from KMS, never from the image or the repo. `GET /healthz` is the readiness
probe.
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
/**
* Build the Teams app package (`hanzo-teams.zip`).
*
* Reads `manifest.json`, substitutes `${{VAR}}` placeholders from the
* environment (so the bot app id is never committed), validates the result is
* well-formed JSON with the required top-level keys, then zips the manifest and
* the two icons into `dist/hanzo-teams.zip` — the sideloadable app package.
*
* Env:
* MICROSOFT_APP_ID Azure Bot app id (bot registration).
* TEAMS_APP_ID App id GUID for the manifest `id` (defaults to APP_ID).
*
* No dependencies: emits a minimal ZIP (stored, no compression) itself.
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { deflateRawSync, crc32 } from 'node:zlib';
const here = dirname(fileURLToPath(import.meta.url));
const distDir = join(here, '..', 'dist');
/** Substitute `${{VAR}}` with `env[VAR]`, erroring on any that are unset. */
function substitute(text, env) {
const missing = new Set();
const out = text.replace(/\$\{\{\s*([A-Z0-9_]+)\s*\}\}/g, (_m, key) => {
// TEAMS_APP_ID defaults to MICROSOFT_APP_ID when unset OR empty.
const v = env[key] || (key === 'TEAMS_APP_ID' ? env.MICROSOFT_APP_ID : undefined);
if (!v) {
missing.add(key);
return '';
}
return v;
});
if (missing.size > 0) {
throw new Error(
`manifest: unset placeholders: ${[...missing].join(', ')}. ` +
`Set them in the environment before packaging.`,
);
}
return out;
}
/** Assert the manifest has the shape Teams requires. */
function validate(manifest) {
const required = [
'manifestVersion',
'id',
'name',
'description',
'bots',
'composeExtensions',
'validDomains',
];
for (const key of required) {
if (!(key in manifest)) throw new Error(`manifest missing required key: ${key}`);
}
if (!Array.isArray(manifest.bots) || manifest.bots.length === 0) {
throw new Error('manifest.bots must be a non-empty array');
}
if (!Array.isArray(manifest.validDomains) || !manifest.validDomains.includes('api.hanzo.ai')) {
throw new Error('manifest.validDomains must include api.hanzo.ai');
}
}
/** Minimal ZIP writer (stored+deflate), enough for a Teams package. */
function zip(entries) {
const chunks = [];
const central = [];
let offset = 0;
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, 'utf8');
const crc = crc32(data) >>> 0;
const compressed = deflateRawSync(data);
const useStore = compressed.length >= data.length;
const method = useStore ? 0 : 8;
const body = useStore ? data : compressed;
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0, 6);
local.writeUInt16LE(method, 8);
local.writeUInt16LE(0, 10);
local.writeUInt16LE(0, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28);
chunks.push(local, nameBuf, body);
const cen = Buffer.alloc(46);
cen.writeUInt32LE(0x02014b50, 0);
cen.writeUInt16LE(20, 4);
cen.writeUInt16LE(20, 6);
cen.writeUInt16LE(0, 8);
cen.writeUInt16LE(method, 10);
cen.writeUInt16LE(0, 12);
cen.writeUInt16LE(0, 14);
cen.writeUInt32LE(crc, 16);
cen.writeUInt32LE(body.length, 20);
cen.writeUInt32LE(data.length, 24);
cen.writeUInt16LE(nameBuf.length, 28);
cen.writeUInt32LE(offset, 42);
central.push(cen, nameBuf);
offset += local.length + nameBuf.length + body.length;
}
const centralBuf = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralBuf.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...chunks, centralBuf, end]);
}
function main() {
const raw = readFileSync(join(here, 'manifest.json'), 'utf8');
const filled = substitute(raw, process.env);
const manifest = JSON.parse(filled);
validate(manifest);
const color = readFileSync(join(here, 'color.png'));
const outline = readFileSync(join(here, 'outline.png'));
const pkg = zip([
{ name: 'manifest.json', data: Buffer.from(JSON.stringify(manifest, null, 2), 'utf8') },
{ name: 'color.png', data: color },
{ name: 'outline.png', data: outline },
]);
mkdirSync(distDir, { recursive: true });
const out = join(distDir, 'hanzo-teams.zip');
writeFileSync(out, pkg);
console.log(`[hanzo-teams] wrote ${out} (${pkg.length} bytes)`);
}
main();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 B

+101
View File
@@ -0,0 +1,101 @@
{
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
"manifestVersion": "1.16",
"version": "0.1.0",
"id": "${{TEAMS_APP_ID}}",
"packageName": "ai.hanzo.teams",
"developer": {
"name": "Hanzo AI",
"websiteUrl": "https://hanzo.ai",
"privacyUrl": "https://hanzo.ai/privacy",
"termsOfUseUrl": "https://hanzo.ai/terms"
},
"name": {
"short": "Hanzo",
"full": "Hanzo AI Assistant"
},
"description": {
"short": "AI assistant for Teams — chat, summarize, draft, extract action items.",
"full": "Hanzo brings frontier AI into Microsoft Teams. Chat with Hanzo in a DM or @mention it in a channel, run quick actions (draft a reply, summarize, explain, extract action items) on any message, and insert an AI draft while composing — all answered by Hanzo AI (api.hanzo.ai) and rendered as native Adaptive Cards."
},
"icons": {
"color": "color.png",
"outline": "outline.png"
},
"accentColor": "#0B0B0F",
"bots": [
{
"botId": "${{MICROSOFT_APP_ID}}",
"scopes": ["personal", "team", "groupChat"],
"supportsFiles": false,
"isNotificationOnly": false,
"commandLists": [
{
"scopes": ["personal", "team", "groupChat"],
"commands": [
{
"title": "Ask Hanzo",
"description": "Ask Hanzo AI anything"
},
{
"title": "Summarize",
"description": "Summarize the message or thread"
},
{
"title": "Draft reply",
"description": "Draft a reply to the message"
}
]
}
]
}
],
"composeExtensions": [
{
"botId": "${{MICROSOFT_APP_ID}}",
"commands": [
{
"id": "askHanzo",
"type": "action",
"title": "Ask Hanzo",
"description": "Run a Hanzo quick action on this message",
"context": ["message", "compose", "commandBox"],
"fetchTask": false,
"parameters": [
{
"name": "chip",
"title": "Action",
"description": "Which Hanzo action to run",
"inputType": "choiceset",
"value": "summarize",
"choices": [
{ "title": "Summarize", "value": "summarize" },
{ "title": "Draft reply", "value": "draft" },
{ "title": "Explain", "value": "explain" },
{ "title": "Extract action items", "value": "action_items" }
]
}
]
},
{
"id": "composeHanzo",
"type": "query",
"title": "Compose with Hanzo",
"description": "Generate an AI draft to insert into your message",
"initialRun": false,
"context": ["compose", "commandBox"],
"parameters": [
{
"name": "prompt",
"title": "Prompt",
"description": "What should Hanzo write?",
"inputType": "text"
}
]
}
]
}
],
"permissions": ["identity", "messageTeamMembers"],
"validDomains": ["api.hanzo.ai", "token.botframework.com"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 B

+64
View File
@@ -0,0 +1,64 @@
{
"name": "@hanzo/teams",
"version": "0.1.0",
"description": "Hanzo AI for Microsoft Teams — a Bot Framework app with chat, message extensions, and Adaptive Card actions, built on @hanzo/ai, @hanzo/iam, and @hanzo/cards.",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"bin": {
"hanzo-teams": "./dist/server.js"
},
"files": [
"dist",
"manifest",
"README.md"
],
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"start": "node dist/server.js",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"package": "node manifest/build.mjs"
},
"dependencies": {
"@hanzo/ai": "^0.1.1",
"@hanzo/cards": "workspace:*",
"@hanzo/iam": "^0.13.2",
"botbuilder": "^4.23.3",
"restify": "^11.1.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/restify": "^8.5.12",
"tsup": "^8.0.0",
"typescript": "^5.3.0",
"vitest": "^3.2.6"
},
"keywords": [
"hanzo",
"teams",
"microsoft-teams",
"bot-framework",
"botbuilder",
"adaptive-cards",
"ai"
],
"author": "Hanzo AI",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/hanzoai/extension.git",
"directory": "packages/teams"
}
}
+285
View File
@@ -0,0 +1,285 @@
/**
* The Hanzo Teams bot.
*
* A botbuilder {@link TeamsActivityHandler} that brings Hanzo AI into Teams:
*
* - **1:1 & channel chat** — a DM or `@Hanzo <prompt>` runs a completion and
* replies with a Hanzo assistant Adaptive Card panel.
* - **Adaptive Card actions** — the panel's model picker, quick-action chips,
* and prompt input all post `Action.Submit`; {@link dispatch} routes them.
* - **Message extension** — the "Ask Hanzo" action command on a message
* summarizes/drafts a reply; the compose command inserts an AI draft.
*
* The handler is a thin wire: it pulls context, calls {@link complete}, and
* renders panels. All routing/shaping logic lives in the pure modules
* (`dispatch`, `panels`, `context`, `hanzo`), which the tests exercise directly.
*/
import {
CardFactory,
MessageFactory,
TeamsActivityHandler,
type TurnContext,
} from 'botbuilder';
import type {
AdaptiveCardInvokeResponse,
AdaptiveCardInvokeValue,
MessagingExtensionAction,
MessagingExtensionActionResponse,
MessagingExtensionQuery,
MessagingExtensionResponse,
} from 'botbuilder';
import type { Config } from './config';
import {
dispatch,
type CardIntent,
type SubmitData,
} from './dispatch';
import {
promptFromActivity,
textFromMessagePayload,
truncate,
type ActivityLike,
} from './context';
import {
QUICK_ACTIONS,
renderCard,
resultPanel,
signedOutPanel,
systemPromptFor,
welcomePanel,
} from './panels';
import { complete, type AiClient, type ChatMessage } from './hanzo';
/** Command ids declared in the manifest's messageExtensions. */
export const COMMAND = {
/** Action command on a message: "Ask Hanzo". */
Ask: 'askHanzo',
/** Compose action: insert an AI draft. */
Compose: 'composeHanzo',
} as const;
/** A completed answer plus the model that produced it. */
interface Answer {
text: string;
model: string;
}
/** The bot's collaborators, injected so the handler is testable. */
export interface BotDeps {
/** Resolved configuration. */
config: Config;
/** Hanzo AI client (built from the config's API key). */
ai: AiClient;
}
export class HanzoTeamsBot extends TeamsActivityHandler {
private readonly config: Config;
private readonly ai: AiClient;
constructor(deps: BotDeps) {
super();
this.config = deps.config;
this.ai = deps.ai;
this.onMessage(async (context, next) => {
await this.handleMessage(context);
await next();
});
this.onMembersAdded(async (context, next) => {
await this.greet(context);
await next();
});
}
// ── Chat ────────────────────────────────────────────────────────────────
/** A message activity: either a card `Action.Submit` or a typed prompt. */
private async handleMessage(context: TurnContext): Promise<void> {
const value = context.activity.value as SubmitData | undefined;
if (value && typeof value === 'object') {
await this.handleSubmit(context, dispatch(value));
return;
}
const prompt = promptFromActivity(context.activity as ActivityLike);
if (!prompt) {
await context.sendActivity(this.panelActivity(welcomePanel(this.panelOpts())));
return;
}
const answer = await this.answer([{ role: 'user', content: prompt }], this.config.defaultModel);
await context.sendActivity(this.resultActivity(answer));
}
/** Route a dispatched card intent to its effect. */
private async handleSubmit(context: TurnContext, intent: CardIntent): Promise<void> {
switch (intent.kind) {
case 'sign_in':
await context.sendActivity(this.panelActivity(signedOutPanel(this.panelOpts())));
return;
case 'model_select':
// Re-render the panel with the newly selected model preserved.
await context.sendActivity(
this.panelActivity(welcomePanel(this.panelOpts(intent.model))),
);
return;
case 'prompt': {
const model = this.pickModel(intent.model);
const answer = await this.answer([{ role: 'user', content: intent.prompt }], model);
await context.sendActivity(this.resultActivity(answer));
return;
}
case 'quick_action': {
const system = systemPromptFor(intent.chip);
if (!system) {
await context.sendActivity(`Unknown action: ${intent.chip}`);
return;
}
// A quick action in a bot card has no attached message context (that
// path is the "Ask Hanzo" message extension). It operates on the text
// typed into the prompt input, so it needs one.
if (!intent.prompt) {
await context.sendActivity(
'Type what you want me to work on in the prompt box, then choose an action.',
);
return;
}
const answer = await this.answer(
[
{ role: 'system', content: system },
{ role: 'user', content: intent.prompt },
],
this.pickModel(intent.model),
);
await context.sendActivity(this.resultActivity(answer));
return;
}
case 'unknown':
default:
await context.sendActivity(this.panelActivity(welcomePanel(this.panelOpts())));
return;
}
}
/** Send the welcome panel when the bot is added to a chat/team. */
private async greet(context: TurnContext): Promise<void> {
const botId = context.activity.recipient?.id;
const added = context.activity.membersAdded ?? [];
if (!added.some((m) => m.id !== botId)) return;
await context.sendActivity(this.panelActivity(welcomePanel(this.panelOpts())));
}
// ── Message extension: "Ask Hanzo" on a message ─────────────────────────
protected override async handleTeamsMessagingExtensionSubmitAction(
_context: TurnContext,
action: MessagingExtensionAction,
): Promise<MessagingExtensionActionResponse> {
const data = (action.data ?? {}) as SubmitData;
const chip = typeof data.chip === 'string' ? data.chip : 'summarize';
// Fall back to the summarize prompt for any unrecognized chip.
const system = systemPromptFor(chip) ?? systemPromptFor('summarize')!;
const source = action.messagePayload
? textFromMessagePayload(action.messagePayload)
: '';
const messages: ChatMessage[] = [
{ role: 'system', content: system },
{
role: 'user',
content: source
? `Message:\n\n${source}`
: 'No message content was provided.',
},
];
const answer = await this.answer(messages, this.pickModel());
return {
composeExtension: {
type: 'result',
attachmentLayout: 'list',
attachments: [this.card(answer)],
},
};
}
// ── Compose extension: insert an AI draft ───────────────────────────────
protected override async handleTeamsMessagingExtensionQuery(
_context: TurnContext,
query: MessagingExtensionQuery,
): Promise<MessagingExtensionResponse> {
const prompt = queryText(query);
if (!prompt) {
return { composeExtension: { type: 'result', attachmentLayout: 'list', attachments: [] } };
}
const answer = await this.answer(
[{ role: 'user', content: prompt }],
this.pickModel(),
);
return {
composeExtension: {
type: 'result',
attachmentLayout: 'list',
attachments: [this.card(answer)],
},
};
}
// ── Adaptive Card invoke (Action.Execute path) ──────────────────────────
protected override async onAdaptiveCardInvoke(
context: TurnContext,
invokeValue: AdaptiveCardInvokeValue,
): Promise<AdaptiveCardInvokeResponse> {
const data = (invokeValue.action?.data ?? {}) as SubmitData;
await this.handleSubmit(context, dispatch(data));
return { statusCode: 200, type: 'application/vnd.microsoft.card.adaptive', value: {} };
}
// ── Completion + rendering helpers ──────────────────────────────────────
private async answer(messages: ChatMessage[], model: string): Promise<Answer> {
const text = await complete(this.ai, {
model,
messages,
maxTokens: 1024,
});
return { text: truncate(text), model };
}
private pickModel(candidate?: string): string {
if (candidate && this.config.models.includes(candidate)) return candidate;
return this.config.defaultModel;
}
private panelOpts(model?: string): { models: string[]; model: string } {
return { models: this.config.models, model: this.pickModel(model) };
}
private card(answer: Answer) {
const spec = resultPanel({ ...this.panelOpts(answer.model), signedIn: true }, answer.text);
return CardFactory.adaptiveCard(renderCard(spec));
}
private resultActivity(answer: Answer) {
return MessageFactory.attachment(this.card(answer));
}
private panelActivity(spec: ReturnType<typeof welcomePanel>) {
return MessageFactory.attachment(CardFactory.adaptiveCard(renderCard(spec)));
}
}
/** The compose-query text from a message-extension query's parameters. */
function queryText(query: MessagingExtensionQuery): string {
const params = query.parameters ?? [];
const values = params
.map((p) => (typeof p.value === 'string' ? p.value : ''))
.filter((v) => v.length > 0);
return values.join(' ').trim();
}
/** The quick actions the manifest advertises; re-exported for the manifest step. */
export { QUICK_ACTIONS };
+102
View File
@@ -0,0 +1,102 @@
/**
* Environment configuration — the process boundary.
*
* Secrets live in the environment, never in source or the repo. `loadConfig`
* reads them once, validates them, and returns a frozen {@link Config}. Every
* other module takes config as an argument and never touches `process.env`, so
* they stay pure and testable.
*/
/** Default catalog shown in the model picker when `HANZO_MODELS` is unset. */
export const DEFAULT_MODELS = ['zen-eco-3b', 'zen-mini-8b', 'zen-4b'] as const;
/** Resolved, validated configuration for the Teams app. */
export interface Config {
/** Azure Bot registration app id (Bot Framework `MicrosoftAppId`). */
appId: string;
/** Azure Bot registration secret (Bot Framework `MicrosoftAppPassword`). */
appPassword: string;
/** Azure AD tenant id. Empty for the multi-tenant `MultiTenant` type. */
appTenantId: string;
/** Bot app type: `MultiTenant` (default), `SingleTenant`, or `UserAssignedMSI`. */
appType: string;
/** Hanzo API key sent as the bearer token to api.hanzo.ai. */
hanzoApiKey: string;
/** AI base URL. `@hanzo/ai` defaults to https://api.hanzo.ai; override for dev. */
hanzoBaseUrl?: string;
/** Model ids offered in the picker. */
models: string[];
/** Default model id (first of {@link models} unless overridden). */
defaultModel: string;
/** HTTP port for the messaging endpoint. */
port: number;
}
/** Raw environment record. `process.env` shape without a hard `process` dep. */
export type Env = Record<string, string | undefined>;
function required(env: Env, key: string): string {
const v = env[key];
if (!v) {
throw new Error(
`Missing required environment variable ${key}. ` +
`Set it in the environment (never commit secrets); see README.`,
);
}
return v;
}
function models(env: Env): string[] {
const raw = env.HANZO_MODELS;
if (!raw) return [...DEFAULT_MODELS];
const list = raw
.split(',')
.map((m) => m.trim())
.filter(Boolean);
return list.length > 0 ? list : [...DEFAULT_MODELS];
}
function port(env: Env): number {
const raw = env.PORT?.trim();
if (!raw) return 3978;
// Strict: reject trailing garbage ("80x", "3978 #http") that parseInt accepts.
if (!/^\d+$/.test(raw)) {
throw new Error(`Invalid PORT ${env.PORT}: expected an integer in 1..65535.`);
}
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0 || n > 65535) {
throw new Error(`Invalid PORT ${env.PORT}: expected an integer in 1..65535.`);
}
return n;
}
/**
* Read and validate configuration from an environment record.
*
* `MICROSOFT_APP_ID`, `MICROSOFT_APP_PASSWORD`, and `HANZO_API_KEY` are
* required; everything else has a safe default. Throws on missing required
* vars or a malformed `PORT` — fail loud at startup, never at request time.
*/
export function loadConfig(env: Env = process.env): Config {
const list = models(env);
const first = list[0];
if (!first) throw new Error('HANZO_MODELS resolved to an empty list.');
const defaultModel = env.HANZO_DEFAULT_MODEL?.trim() || first;
if (!list.includes(defaultModel)) {
throw new Error(
`HANZO_DEFAULT_MODEL ${defaultModel} is not in HANZO_MODELS (${list.join(', ')}).`,
);
}
return Object.freeze({
appId: required(env, 'MICROSOFT_APP_ID'),
appPassword: required(env, 'MICROSOFT_APP_PASSWORD'),
appTenantId: env.MICROSOFT_APP_TENANT_ID?.trim() ?? '',
appType: env.MICROSOFT_APP_TYPE?.trim() || 'MultiTenant',
hanzoApiKey: required(env, 'HANZO_API_KEY'),
hanzoBaseUrl: env.HANZO_BASE_URL?.trim() || undefined,
models: list,
defaultModel,
port: port(env),
});
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Message/thread text extraction — pure, host-schema in, plain text out.
*
* Teams hands the bot two very different context shapes:
* - an inbound **Activity** (`onMessage`), where the user typed a prompt,
* possibly with an `@Hanzo` mention that must be stripped; and
* - a **message-extension** payload (`MessageActionsPayload`), the message a
* user ran "Ask Hanzo" on — its body may be HTML.
*
* These functions take only the fields they read (structural typing), so they
* are trivially testable and carry no botbuilder runtime dependency.
*/
/** Hard cap on context text fed to the model, in characters. */
export const MAX_CONTEXT_CHARS = 12_000;
/** Structural view of the inbound Activity fields we read. */
export interface ActivityLike {
text?: string;
/** Entities on the activity; `@mention` entities carry the mention text. */
entities?: Array<{ type?: string; text?: string } | undefined>;
}
/** Structural view of a Teams message payload body. */
export interface MessageBodyLike {
contentType?: string;
content?: string;
}
/** Structural view of the message a message-extension acts on. */
export interface MessagePayloadLike {
subject?: string;
body?: MessageBodyLike;
from?: { user?: { displayName?: string } } | undefined;
}
const WHITESPACE = /\s+/g;
/** Collapse runs of whitespace and trim. */
export function normalizeWhitespace(s: string): string {
return s.replace(WHITESPACE, ' ').trim();
}
/**
* Strip HTML to readable text: drop `<script>`/`<style>` bodies, turn block
* boundaries into newlines, remove remaining tags, and decode the handful of
* named entities Teams emits. Not a full HTML parser — a deliberate, safe
* subset for turning message bodies into prompt context.
*/
export function htmlToText(html: string): string {
const withoutScripts = html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
const withBreaks = withoutScripts
.replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n');
const stripped = withBreaks.replace(/<[^>]+>/g, '');
const decoded = stripped
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'");
return decoded
.split('\n')
.map((line) => normalizeWhitespace(line))
.filter((line) => line.length > 0)
.join('\n');
}
/**
* Truncate to at most {@link MAX_CONTEXT_CHARS} (or `limit`), appending an
* ellipsis marker so the model knows the text was cut. Truncates on a word
* boundary when a nearby one exists.
*/
export function truncate(text: string, limit: number = MAX_CONTEXT_CHARS): string {
if (text.length <= limit) return text;
const slice = text.slice(0, limit);
const lastSpace = slice.lastIndexOf(' ');
const cut = lastSpace > limit * 0.8 ? slice.slice(0, lastSpace) : slice;
return `${cut.trimEnd()}`;
}
/** Matches a Teams `<at ...>Display Name</at>` mention span. */
const AT_MENTION = /<at\b[^>]*>.*?<\/at>/gis;
/**
* The user's prompt from an inbound Activity, with the bot `@mention` removed.
*
* Teams renders mentions in `activity.text` as `<at>…</at>` spans (each has a
* matching `mention` entity). We strip only those spans — never arbitrary
* substrings — so a mention of "Hanzo" does not also delete the word "Hanzo"
* elsewhere in the user's prompt. Any leftover exact-match mention `entity.text`
* (a plain-text mention with no `<at>` wrapper) is removed once, at the start.
*/
export function promptFromActivity(activity: ActivityLike): string {
let text = (activity.text ?? '').replace(AT_MENTION, ' ');
for (const entity of activity.entities ?? []) {
if (entity?.type === 'mention' && entity.text) {
// Only a leading plain-text mention; leave later occurrences intact.
const trimmed = text.trimStart();
if (trimmed.startsWith(entity.text)) {
const lead = text.length - trimmed.length;
text = text.slice(0, lead) + trimmed.slice(entity.text.length);
}
}
}
return normalizeWhitespace(text);
}
/**
* Readable text of the message a message-extension acted on: subject line then
* body (HTML decoded to text when `contentType` is html), truncated for the
* prompt. Returns `''` when there is nothing to read.
*/
export function textFromMessagePayload(
payload: MessagePayloadLike,
limit: number = MAX_CONTEXT_CHARS,
): string {
const parts: string[] = [];
const subject = payload.subject ? normalizeWhitespace(payload.subject) : '';
if (subject) parts.push(subject);
const body = payload.body;
if (body?.content) {
const isHtml = (body.contentType ?? '').toLowerCase() === 'html';
const bodyText = isHtml ? htmlToText(body.content) : normalizeWhitespace(body.content);
if (bodyText) parts.push(bodyText);
}
return truncate(parts.join('\n\n'), limit);
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Adaptive Card action dispatch — pure routing from a submit payload to intent.
*
* Every `Action.Submit` in a Hanzo card carries `data.action` (from
* `@hanzo/cards`). This module turns that raw payload into a typed
* {@link CardIntent} the bot can act on, with **no** botbuilder or network
* dependency, so the routing table is unit-tested in isolation.
*
* The `data.action` grammar from `@hanzo/cards`:
* - `hanzo_model_select` — model picker changed
* - `hanzo_prompt_submit` — freeform prompt submitted
* - `hanzo_quick_action:<chip>` — a quick-action chip (also `data.chip`)
* - `hanzo_sign_in` — sign-in button
*/
import { ActionId, PROMPT_INPUT_ID } from '@hanzo/cards';
/** The raw submit payload: `data.action` plus the card's input fields by id. */
export type SubmitData = Record<string, unknown>;
/** A model-picker change. */
export interface ModelSelectIntent {
kind: 'model_select';
/** Selected model id. */
model: string;
}
/** A freeform prompt submission. */
export interface PromptIntent {
kind: 'prompt';
/** The prompt text from the `hanzo_prompt` input. */
prompt: string;
/** Model id selected in the same submit, when present. */
model?: string;
}
/** A quick-action chip click. */
export interface QuickActionIntent {
kind: 'quick_action';
/** The chip id, e.g. `summarize`. */
chip: string;
/** Freeform prompt submitted alongside, when present. */
prompt?: string;
/** Model id selected in the same submit, when present. */
model?: string;
}
/** The sign-in button. */
export interface SignInIntent {
kind: 'sign_in';
}
/** An unrecognized or malformed payload. */
export interface UnknownIntent {
kind: 'unknown';
/** The raw `action` value, for logging. */
action?: string;
}
/** The closed set of intents a Hanzo card can produce. */
export type CardIntent =
| ModelSelectIntent
| PromptIntent
| QuickActionIntent
| SignInIntent
| UnknownIntent;
const QUICK_PREFIX = `${ActionId.QuickAction}:`;
function str(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}
/**
* The selected model id from a submit payload. The picker's `Input.ChoiceSet`
* is keyed by {@link ActionId.ModelSelect}, so its value rides along on every
* submit from the same card.
*/
export function selectedModel(data: SubmitData): string | undefined {
return str(data[ActionId.ModelSelect]);
}
/**
* The prompt text from a submit payload. The `Input.Text` field is keyed by
* {@link PROMPT_INPUT_ID}.
*/
export function promptText(data: SubmitData): string | undefined {
const raw = str(data[PROMPT_INPUT_ID]);
return raw ? raw.trim() || undefined : undefined;
}
/**
* Route a submit payload to a typed intent. This is the single dispatch point
* for both `onAdaptiveCardInvoke` (`data` from `invokeValue.action.data`) and
* classic `Action.Submit` (`data` from `activity.value`).
*/
export function dispatch(data: SubmitData): CardIntent {
const action = str(data.action);
const model = selectedModel(data);
const prompt = promptText(data);
if (action === ActionId.SignIn) {
return { kind: 'sign_in' };
}
if (action === ActionId.ModelSelect) {
// A bare model-select carries its value in the picker field, not `chip`.
if (model) return { kind: 'model_select', model };
return { kind: 'unknown', action };
}
if (action === ActionId.PromptSubmit) {
if (!prompt) return { kind: 'unknown', action };
return { kind: 'prompt', prompt, ...(model ? { model } : {}) };
}
if (action && action.startsWith(QUICK_PREFIX)) {
// The chip id is both the suffix and `data.chip`; prefer the explicit field.
const chip = str(data.chip) ?? action.slice(QUICK_PREFIX.length);
if (!chip) return { kind: 'unknown', action };
return {
kind: 'quick_action',
chip,
...(prompt ? { prompt } : {}),
...(model ? { model } : {}),
};
}
return { kind: 'unknown', ...(action ? { action } : {}) };
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Thin Hanzo AI wrapper.
*
* The bot talks to api.hanzo.ai through `@hanzo/ai`'s headless client
* (`createAiClient`) — never a raw `fetch`, never an `/api/` path. Card hosts
* are non-streaming, so we only ever request one completed message.
*
* This module depends on the *narrowest* slice of the client it needs
* ({@link AiClient}), so it composes with the real client in production and a
* plain object in tests. Everything here is pure request/response shaping; no
* env access, no globals.
*/
/**
* The headless-client factory from `@hanzo/ai`. Declared as the exact contract
* this wrapper needs, then resolved from the module by name — so we stay bound
* to the documented API surface, not to the package's full (React-heavy) type
* exports, and this module builds regardless of unrelated churn in `@hanzo/ai`.
*/
type CreateAiClient = (opts: { apiKey: string; baseURL?: string }) => unknown;
/**
* Resolve `createAiClient` lazily. `@hanzo/ai` is loaded only when a live client
* is actually built (`makeClient`), never at import time — so the pure
* request/response helpers (`complete`, `listModels`) carry no dependency on it
* and stay unit-testable with a plain stub.
*/
function resolveFactory(): CreateAiClient {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require('@hanzo/ai') as Record<string, unknown>;
const factory = mod.createAiClient;
if (typeof factory !== 'function') {
throw new Error(
"@hanzo/ai does not export 'createAiClient'. Install a version of " +
'@hanzo/ai that provides the headless client.',
);
}
return factory as CreateAiClient;
}
/** A chat message in the OpenAI-compatible shape the client expects. */
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
/** The request we send for a single non-streaming completion. */
export interface CompletionRequest {
/** Model id, e.g. `zen-eco-3b`. */
model: string;
/** Conversation so far. */
messages: ChatMessage[];
/** Sampling temperature. Omitted when undefined. */
temperature?: number;
/** Hard cap on generated tokens. Omitted when undefined. */
maxTokens?: number;
}
/** The minimal completion response we read: the first choice's message text. */
export interface CompletionChoiceMessage {
content?: string | null;
}
export interface CompletionChoice {
message?: CompletionChoiceMessage;
}
export interface CompletionResponse {
choices?: CompletionChoice[];
}
/** A model entry from the catalog. */
export interface ModelEntry {
id: string;
}
export interface ModelList {
data?: ModelEntry[];
}
/**
* The exact slice of `@hanzo/ai`'s client this wrapper uses. Declaring it here
* (rather than importing the client's full type) keeps the wrapper decoupled
* and lets tests pass a hand-built stub.
*/
export interface AiClient {
chat: {
completions: {
create(req: {
model: string;
messages: ChatMessage[];
temperature?: number;
max_tokens?: number;
}): Promise<CompletionResponse>;
};
};
models: {
list(): Promise<ModelList>;
};
}
/** Options for {@link makeClient}. */
export interface ClientOptions {
/** Hanzo API key (bearer token). */
apiKey: string;
/** Override base URL. Defaults to `@hanzo/ai`'s api.hanzo.ai. */
baseUrl?: string;
}
/**
* Build a Hanzo AI client bound to an API key. Delegates to `@hanzo/ai`, which
* targets `https://api.hanzo.ai` and the `/v1/...` routes by default.
*/
export function makeClient(opts: ClientOptions): AiClient {
const createAiClient = resolveFactory();
return createAiClient({
apiKey: opts.apiKey,
...(opts.baseUrl ? { baseURL: opts.baseUrl } : {}),
}) as AiClient;
}
/**
* Run one non-streaming completion and return the assistant text.
*
* Throws when the model returns no choices — an empty answer is a failure the
* caller must surface, not a silent empty card.
*/
export async function complete(
client: AiClient,
req: CompletionRequest,
): Promise<string> {
const res = await client.chat.completions.create({
model: req.model,
messages: req.messages,
...(req.temperature !== undefined ? { temperature: req.temperature } : {}),
...(req.maxTokens !== undefined ? { max_tokens: req.maxTokens } : {}),
});
const text = res.choices?.[0]?.message?.content;
if (typeof text !== 'string' || text.length === 0) {
throw new Error('Hanzo AI returned an empty completion.');
}
return text;
}
/**
* List available model ids from the catalog. Returns `[]` when the catalog is
* empty or shaped unexpectedly — the caller falls back to the configured list.
*/
export async function listModels(client: AiClient): Promise<string[]> {
const res = await client.models.list();
const data = res.data;
if (!Array.isArray(data)) return [];
return data.map((m) => m.id).filter((id): id is string => typeof id === 'string');
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Identity — Teams/AAD user ↔ Hanzo, via `@hanzo/iam`.
*
* Two supported models, both resolving to a per-request {@link HanzoIdentity}:
*
* 1. **Per-tenant key** (default, complete out of the box): the tenant is
* provisioned with one `HANZO_API_KEY`. Every Teams user in that tenant
* acts as that Hanzo principal — the enterprise/regulated-org pattern.
* 2. **Per-user link**: a Teams user presents a Hanzo IAM JWT (obtained via
* Teams SSO/OAuth). `resolveFromToken` validates it with
* `@hanzo/iam`'s `validateToken` and derives the Hanzo org from the `sub`
* (`org/username`) claim.
*
* Only token validation and claim parsing live here; wiring the SSO flow is the
* bot's job. Kept side-effect-free apart from the `validateToken` network call.
*/
import { validateToken, type JwtClaims } from '@hanzo/iam';
/** A resolved Hanzo principal for a Teams user. */
export interface HanzoIdentity {
/** Whether this request is authorized to call Hanzo AI. */
signedIn: boolean;
/** Hanzo org (from the `sub` prefix), when known. */
org?: string;
/** Hanzo username (from the `sub` suffix), when known. */
username?: string;
/** User email, when the token carries it. */
email?: string;
}
/**
* Split a Hanzo `sub` (`"org/username"`) into its parts. Returns `undefined`
* parts when the claim is missing or not in that form.
*/
export function parseSub(sub: string | undefined): {
org?: string;
username?: string;
} {
if (!sub) return {};
const slash = sub.indexOf('/');
if (slash < 0) {
// No org prefix: the whole value is the username.
return { username: sub };
}
// Split on the first slash; omit an empty org or username rather than leaking
// the separator (`"/alice"` → username `"alice"`, `"acme/"` → org `"acme"`).
const org = sub.slice(0, slash);
const username = sub.slice(slash + 1);
return {
...(org ? { org } : {}),
...(username ? { username } : {}),
};
}
/** Build a {@link HanzoIdentity} from validated JWT claims. */
export function identityFromClaims(claims: JwtClaims): HanzoIdentity {
const { org, username } = parseSub(claims.sub);
return {
signedIn: true,
...(org ? { org } : {}),
...(username ? { username } : {}),
...(claims.email ? { email: claims.email } : {}),
};
}
/** The signed-out identity — the tenant has no Hanzo key and no user token. */
export function anonymous(): HanzoIdentity {
return { signedIn: false };
}
/** Per-tenant model: a configured API key means every request is signed in. */
export function tenantIdentity(hasApiKey: boolean): HanzoIdentity {
return hasApiKey ? { signedIn: true } : anonymous();
}
/**
* Config for {@link resolveFromToken} — the exact `@hanzo/iam` validator config
* (`serverUrl` + `clientId` are required; the rest are split-horizon/JWKS knobs).
*/
export type TokenConfig = Parameters<typeof validateToken>[1];
/**
* Validate a Hanzo IAM JWT and resolve the identity. Returns {@link anonymous}
* when validation fails — an invalid token is signed-out, not an error.
*
* `validateToken` returns a discriminated union: `{ ok: true, owner, claims }`
* on success, `{ ok: false, reason }` otherwise. We derive the org from the
* `owner` field, falling back to the `sub` prefix.
*/
export async function resolveFromToken(
token: string,
config: TokenConfig,
): Promise<HanzoIdentity> {
try {
const result = await validateToken(token, config);
if (!result.ok) return anonymous();
const identity = identityFromClaims(result.claims);
// The validator's `owner` is authoritative for the org.
return { ...identity, ...(result.owner ? { org: result.owner } : {}) };
} catch {
return anonymous();
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* `@hanzo/teams` — Hanzo AI for Microsoft Teams.
*
* A Bot Framework (botbuilder) app that brings the Hanzo assistant into Teams:
* 1:1 & channel chat, message extensions ("Ask Hanzo" / compose a draft), and
* Adaptive Card actions (model picker, quick actions, prompt) — all rendered
* from the shared `@hanzo/cards` panel spec and answered via `@hanzo/ai`.
*
* Composition: pure logic modules (`dispatch`, `panels`, `context`, `hanzo`,
* `identity`, `config`) that the bot wires together and the server hosts.
*/
export { HanzoTeamsBot, COMMAND, type BotDeps } from './bot';
export {
createServer,
makeAdapter,
botAuth,
start,
MESSAGES_PATH,
} from './server';
export { loadConfig, DEFAULT_MODELS, type Config, type Env } from './config';
export {
complete,
listModels,
makeClient,
type AiClient,
type ChatMessage,
type CompletionRequest,
} from './hanzo';
export {
dispatch,
selectedModel,
promptText,
type CardIntent,
type SubmitData,
type PromptIntent,
type QuickActionIntent,
type ModelSelectIntent,
type SignInIntent,
type UnknownIntent,
} from './dispatch';
export {
welcomePanel,
resultPanel,
signedOutPanel,
renderCard,
systemPromptFor,
QUICK_ACTIONS,
QuickActionId,
type PanelOptions,
} from './panels';
export {
promptFromActivity,
textFromMessagePayload,
htmlToText,
truncate,
normalizeWhitespace,
MAX_CONTEXT_CHARS,
type ActivityLike,
type MessagePayloadLike,
} from './context';
export {
parseSub,
identityFromClaims,
resolveFromToken,
tenantIdentity,
anonymous,
type HanzoIdentity,
type TokenConfig,
} from './identity';
+104
View File
@@ -0,0 +1,104 @@
/**
* Panels — the Hanzo assistant UI, built once via `@hanzo/cards`.
*
* Every card the bot renders comes from here. We assemble a canonical
* {@link PanelSpec} with `assistantPanel` and emit an Adaptive Card with
* `toAdaptiveCard`; we never hand-write Adaptive Card JSON. Quick-action ids
* defined here are the contract between the card and {@link dispatch}.
*
* Pure: no network, no botbuilder, no secrets — just spec assembly.
*/
import {
assistantPanel,
toAdaptiveCard,
type AdaptiveCard,
type PanelSpec,
type QuickAction,
} from '@hanzo/cards';
/** Stable quick-action ids. These flow into `data.action` as the chip id. */
export const QuickActionId = {
Draft: 'draft',
Summarize: 'summarize',
Explain: 'explain',
ActionItems: 'action_items',
} as const;
export type QuickActionId = (typeof QuickActionId)[keyof typeof QuickActionId];
/** The four assistant quick actions, in display order. */
export const QUICK_ACTIONS: readonly QuickAction[] = [
{ id: QuickActionId.Draft, label: 'Draft reply', style: 'primary' },
{ id: QuickActionId.Summarize, label: 'Summarize' },
{ id: QuickActionId.Explain, label: 'Explain' },
{ id: QuickActionId.ActionItems, label: 'Extract action items' },
] as const;
/**
* System prompt for each quick action. The action operates on supplied context
* (a message/thread) plus any freeform prompt. `undefined` for an unknown id.
*/
export function systemPromptFor(actionId: string): string | undefined {
switch (actionId) {
case QuickActionId.Draft:
return 'You are Hanzo, drafting a concise, professional reply to the message below. Return only the reply body.';
case QuickActionId.Summarize:
return 'You are Hanzo. Summarize the message or thread below in a few clear bullet points.';
case QuickActionId.Explain:
return 'You are Hanzo. Explain the message or thread below in plain language for someone new to the topic.';
case QuickActionId.ActionItems:
return 'You are Hanzo. Extract concrete action items from the message or thread below as a checklist, each with an owner when one is stated.';
default:
return undefined;
}
}
/** Options shared by the panel builders. */
export interface PanelOptions {
/** Model ids for the picker. */
models: string[];
/** Selected model id. */
model: string;
/** Whether the user is signed in to Hanzo. */
signedIn?: boolean;
}
/** The base spec: header, model picker, quick actions, prompt input, footer. */
function base(opts: PanelOptions, output?: string): PanelSpec {
return assistantPanel({
title: 'Hanzo Assistant',
subtitle: 'AI for Microsoft Teams',
models: opts.models,
model: opts.model,
actions: [...QUICK_ACTIONS],
output,
signedIn: opts.signedIn ?? true,
});
}
/** The idle assistant panel (no output yet), e.g. the bot's welcome card. */
export function welcomePanel(opts: PanelOptions): PanelSpec {
return base(opts);
}
/** An assistant panel showing a completed answer, ready for the next prompt. */
export function resultPanel(opts: PanelOptions, output: string): PanelSpec {
return base(opts, output);
}
/** The signed-out panel: sign-in prompt replaces the interactive widgets. */
export function signedOutPanel(opts: Omit<PanelOptions, 'signedIn'>): PanelSpec {
return assistantPanel({
title: 'Hanzo Assistant',
subtitle: 'AI for Microsoft Teams',
models: opts.models,
model: opts.model,
signedIn: false,
signInPrompt: 'Connect your Hanzo account to use the assistant in Teams.',
});
}
/** Emit the Adaptive Card JSON for a spec. Convenience over `toAdaptiveCard`. */
export function renderCard(spec: PanelSpec): AdaptiveCard {
return toAdaptiveCard(spec);
}
+85
View File
@@ -0,0 +1,85 @@
/**
* HTTP server — the Bot Framework messaging endpoint.
*
* Wires the {@link HanzoTeamsBot} to Teams over a restify server using the
* modern {@link CloudAdapter} + {@link ConfigurationBotFrameworkAuthentication}
* (the single-tenant/multi-tenant/MSI-aware auth path). Azure Bot Service posts
* inbound activities to `POST /api/messages`; the adapter authenticates them
* with the app id/password and runs the bot.
*
* This is the only module that opens a socket. It reads config once at startup
* and constructs the AI client from the configured key.
*/
import * as restify from 'restify';
import {
CloudAdapter,
ConfigurationBotFrameworkAuthentication,
type ConfigurationBotFrameworkAuthenticationOptions,
type TurnContext,
} from 'botbuilder';
import { loadConfig, type Config } from './config';
import { HanzoTeamsBot } from './bot';
import { makeClient } from './hanzo';
/** The path Azure Bot Service posts activities to; set as the messaging endpoint. */
export const MESSAGES_PATH = '/api/messages';
/** Build the Bot Framework auth from Hanzo config (maps to `Microsoft*` keys). */
export function botAuth(config: Config): ConfigurationBotFrameworkAuthentication {
const options: ConfigurationBotFrameworkAuthenticationOptions = {
MicrosoftAppId: config.appId,
MicrosoftAppPassword: config.appPassword,
MicrosoftAppType: config.appType,
MicrosoftAppTenantId: config.appTenantId,
};
return new ConfigurationBotFrameworkAuthentication(options);
}
/** Construct the adapter with a graceful onTurnError that surfaces failures. */
export function makeAdapter(config: Config): CloudAdapter {
const adapter = new CloudAdapter(botAuth(config));
adapter.onTurnError = async (context: TurnContext, error: Error) => {
// Log for operators; tell the user without leaking internals.
console.error('[hanzo-teams] turn error:', error);
await context.sendActivity('Hanzo hit an error handling that. Please try again.');
};
return adapter;
}
/** Build the restify server, mounting the bot at {@link MESSAGES_PATH}. */
export function createServer(config: Config = loadConfig()): restify.Server {
const ai = makeClient({
apiKey: config.hanzoApiKey,
...(config.hanzoBaseUrl ? { baseUrl: config.hanzoBaseUrl } : {}),
});
const bot = new HanzoTeamsBot({ config, ai });
const adapter = makeAdapter(config);
const server = restify.createServer({ name: 'hanzo-teams' });
server.use(restify.plugins.bodyParser());
server.post(MESSAGES_PATH, (req, res) => {
adapter.process(req, res, (context) => bot.run(context));
});
server.get('/healthz', (_req, res, next) => {
res.send(200, { status: 'ok' });
next();
});
return server;
}
/** Start listening. Invoked by `pnpm start`. */
export function start(): void {
const config = loadConfig();
const server = createServer(config);
server.listen(config.port, () => {
console.log(`[hanzo-teams] listening on ${server.url}${MESSAGES_PATH}`);
});
}
// Run when executed directly (not when imported by tests).
if (require.main === module) {
start();
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { loadConfig, DEFAULT_MODELS, type Env } from '../src/config';
const base: Env = {
MICROSOFT_APP_ID: 'app-id',
MICROSOFT_APP_PASSWORD: 'app-secret',
HANZO_API_KEY: 'hk-test',
};
describe('loadConfig', () => {
it('loads required vars and applies defaults', () => {
const c = loadConfig(base);
expect(c.appId).toBe('app-id');
expect(c.appPassword).toBe('app-secret');
expect(c.hanzoApiKey).toBe('hk-test');
expect(c.appType).toBe('MultiTenant');
expect(c.appTenantId).toBe('');
expect(c.port).toBe(3978);
expect(c.models).toEqual([...DEFAULT_MODELS]);
expect(c.defaultModel).toBe(DEFAULT_MODELS[0]);
expect(c.hanzoBaseUrl).toBeUndefined();
});
it('is frozen', () => {
const c = loadConfig(base);
expect(Object.isFrozen(c)).toBe(true);
});
it('parses a custom model list and default model', () => {
const c = loadConfig({
...base,
HANZO_MODELS: 'zen-4b, zen-coder-14b ,zen-mini-8b',
HANZO_DEFAULT_MODEL: 'zen-mini-8b',
});
expect(c.models).toEqual(['zen-4b', 'zen-coder-14b', 'zen-mini-8b']);
expect(c.defaultModel).toBe('zen-mini-8b');
});
it('falls back to the first model when default is unset', () => {
const c = loadConfig({ ...base, HANZO_MODELS: 'zen-4b,zen-eco-3b' });
expect(c.defaultModel).toBe('zen-4b');
});
it('honors a custom port and base url', () => {
const c = loadConfig({ ...base, PORT: '8080', HANZO_BASE_URL: 'http://localhost:1234' });
expect(c.port).toBe(8080);
expect(c.hanzoBaseUrl).toBe('http://localhost:1234');
});
it('throws on a missing required var', () => {
const { MICROSOFT_APP_PASSWORD: _omit, ...partial } = base;
expect(() => loadConfig(partial)).toThrow(/MICROSOFT_APP_PASSWORD/);
});
it('throws on an invalid port', () => {
expect(() => loadConfig({ ...base, PORT: '0' })).toThrow(/PORT/);
expect(() => loadConfig({ ...base, PORT: '99999' })).toThrow(/PORT/);
expect(() => loadConfig({ ...base, PORT: 'abc' })).toThrow(/PORT/);
});
it('rejects a port with trailing garbage instead of truncating it', () => {
expect(() => loadConfig({ ...base, PORT: '80x' })).toThrow(/PORT/);
expect(() => loadConfig({ ...base, PORT: '3978 #http' })).toThrow(/PORT/);
});
it('rejects a default model that is not in the model list', () => {
expect(() =>
loadConfig({ ...base, HANZO_MODELS: 'zen-4b', HANZO_DEFAULT_MODEL: 'zen-99b' }),
).toThrow(/HANZO_DEFAULT_MODEL/);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import {
htmlToText,
truncate,
normalizeWhitespace,
promptFromActivity,
textFromMessagePayload,
MAX_CONTEXT_CHARS,
} from '../src/context';
describe('normalizeWhitespace', () => {
it('collapses runs of whitespace and trims', () => {
expect(normalizeWhitespace(' a b\n\tc ')).toBe('a b c');
});
});
describe('htmlToText', () => {
it('strips tags and decodes entities', () => {
const html = '<p>Hello&nbsp;<b>world</b> &amp; more</p>';
expect(htmlToText(html)).toBe('Hello world & more');
});
it('turns block boundaries into newlines', () => {
const html = '<div>one</div><div>two</div>';
expect(htmlToText(html)).toBe('one\ntwo');
});
it('turns list items and <br> into lines', () => {
const html = '<ul><li>a</li><li>b</li></ul>first<br>second';
expect(htmlToText(html)).toBe('a\nb\nfirst\nsecond');
});
it('drops script and style bodies', () => {
const html = '<style>.x{color:red}</style><p>keep</p><script>alert(1)</script>';
expect(htmlToText(html)).toBe('keep');
});
});
describe('truncate', () => {
it('leaves short text unchanged', () => {
expect(truncate('short', 100)).toBe('short');
});
it('cuts on a word boundary and appends an ellipsis', () => {
const text = 'alpha beta gamma delta epsilon';
const out = truncate(text, 12);
expect(out.endsWith('…')).toBe(true);
expect(out.length).toBeLessThanOrEqual(13);
expect(out).toBe('alpha beta…');
});
it('defaults to MAX_CONTEXT_CHARS', () => {
const long = 'x'.repeat(MAX_CONTEXT_CHARS + 500);
const out = truncate(long);
expect(out.length).toBeLessThanOrEqual(MAX_CONTEXT_CHARS + 1);
expect(out.endsWith('…')).toBe(true);
});
});
describe('promptFromActivity', () => {
it('returns normalized text when there is no mention', () => {
expect(promptFromActivity({ text: ' what is OIDC? ' })).toBe('what is OIDC?');
});
it('strips the <at> mention span from a channel message', () => {
const activity = {
text: '<at>Hanzo</at> summarize this thread',
entities: [{ type: 'mention', text: '<at>Hanzo</at>' }],
};
expect(promptFromActivity(activity)).toBe('summarize this thread');
});
it('strips <at id> markup even when entity.text is the plain display name', () => {
const activity = {
text: '<at id="0">Hanzo</at> explain OIDC',
entities: [{ type: 'mention', text: 'Hanzo' }],
};
expect(promptFromActivity(activity)).toBe('explain OIDC');
});
it('does NOT delete later occurrences of the mention word in the prompt', () => {
// Regression: a naive split().join() would mangle "Hanzo the company".
const activity = {
text: '<at>Hanzo</at> tell me about Hanzo the company',
entities: [{ type: 'mention', text: 'Hanzo' }],
};
expect(promptFromActivity(activity)).toBe('tell me about Hanzo the company');
});
it('removes a leading plain-text mention only', () => {
const activity = {
text: 'Hanzo draft a reply to Hanzo support',
entities: [{ type: 'mention', text: 'Hanzo' }],
};
expect(promptFromActivity(activity)).toBe('draft a reply to Hanzo support');
});
it('handles a missing text field', () => {
expect(promptFromActivity({})).toBe('');
});
});
describe('textFromMessagePayload', () => {
it('joins subject and html body decoded to text', () => {
const out = textFromMessagePayload({
subject: 'Release plan',
body: { contentType: 'html', content: '<p>Ship <b>v1</b> on Friday</p>' },
});
expect(out).toBe('Release plan\n\nShip v1 on Friday');
});
it('treats non-html body as plain text', () => {
const out = textFromMessagePayload({
body: { contentType: 'text', content: ' just text ' },
});
expect(out).toBe('just text');
});
it('returns empty string when there is nothing to read', () => {
expect(textFromMessagePayload({})).toBe('');
expect(textFromMessagePayload({ body: {} })).toBe('');
});
it('truncates a very long body', () => {
const out = textFromMessagePayload(
{ body: { contentType: 'text', content: 'word '.repeat(5000) } },
50,
);
expect(out.length).toBeLessThanOrEqual(51);
expect(out.endsWith('…')).toBe(true);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import { ActionId, PROMPT_INPUT_ID } from '@hanzo/cards';
import {
dispatch,
selectedModel,
promptText,
type SubmitData,
} from '../src/dispatch';
// Build a submit payload the way an Adaptive Card returns one: `data.action`
// plus the input fields keyed by their card ids.
function submit(action: string, extra: SubmitData = {}): SubmitData {
return { action, ...extra };
}
describe('selectedModel / promptText', () => {
it('reads the model picker value by ActionId.ModelSelect', () => {
expect(selectedModel({ [ActionId.ModelSelect]: 'zen-4b' })).toBe('zen-4b');
expect(selectedModel({})).toBeUndefined();
expect(selectedModel({ [ActionId.ModelSelect]: '' })).toBeUndefined();
});
it('reads and trims the prompt input by PROMPT_INPUT_ID', () => {
expect(promptText({ [PROMPT_INPUT_ID]: ' hi ' })).toBe('hi');
expect(promptText({ [PROMPT_INPUT_ID]: ' ' })).toBeUndefined();
expect(promptText({})).toBeUndefined();
});
});
describe('dispatch', () => {
it('routes the sign-in action', () => {
expect(dispatch(submit(ActionId.SignIn))).toEqual({ kind: 'sign_in' });
});
it('routes a model select carrying the picker value', () => {
const data = submit(ActionId.ModelSelect, { [ActionId.ModelSelect]: 'zen-mini-8b' });
expect(dispatch(data)).toEqual({ kind: 'model_select', model: 'zen-mini-8b' });
});
it('treats a model select with no value as unknown', () => {
expect(dispatch(submit(ActionId.ModelSelect))).toEqual({
kind: 'unknown',
action: ActionId.ModelSelect,
});
});
it('routes a prompt submit with the input text and selected model', () => {
const data = submit(ActionId.PromptSubmit, {
[PROMPT_INPUT_ID]: 'Explain OIDC',
[ActionId.ModelSelect]: 'zen-4b',
});
expect(dispatch(data)).toEqual({
kind: 'prompt',
prompt: 'Explain OIDC',
model: 'zen-4b',
});
});
it('routes a prompt submit without a model when none selected', () => {
const data = submit(ActionId.PromptSubmit, { [PROMPT_INPUT_ID]: 'hello' });
expect(dispatch(data)).toEqual({ kind: 'prompt', prompt: 'hello' });
});
it('treats an empty prompt submit as unknown', () => {
const data = submit(ActionId.PromptSubmit, { [PROMPT_INPUT_ID]: ' ' });
expect(dispatch(data)).toEqual({ kind: 'unknown', action: ActionId.PromptSubmit });
});
it('routes a quick action from the prefixed action id and chip field', () => {
// @hanzo/cards emits `hanzo_quick_action:<chip>` with data.chip = <chip>.
const action = `${ActionId.QuickAction}:summarize`;
const data = submit(action, { chip: 'summarize' });
expect(dispatch(data)).toEqual({ kind: 'quick_action', chip: 'summarize' });
});
it('derives the chip from the action suffix when data.chip is absent', () => {
const action = `${ActionId.QuickAction}:draft`;
expect(dispatch(submit(action))).toEqual({ kind: 'quick_action', chip: 'draft' });
});
it('carries the prompt and model along with a quick action', () => {
const action = `${ActionId.QuickAction}:action_items`;
const data = submit(action, {
chip: 'action_items',
[PROMPT_INPUT_ID]: 'focus on blockers',
[ActionId.ModelSelect]: 'zen-eco-3b',
});
expect(dispatch(data)).toEqual({
kind: 'quick_action',
chip: 'action_items',
prompt: 'focus on blockers',
model: 'zen-eco-3b',
});
});
it('returns unknown for an unrecognized action', () => {
expect(dispatch(submit('something_else'))).toEqual({
kind: 'unknown',
action: 'something_else',
});
});
it('returns unknown with no action for an empty payload', () => {
expect(dispatch({})).toEqual({ kind: 'unknown' });
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect, vi } from 'vitest';
import { complete, listModels, type AiClient } from '../src/hanzo';
// A hand-built stub of the exact client slice hanzo.ts uses, so we assert on
// the request we shape and the response we read — no network.
function stubClient(overrides: Partial<AiClient> = {}): {
client: AiClient;
create: ReturnType<typeof vi.fn>;
list: ReturnType<typeof vi.fn>;
} {
const create = vi.fn(async () => ({
choices: [{ message: { content: 'hello from hanzo' } }],
}));
const list = vi.fn(async () => ({ data: [{ id: 'zen-eco-3b' }, { id: 'zen-4b' }] }));
const client: AiClient = {
chat: { completions: { create } },
models: { list },
...overrides,
} as AiClient;
return { client, create, list };
}
describe('complete', () => {
it('shapes the request with model, messages, and max_tokens', async () => {
const { client, create } = stubClient();
const text = await complete(client, {
model: 'zen-4b',
messages: [{ role: 'user', content: 'hi' }],
maxTokens: 256,
temperature: 0.3,
});
expect(text).toBe('hello from hanzo');
expect(create).toHaveBeenCalledWith({
model: 'zen-4b',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 256,
temperature: 0.3,
});
});
it('omits temperature and max_tokens when not provided', async () => {
const { client, create } = stubClient();
await complete(client, { model: 'zen-eco-3b', messages: [] });
expect(create).toHaveBeenCalledWith({ model: 'zen-eco-3b', messages: [] });
});
it('throws on an empty completion', async () => {
const { client } = stubClient({
chat: { completions: { create: vi.fn(async () => ({ choices: [] })) } },
} as Partial<AiClient>);
await expect(
complete(client, { model: 'x', messages: [] }),
).rejects.toThrow(/empty completion/i);
});
it('throws when the message content is null', async () => {
const { client } = stubClient({
chat: {
completions: { create: vi.fn(async () => ({ choices: [{ message: { content: null } }] })) },
},
} as Partial<AiClient>);
await expect(complete(client, { model: 'x', messages: [] })).rejects.toThrow();
});
});
describe('listModels', () => {
it('returns the model ids from the catalog', async () => {
const { client } = stubClient();
expect(await listModels(client)).toEqual(['zen-eco-3b', 'zen-4b']);
});
it('returns [] when the catalog is shaped unexpectedly', async () => {
const { client } = stubClient({
models: { list: vi.fn(async () => ({})) },
} as Partial<AiClient>);
expect(await listModels(client)).toEqual([]);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import {
parseSub,
identityFromClaims,
anonymous,
tenantIdentity,
} from '../src/identity';
describe('parseSub', () => {
it('splits org/username', () => {
expect(parseSub('acme/alice')).toEqual({ org: 'acme', username: 'alice' });
});
it('treats a value with no slash as a bare username', () => {
expect(parseSub('alice')).toEqual({ username: 'alice' });
});
it('splits on the first slash without leaking the separator', () => {
expect(parseSub('acme/')).toEqual({ org: 'acme' });
expect(parseSub('/alice')).toEqual({ username: 'alice' });
expect(parseSub('acme/team/alice')).toEqual({ org: 'acme', username: 'team/alice' });
});
it('returns empty for undefined', () => {
expect(parseSub(undefined)).toEqual({});
});
});
describe('identityFromClaims', () => {
it('builds a signed-in identity from org/username + email', () => {
expect(identityFromClaims({ sub: 'acme/bob', email: 'bob@acme.com' })).toEqual({
signedIn: true,
org: 'acme',
username: 'bob',
email: 'bob@acme.com',
});
});
it('omits absent fields', () => {
expect(identityFromClaims({ sub: 'solo' })).toEqual({
signedIn: true,
username: 'solo',
});
});
});
describe('tenantIdentity / anonymous', () => {
it('is signed in when a tenant key exists', () => {
expect(tenantIdentity(true)).toEqual({ signedIn: true });
});
it('is signed out with no key', () => {
expect(tenantIdentity(false)).toEqual({ signedIn: false });
expect(anonymous()).toEqual({ signedIn: false });
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const manifestPath = join(__dirname, '..', 'manifest', 'manifest.json');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record<string, any>;
describe('Teams app manifest', () => {
it('is valid JSON with the required top-level keys', () => {
for (const key of [
'manifestVersion',
'id',
'name',
'description',
'icons',
'bots',
'composeExtensions',
'permissions',
'validDomains',
]) {
expect(manifest[key], `missing ${key}`).toBeDefined();
}
});
it('targets Teams schema v1.16+', () => {
const [major, minor] = manifest.manifestVersion.split('.').map(Number);
expect(major).toBe(1);
expect(minor).toBeGreaterThanOrEqual(16);
});
it('declares a bot in personal, team, and groupChat scopes', () => {
const bot = manifest.bots[0];
expect(bot.scopes).toEqual(expect.arrayContaining(['personal', 'team', 'groupChat']));
});
it('declares the askHanzo action and composeHanzo query commands', () => {
const commands = manifest.composeExtensions[0].commands;
const ids = commands.map((c: any) => c.id);
expect(ids).toContain('askHanzo');
expect(ids).toContain('composeHanzo');
const ask = commands.find((c: any) => c.id === 'askHanzo');
expect(ask.type).toBe('action');
expect(ask.context).toEqual(expect.arrayContaining(['message', 'compose']));
const compose = commands.find((c: any) => c.id === 'composeHanzo');
expect(compose.type).toBe('query');
});
it('offers the four quick actions as the askHanzo chip choices', () => {
const ask = manifest.composeExtensions[0].commands.find((c: any) => c.id === 'askHanzo');
const chip = ask.parameters.find((p: any) => p.name === 'chip');
expect(chip.choices.map((c: any) => c.value)).toEqual([
'summarize',
'draft',
'explain',
'action_items',
]);
});
it('lists api.hanzo.ai as a valid domain and never uses an /api/ path', () => {
expect(manifest.validDomains).toContain('api.hanzo.ai');
expect(JSON.stringify(manifest)).not.toMatch(/hanzo\.ai\/api\//);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { ActionId, PROMPT_INPUT_ID, ADAPTIVE_CARD_VERSION } from '@hanzo/cards';
import {
welcomePanel,
resultPanel,
signedOutPanel,
renderCard,
systemPromptFor,
QUICK_ACTIONS,
QuickActionId,
} from '../src/panels';
const opts = { models: ['zen-eco-3b', 'zen-4b'], model: 'zen-eco-3b' };
// Walk an Adaptive Card body/actions tree collecting every element.type.
function collectTypes(card: ReturnType<typeof renderCard>): string[] {
const types: string[] = [];
const visit = (el: any) => {
if (!el || typeof el !== 'object') return;
if (typeof el.type === 'string') types.push(el.type);
for (const key of ['body', 'items', 'columns', 'actions', 'choices']) {
if (Array.isArray(el[key])) el[key].forEach(visit);
}
};
card.body.forEach(visit);
card.actions.forEach(visit);
return types;
}
// Collect every `data.action` from Action.Submit elements in the card.
function collectActions(card: ReturnType<typeof renderCard>): string[] {
const actions: string[] = [];
const visit = (el: any) => {
if (!el || typeof el !== 'object') return;
if (el.type === 'Action.Submit' && el.data && typeof el.data.action === 'string') {
actions.push(el.data.action);
}
for (const key of ['body', 'items', 'columns', 'actions']) {
if (Array.isArray(el[key])) el[key].forEach(visit);
}
};
card.body.forEach(visit);
card.actions.forEach(visit);
return actions;
}
describe('QUICK_ACTIONS', () => {
it('are the four assistant actions with stable ids', () => {
expect(QUICK_ACTIONS.map((a) => a.id)).toEqual([
QuickActionId.Draft,
QuickActionId.Summarize,
QuickActionId.Explain,
QuickActionId.ActionItems,
]);
});
});
describe('systemPromptFor', () => {
it('returns a prompt for each known action', () => {
for (const id of Object.values(QuickActionId)) {
expect(systemPromptFor(id)).toBeTypeOf('string');
}
});
it('returns undefined for an unknown action', () => {
expect(systemPromptFor('nope')).toBeUndefined();
});
});
describe('welcomePanel → Adaptive Card', () => {
const card = renderCard(welcomePanel(opts));
it('is an AdaptiveCard at the expected schema version', () => {
expect(card.type).toBe('AdaptiveCard');
expect(card.version).toBe(ADAPTIVE_CARD_VERSION);
});
it('renders a model ChoiceSet keyed by ModelSelect with the choices', () => {
const choiceSet = card.body.find(
(el: any) => el.type === 'Input.ChoiceSet' && el.id === ActionId.ModelSelect,
) as any;
expect(choiceSet).toBeDefined();
expect(choiceSet.value).toBe('zen-eco-3b');
expect(choiceSet.choices.map((c: any) => c.value)).toEqual(['zen-eco-3b', 'zen-4b']);
});
it('renders the prompt Input.Text keyed by PROMPT_INPUT_ID', () => {
const types = collectTypes(card);
expect(types).toContain('Input.Text');
const input = card.body.find((el: any) => el.id === PROMPT_INPUT_ID);
expect(input).toBeDefined();
});
it('emits a submit for each quick action plus the prompt submit', () => {
const actions = collectActions(card);
expect(actions).toContain(ActionId.PromptSubmit);
for (const qa of QUICK_ACTIONS) {
expect(actions).toContain(`${ActionId.QuickAction}:${qa.id}`);
}
});
});
describe('resultPanel → Adaptive Card', () => {
it('includes the output text in the body', () => {
const card = renderCard(resultPanel(opts, 'the answer'));
const flat = JSON.stringify(card);
expect(flat).toContain('the answer');
});
});
describe('signedOutPanel → Adaptive Card', () => {
const card = renderCard(signedOutPanel({ models: opts.models, model: opts.model }));
it('shows a sign-in submit and hides the prompt/model widgets', () => {
const actions = collectActions(card);
expect(actions).toEqual([ActionId.SignIn]);
const types = collectTypes(card);
expect(types).not.toContain('Input.Text');
expect(types).not.toContain('Input.ChoiceSet');
});
});
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": ".",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules", "dist"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'tsup';
export default defineConfig({
// `index` is the library entry; `server` is the runnable messaging endpoint
// (`node dist/server.js` / the `hanzo-teams` bin).
entry: { index: 'src/index.ts', server: 'src/server.ts' },
format: ['cjs', 'esm'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
});
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
// The server opens a socket and the bot wires botbuilder at runtime; the
// pure logic they compose (dispatch/panels/context/hanzo/config/identity)
// is what we assert.
exclude: ['**/*.d.ts', '**/*.config.*', 'src/server.ts', 'src/index.ts'],
},
},
});
+1
View File
@@ -12,6 +12,7 @@ packages:
- 'packages/office'
- 'packages/outlook'
- 'packages/pdf'
- 'packages/teams'
- 'packages/tools'
- 'packages/vscode'
- 'apps/*'