Merge remote-tracking branch 'origin/feat/teams'
# Conflicts: # pnpm-workspace.yaml
This commit is contained in:
@@ -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.
|
||||
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/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);
|
||||
}
|
||||
@@ -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 } : {}) };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 <b>world</b> & 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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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\//);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -14,6 +14,7 @@ packages:
|
||||
- 'packages/outlook'
|
||||
- 'packages/pdf'
|
||||
- 'packages/slack'
|
||||
- 'packages/teams'
|
||||
- 'packages/tools'
|
||||
- 'packages/vscode'
|
||||
- 'apps/*'
|
||||
|
||||
Reference in New Issue
Block a user