feat(setup): Microsoft Teams wiring with Claude handoff
Teams is the most complex channel NanoClaw supports — no "paste a
token" shortcut exists. Operators walk through ~6 Azure portal steps
(app registration, client secret, Azure Bot resource, messaging
endpoint, Teams channel, manifest sideload). The driver makes each
step as guided as possible and gives the operator an explicit
escape to interactive Claude whenever they get stuck.
Handoff mechanism (reusable across channels):
- setup/lib/claude-handoff.ts: offerClaudeHandoff(ctx) spawns
`claude --append-system-prompt <context> --permission-mode acceptEdits`
with stdio: 'inherit', returns when Claude exits so the driver can
re-offer the same step. Context captures channel, current step,
completed steps, collected values (secrets redacted), and file refs.
- validateWithHelpEscape / isHelpEscape: wrap clack text/password
prompts so typing '?' triggers the handoff mid-paste.
- Parallel to the existing claude-assist.ts (which is failure-triggered
and runs claude -p for a one-shot command suggestion). This is the
user-initiated, interactive counterpart.
Teams driver (setup/channels/teams.ts):
- 6-step walkthrough, each a clack note + paste prompts + stepGate
select ("Done / Stuck — hand me off to Claude / Show me again").
- Collects TEAMS_APP_ID / TEAMS_APP_TENANT_ID / TEAMS_APP_PASSWORD /
TEAMS_APP_TYPE plus the operator's public HTTPS URL (advisory —
no tunnel automation yet).
- Emits the full Azure CLI invocation alongside the portal steps for
operators who prefer scripted creation.
- UUID/password prompts accept '?' as a help escape; select prompts
have an explicit 'Stuck' option that triggers the handoff.
Manifest generator (setup/lib/teams-manifest.ts):
- Builds data/teams/teams-app-package.zip in-process: manifest.json
(schema v1.16) with app ID injected, a 32×32 outline icon, a
192×192 brand-blue color icon, bundled with the system `zip`.
- Minimal hand-rolled PNG encoder (CRC32 table + zlib deflate) so we
don't need ImageMagick or vendored binary blobs.
- ~2.5KB zip, validates with `unzip -l`; icons verify as valid PNGs.
Installer (setup/add-teams.sh):
- Non-interactive mirror of add-discord.sh. Validates the four env
vars, copies adapter from origin/channels, installs
@chat-adapter/teams@4.26.0, upserts creds to .env + data/env/env,
restarts the service.
auto.ts: Teams option in askChannelChoice with 'complex setup' hint,
dispatch to runTeamsChannel.
Deferred (known limitation, operator instructed to finish manually):
- Wait-for-first-DM pairing to capture the auto-generated Teams
platform_id. Teams platform IDs are only discoverable after the
first inbound activity. The driver installs the adapter and stops
there; the operator DMs the bot, NanoClaw auto-creates the
messaging group, and they wire an agent via /manage-channels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
131
setup/add-teams.sh
Executable file
131
setup/add-teams.sh
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Teams adapter, persist TEAMS_APP_ID / _PASSWORD / _TENANT_ID /
|
||||
# _TYPE to .env + data/env/env, and restart the service. Non-interactive —
|
||||
# the operator-facing Azure portal walkthroughs live in
|
||||
# setup/channels/teams.ts. Credentials come in via env vars:
|
||||
# TEAMS_APP_ID (required)
|
||||
# TEAMS_APP_PASSWORD (required — client secret value from Azure)
|
||||
# TEAMS_APP_TYPE (required — SingleTenant | MultiTenant)
|
||||
# TEAMS_APP_TENANT_ID (required when type=SingleTenant)
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_TEAMS) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the
|
||||
# full story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-teams/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.26.0"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_TEAMS ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-teams] $*" >&2; }
|
||||
|
||||
if [ -z "${TEAMS_APP_ID:-}" ]; then
|
||||
emit_status failed "TEAMS_APP_ID env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${TEAMS_APP_PASSWORD:-}" ]; then
|
||||
emit_status failed "TEAMS_APP_PASSWORD env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${TEAMS_APP_TYPE:-}" ]; then
|
||||
emit_status failed "TEAMS_APP_TYPE env var not set (SingleTenant|MultiTenant)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${TEAMS_APP_TYPE}" = "SingleTenant" ] && [ -z "${TEAMS_APP_TENANT_ID:-}" ]; then
|
||||
emit_status failed "TEAMS_APP_TENANT_ID required when TEAMS_APP_TYPE=SingleTenant"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/teams.ts ] && return 0
|
||||
! grep -q "^import './teams.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/teams.ts" > src/channels/teams.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './teams.js';" src/channels/index.ts; then
|
||||
echo "import './teams.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# Persist credentials.
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
upsert_env TEAMS_APP_ID "$TEAMS_APP_ID"
|
||||
upsert_env TEAMS_APP_PASSWORD "$TEAMS_APP_PASSWORD"
|
||||
upsert_env TEAMS_APP_TYPE "$TEAMS_APP_TYPE"
|
||||
if [ -n "${TEAMS_APP_TENANT_ID:-}" ]; then
|
||||
upsert_env TEAMS_APP_TENANT_ID "$TEAMS_APP_TENANT_ID"
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart nanoclaw >&2 2>/dev/null \
|
||||
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Teams adapter a moment to register its webhook before the driver
|
||||
# continues.
|
||||
sleep 5
|
||||
|
||||
emit_status success
|
||||
@@ -26,6 +26,7 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { runDiscordChannel } from './channels/discord.js';
|
||||
import { runTeamsChannel } from './channels/teams.js';
|
||||
import { runTelegramChannel } from './channels/telegram.js';
|
||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
@@ -224,10 +225,12 @@ async function main(): Promise<void> {
|
||||
await runDiscordChannel(displayName!);
|
||||
} else if (choice === 'whatsapp') {
|
||||
await runWhatsAppChannel(displayName!);
|
||||
} else if (choice === 'teams') {
|
||||
await runTeamsChannel(displayName!);
|
||||
} else {
|
||||
p.log.info(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, or Slack).',
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, or Slack).',
|
||||
4,
|
||||
),
|
||||
);
|
||||
@@ -522,7 +525,9 @@ async function askDisplayName(fallback: string): Promise<string> {
|
||||
return value;
|
||||
}
|
||||
|
||||
async function askChannelChoice(): Promise<'telegram' | 'discord' | 'whatsapp' | 'skip'> {
|
||||
async function askChannelChoice(): Promise<
|
||||
'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'
|
||||
> {
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'Want to chat with your assistant from your phone?',
|
||||
@@ -530,12 +535,13 @@ async function askChannelChoice(): Promise<'telegram' | 'discord' | 'whatsapp' |
|
||||
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||||
{ value: 'discord', label: 'Yes, connect Discord' },
|
||||
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
|
||||
{ value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' },
|
||||
{ value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('channel_choice', String(choice));
|
||||
return choice as 'telegram' | 'discord' | 'whatsapp' | 'skip';
|
||||
return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip';
|
||||
}
|
||||
|
||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||
|
||||
629
setup/channels/teams.ts
Normal file
629
setup/channels/teams.ts
Normal file
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* Microsoft Teams channel flow for setup:auto.
|
||||
*
|
||||
* Teams is the most complex channel NanoClaw supports — the Slack/Discord
|
||||
* "paste a token" shortcut doesn't exist. The operator has to walk through
|
||||
* ~7 Azure portal steps (app registration, client secret, Azure Bot
|
||||
* resource, messaging endpoint, Teams channel enable, manifest, sideload).
|
||||
*
|
||||
* This driver's job is to make each of those steps as guided as possible
|
||||
* inside the terminal:
|
||||
* 1. Print a clack note with the exact sub-steps and the portal URL.
|
||||
* 2. Ask for the value(s) that step yields (App ID, secret, tenant, etc.).
|
||||
* 3. At every step boundary, offer `stepGate` — a Done / Stuck / Show-again
|
||||
* select. "Stuck" hands off to interactive Claude with full context.
|
||||
*
|
||||
* Text/password prompts also accept `?` as an answer to trigger the handoff,
|
||||
* so the operator can escape at any paste point without scrolling back to a
|
||||
* step boundary.
|
||||
*
|
||||
* What's deferred (known limitation, instruct user how to finish manually):
|
||||
* - Wait-for-first-DM to capture the auto-generated Teams platformId.
|
||||
* Unlike Discord/Telegram, the Teams platform_id is only discoverable
|
||||
* after the first inbound activity. The driver installs the adapter and
|
||||
* stops there; the operator DMs the bot, NanoClaw auto-creates the
|
||||
* messaging group, and they wire an agent via `/manage-channels`.
|
||||
*/
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import {
|
||||
isHelpEscape,
|
||||
offerClaudeHandoff,
|
||||
validateWithHelpEscape,
|
||||
type HandoffContext,
|
||||
} from '../lib/claude-handoff.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
|
||||
const CHANNEL = 'teams';
|
||||
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
|
||||
const AZURE_PORTAL_URL = 'https://portal.azure.com';
|
||||
|
||||
interface Collected {
|
||||
publicUrl?: string;
|
||||
appId?: string;
|
||||
tenantId?: string;
|
||||
appType?: 'SingleTenant' | 'MultiTenant';
|
||||
appPassword?: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export async function runTeamsChannel(_displayName: string): Promise<void> {
|
||||
const collected: Collected = {};
|
||||
const completed: string[] = [];
|
||||
|
||||
printIntro();
|
||||
|
||||
await confirmPrereqs({ collected, completed });
|
||||
await stepPublicUrl({ collected, completed });
|
||||
await stepAppRegistration({ collected, completed });
|
||||
await stepClientSecret({ collected, completed });
|
||||
await stepAzureBot({ collected, completed });
|
||||
await stepEnableTeamsChannel({ collected, completed });
|
||||
const manifestResult = await stepGenerateManifest({ collected, completed });
|
||||
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath });
|
||||
|
||||
await installAdapter(collected);
|
||||
completed.push('Adapter installed and service restarted.');
|
||||
|
||||
printPostInstallGuidance();
|
||||
}
|
||||
|
||||
// ─── step: intro / prereqs ──────────────────────────────────────────────
|
||||
|
||||
function printIntro(): void {
|
||||
p.note(
|
||||
[
|
||||
'Setting up Teams is more involved than the other channels — about',
|
||||
'7 steps across the Azure portal and Teams admin.',
|
||||
'',
|
||||
k.dim("At any prompt you can type '?' and press Enter to hand off"),
|
||||
k.dim("to Claude interactive mode with your current progress."),
|
||||
k.dim("You can also pick 'Stuck' at any Done/Stuck/Show-again prompt."),
|
||||
].join('\n'),
|
||||
'Microsoft Teams setup',
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
'Before we start, confirm you have:',
|
||||
'',
|
||||
' • A Microsoft 365 tenant where you can sideload custom apps',
|
||||
' (free personal Teams does NOT support this — you need a',
|
||||
' Microsoft 365 Business / EDU / developer tenant)',
|
||||
' • Teams admin or developer tenant rights',
|
||||
' • A way to expose an HTTPS endpoint from this machine',
|
||||
' (ngrok, Cloudflare Tunnel, or a reverse-proxied VPS)',
|
||||
].join('\n'),
|
||||
'Prereqs',
|
||||
);
|
||||
|
||||
await stepGate({
|
||||
stepName: 'teams-prereqs',
|
||||
stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel',
|
||||
reshow: () => confirmPrereqs(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push('Prereqs confirmed.');
|
||||
}
|
||||
|
||||
// ─── step: public URL ──────────────────────────────────────────────────
|
||||
|
||||
async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
"Azure Bot Service delivers messages to an HTTPS endpoint you",
|
||||
"control. The endpoint needs to reach this machine's webhook",
|
||||
"server at /api/webhooks/teams.",
|
||||
'',
|
||||
k.dim('Examples:'),
|
||||
k.dim(' ngrok http 3000 → https://abcd1234.ngrok.io'),
|
||||
k.dim(' cloudflared tunnel … → https://<tunnel>.trycloudflare.com'),
|
||||
k.dim(' or a reverse proxy on your own domain'),
|
||||
'',
|
||||
"If you don't have a tunnel running yet, start one in another",
|
||||
"terminal, then come back here.",
|
||||
].join('\n'),
|
||||
'Public HTTPS URL',
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Paste your public base URL (e.g. https://abcd1234.ngrok.io)',
|
||||
placeholder: 'https://…',
|
||||
validate: validateWithHelpEscape((v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
if (!/^https:\/\/[^\s/]+/.test(t)) {
|
||||
return 'Must be an https:// URL (Azure rejects http)';
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (isHelpEscape(answer)) {
|
||||
await offerHandoff({
|
||||
step: 'teams-public-url',
|
||||
stepDescription:
|
||||
'setting up a public HTTPS tunnel to reach this machine on port 3000',
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const url = (answer as string).trim().replace(/\/$/, '');
|
||||
args.collected.publicUrl = url;
|
||||
setupLog.userInput('teams_public_url', url);
|
||||
break;
|
||||
}
|
||||
|
||||
args.completed.push(`Public URL: ${args.collected.publicUrl}`);
|
||||
}
|
||||
|
||||
// ─── step: Azure App Registration ──────────────────────────────────────
|
||||
|
||||
async function stepAppRegistration(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
|
||||
'2. Name it (e.g. "NanoClaw")',
|
||||
'3. Supported account types: Single tenant (your org only) OR',
|
||||
' Multi tenant (any Microsoft 365 tenant can add the bot)',
|
||||
'4. Click Register',
|
||||
'5. On the Overview page, copy:',
|
||||
' • Application (client) ID',
|
||||
' • Directory (tenant) ID',
|
||||
].join('\n'),
|
||||
'Step 1 of 6 — Create Azure App Registration',
|
||||
);
|
||||
await confirmThenOpen(
|
||||
AZURE_PORTAL_URL,
|
||||
'Press Enter to open the Azure portal',
|
||||
);
|
||||
|
||||
args.collected.appType = await askAppType(args);
|
||||
args.collected.appId = await askUuid(
|
||||
'Paste the Application (client) ID',
|
||||
'teams-app-id',
|
||||
args,
|
||||
);
|
||||
if (args.collected.appType === 'SingleTenant') {
|
||||
args.collected.tenantId = await askUuid(
|
||||
'Paste the Directory (tenant) ID',
|
||||
'teams-tenant-id',
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
await stepGate({
|
||||
stepName: 'teams-app-registration',
|
||||
stepDescription: 'registering an app in Azure and collecting App ID + tenant type',
|
||||
reshow: () => stepAppRegistration(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push(
|
||||
`App registered: ${args.collected.appId} (${args.collected.appType})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function askAppType(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<'SingleTenant' | 'MultiTenant'> {
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'Which account type did you pick?',
|
||||
options: [
|
||||
{
|
||||
value: 'SingleTenant',
|
||||
label: 'Single tenant',
|
||||
hint: 'your org only — most common for self-host',
|
||||
},
|
||||
{
|
||||
value: 'MultiTenant',
|
||||
label: 'Multi tenant',
|
||||
hint: 'any Microsoft 365 tenant can install the bot',
|
||||
},
|
||||
{ value: 'help', label: 'Stuck — hand me off to Claude' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (choice === 'help') {
|
||||
await offerHandoff({
|
||||
step: 'teams-app-type',
|
||||
stepDescription: "deciding between Single tenant and Multi tenant for their Azure app",
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
return choice as 'SingleTenant' | 'MultiTenant';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── step: client secret ───────────────────────────────────────────────
|
||||
|
||||
async function stepClientSecret(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
`1. In your app registration, open "Certificates & secrets"`,
|
||||
'2. Click "New client secret"',
|
||||
' Description: nanoclaw',
|
||||
' Expires: 180 days (recommended) or longer',
|
||||
'3. Click Add',
|
||||
'4. ' + k.yellow('COPY THE VALUE NOW — Azure only shows it once'),
|
||||
' (the Value column, not the Secret ID)',
|
||||
].join('\n'),
|
||||
'Step 2 of 6 — Create a client secret',
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste the client secret Value',
|
||||
validate: validateWithHelpEscape((v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
if (t.length < 20) return "That looks too short — make sure you copied the Value, not the Secret ID";
|
||||
return undefined;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (isHelpEscape(answer)) {
|
||||
await offerHandoff({
|
||||
step: 'teams-client-secret',
|
||||
stepDescription: 'creating and copying the client secret value from Azure',
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
args.collected.appPassword = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'teams_client_secret',
|
||||
`${args.collected.appPassword.slice(0, 4)}…${args.collected.appPassword.slice(-4)}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
await stepGate({
|
||||
stepName: 'teams-client-secret',
|
||||
stepDescription: 'creating and copying the client secret',
|
||||
reshow: () => stepClientSecret(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push('Client secret captured.');
|
||||
}
|
||||
|
||||
// ─── step: Azure Bot resource ──────────────────────────────────────────
|
||||
|
||||
async function stepAzureBot(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`;
|
||||
const tenantFlag =
|
||||
args.collected.appType === 'SingleTenant'
|
||||
? `--tenant-id ${args.collected.tenantId} `
|
||||
: '';
|
||||
const cliCommand =
|
||||
`az bot create \\\n` +
|
||||
` --resource-group nanoclaw-rg \\\n` +
|
||||
` --name nanoclaw-bot \\\n` +
|
||||
` --app-type ${args.collected.appType} \\\n` +
|
||||
` --appid ${args.collected.appId} \\\n` +
|
||||
` ${tenantFlag}--endpoint "${endpoint}"`;
|
||||
|
||||
p.note(
|
||||
[
|
||||
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
|
||||
'',
|
||||
' • Bot handle: unique name, e.g. nanoclaw-bot',
|
||||
` • Type of App: ${args.collected.appType}`,
|
||||
' • Creation type: Use existing app registration',
|
||||
` • App ID: ${args.collected.appId ?? '<pending>'}`,
|
||||
...(args.collected.appType === 'SingleTenant'
|
||||
? [` • App tenant ID: ${args.collected.tenantId ?? '<pending>'}`]
|
||||
: []),
|
||||
'',
|
||||
'After creating, open the bot → Configuration and set:',
|
||||
` Messaging endpoint: ${k.cyan(endpoint)}`,
|
||||
'',
|
||||
k.dim('Or via Azure CLI (if you have az installed):'),
|
||||
k.dim(cliCommand),
|
||||
].join('\n'),
|
||||
'Step 3 of 6 — Create Azure Bot resource',
|
||||
);
|
||||
|
||||
await stepGate({
|
||||
stepName: 'teams-azure-bot',
|
||||
stepDescription:
|
||||
'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint',
|
||||
reshow: () => stepAzureBot(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push('Azure Bot created; messaging endpoint configured.');
|
||||
}
|
||||
|
||||
// ─── step: enable Teams channel ────────────────────────────────────────
|
||||
|
||||
async function stepEnableTeamsChannel(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
'1. Open your Azure Bot resource → Channels',
|
||||
'2. Click Microsoft Teams → Accept terms → Apply',
|
||||
'',
|
||||
k.dim('CLI alternative:'),
|
||||
k.dim(' az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot'),
|
||||
].join('\n'),
|
||||
'Step 4 of 6 — Enable Teams channel on the bot',
|
||||
);
|
||||
await stepGate({
|
||||
stepName: 'teams-enable-channel',
|
||||
stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource',
|
||||
reshow: () => stepEnableTeamsChannel(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push('Teams channel enabled on the bot.');
|
||||
}
|
||||
|
||||
// ─── step: manifest zip ────────────────────────────────────────────────
|
||||
|
||||
async function stepGenerateManifest(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<{ zipPath: string }> {
|
||||
if (!args.collected.appId) {
|
||||
fail(
|
||||
'teams-manifest',
|
||||
'Missing Azure App ID.',
|
||||
"That's an internal bug — open an issue or retry setup.",
|
||||
);
|
||||
}
|
||||
const shortName =
|
||||
process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
|
||||
|
||||
const s = p.spinner();
|
||||
s.start('Generating your Teams app package…');
|
||||
try {
|
||||
const result = buildTeamsAppPackage({
|
||||
appId: args.collected.appId!,
|
||||
shortName,
|
||||
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
|
||||
websiteUrl: args.collected.publicUrl!,
|
||||
outDir: MANIFEST_DIR,
|
||||
});
|
||||
s.stop(`Package ready: ${k.cyan(shortPath(result.zipPath))}`);
|
||||
setupLog.step('teams-manifest', 'success', 0, {
|
||||
ZIP: result.zipPath,
|
||||
});
|
||||
args.completed.push(`Generated manifest zip at ${shortPath(result.zipPath)}.`);
|
||||
return { zipPath: result.zipPath };
|
||||
} catch (err) {
|
||||
s.stop("Couldn't build the manifest zip.", 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('teams-manifest', 'failed', 0, { ERROR: message });
|
||||
fail(
|
||||
'teams-manifest',
|
||||
"Couldn't generate the Teams app package.",
|
||||
'Make sure `zip` is available on your PATH, then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── step: sideload ────────────────────────────────────────────────────
|
||||
|
||||
async function stepSideload(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
zipPath: string;
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
'1. Open Microsoft Teams',
|
||||
'2. Go to Apps → Manage your apps → Upload an app',
|
||||
'3. Click "Upload a custom app" (or "Upload for me or my teams")',
|
||||
`4. Select: ${k.cyan(args.zipPath)}`,
|
||||
'5. Click Add',
|
||||
'',
|
||||
k.dim('If "Upload a custom app" is missing, your tenant admin has'),
|
||||
k.dim('disabled sideloading. Enable it in Teams Admin Center →'),
|
||||
k.dim('Teams apps → Setup policies → Global → Upload custom apps = On'),
|
||||
].join('\n'),
|
||||
'Step 5 of 6 — Sideload the app into Teams',
|
||||
);
|
||||
await stepGate({
|
||||
stepName: 'teams-sideload',
|
||||
stepDescription: 'uploading the generated zip into Teams as a custom app',
|
||||
reshow: () => stepSideload(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push('App sideloaded into Teams.');
|
||||
}
|
||||
|
||||
// ─── step: install adapter ─────────────────────────────────────────────
|
||||
|
||||
async function installAdapter(collected: Collected): Promise<void> {
|
||||
const env: Record<string, string> = {
|
||||
TEAMS_APP_ID: collected.appId!,
|
||||
TEAMS_APP_PASSWORD: collected.appPassword!,
|
||||
TEAMS_APP_TYPE: collected.appType!,
|
||||
};
|
||||
if (collected.appType === 'SingleTenant') {
|
||||
env.TEAMS_APP_TENANT_ID = collected.tenantId!;
|
||||
}
|
||||
|
||||
const install = await runQuietChild(
|
||||
'teams-install',
|
||||
'bash',
|
||||
['setup/add-teams.sh'],
|
||||
{
|
||||
running: 'Installing the Teams adapter and restarting the service…',
|
||||
done: 'Teams adapter installed.',
|
||||
},
|
||||
{
|
||||
env,
|
||||
extraFields: {
|
||||
APP_ID: collected.appId!,
|
||||
APP_TYPE: collected.appType!,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
fail(
|
||||
'teams-install',
|
||||
"Couldn't install the Teams adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── post-install: how to finish wiring ────────────────────────────────
|
||||
|
||||
function printPostInstallGuidance(): void {
|
||||
p.note(
|
||||
[
|
||||
"The Teams adapter is live and the service is running. To finish",
|
||||
"hooking up an agent:",
|
||||
'',
|
||||
' 1. Find your bot in Teams (search by name, or via the sideloaded',
|
||||
' app) and send it a message ("hi" is fine)',
|
||||
' 2. NanoClaw auto-creates a messaging group on the first inbound',
|
||||
' activity (Teams platform IDs are only discoverable after a',
|
||||
' real message arrives)',
|
||||
' 3. Run ' + k.cyan('/manage-channels') + ' to wire that messaging',
|
||||
' group to an agent group',
|
||||
'',
|
||||
k.dim('If the bot never replies, check logs/nanoclaw.log — most'),
|
||||
k.dim("first-run failures are webhook reachability ('messages never"),
|
||||
k.dim("reach the bot') or auth ('app password rejected')."),
|
||||
].join('\n'),
|
||||
'Step 6 of 6 — Finish wiring',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── shared step gate ──────────────────────────────────────────────────
|
||||
|
||||
async function stepGate(args: {
|
||||
stepName: string;
|
||||
stepDescription: string;
|
||||
reshow: () => Promise<void> | Promise<unknown>;
|
||||
args: { collected: Collected; completed: string[] };
|
||||
}): Promise<void> {
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'How did that go?',
|
||||
options: [
|
||||
{ value: 'done', label: "Done — let's continue" },
|
||||
{ value: 'help', label: 'Stuck — hand me off to Claude' },
|
||||
{ value: 'reshow', label: 'Show me the steps again' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (choice === 'done') return;
|
||||
if (choice === 'help') {
|
||||
await offerHandoff({
|
||||
step: args.stepName,
|
||||
stepDescription: args.stepDescription,
|
||||
args: args.args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (choice === 'reshow') {
|
||||
await args.reshow();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function offerHandoff(args: {
|
||||
step: string;
|
||||
stepDescription: string;
|
||||
args: { collected: Collected; completed: string[] };
|
||||
}): Promise<void> {
|
||||
const ctx: HandoffContext = {
|
||||
channel: CHANNEL,
|
||||
step: args.step,
|
||||
stepDescription: args.stepDescription,
|
||||
completedSteps: args.args.completed.slice(),
|
||||
collectedValues: redactCollected(args.args.collected),
|
||||
files: ['setup/channels/teams.ts', 'setup/add-teams.sh'],
|
||||
};
|
||||
await offerClaudeHandoff(ctx);
|
||||
}
|
||||
|
||||
function redactCollected(c: Collected): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (c.publicUrl) out.publicUrl = c.publicUrl;
|
||||
if (c.appId) out.appId = c.appId;
|
||||
if (c.tenantId) out.tenantId = c.tenantId;
|
||||
if (c.appType) out.appType = c.appType;
|
||||
if (c.appPassword) {
|
||||
out.appPassword = `${c.appPassword.slice(0, 4)}…${c.appPassword.slice(-4)}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── shared: UUID paste with help escape ───────────────────────────────
|
||||
|
||||
async function askUuid(
|
||||
message: string,
|
||||
logKey: string,
|
||||
args: { collected: Collected; completed: string[] },
|
||||
): Promise<string> {
|
||||
while (true) {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message,
|
||||
placeholder: '00000000-0000-0000-0000-000000000000',
|
||||
validate: validateWithHelpEscape((v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(t)) {
|
||||
return 'Expected a UUID like 00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (isHelpEscape(answer)) {
|
||||
await offerHandoff({
|
||||
step: logKey,
|
||||
stepDescription: `entering a UUID for ${logKey}`,
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const value = (answer as string).trim().toLowerCase();
|
||||
setupLog.userInput(logKey, value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── path helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function shortPath(abs: string): string {
|
||||
const home = os.homedir();
|
||||
const cwd = process.cwd();
|
||||
if (abs.startsWith(`${cwd}/`)) return abs.slice(cwd.length + 1);
|
||||
if (abs.startsWith(`${home}/`)) return `~/${abs.slice(home.length + 1)}`;
|
||||
return abs;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ const STEP_FILES: Record<string, string[]> = {
|
||||
'telegram-validate': ['setup/channels/telegram.ts'],
|
||||
'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'],
|
||||
'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'],
|
||||
'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'],
|
||||
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
|
||||
'init-first-agent': [
|
||||
'scripts/init-first-agent.ts',
|
||||
'setup/channels/telegram.ts',
|
||||
|
||||
194
setup/lib/claude-handoff.ts
Normal file
194
setup/lib/claude-handoff.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* User-initiated handoff to interactive Claude, parallel to claude-assist.ts.
|
||||
*
|
||||
* claude-assist is for failures: it runs `claude -p` non-interactively, parses
|
||||
* a suggested command, and offers to run it. This module is for the opposite
|
||||
* case — the user is mid-flow, not stuck on an error, and wants Claude to
|
||||
* walk them through something the driver can't fully automate (Azure portal
|
||||
* clickthrough, writing a manifest, tunneling a port, etc.).
|
||||
*
|
||||
* Flow:
|
||||
* 1. Build a handoff prompt from the caller's context: channel, current
|
||||
* step, completed steps, collected values (secrets redacted), relevant
|
||||
* files to read.
|
||||
* 2. Spawn `claude --append-system-prompt "<context>"
|
||||
* --permission-mode acceptEdits` with `stdio: 'inherit'` so Claude owns
|
||||
* the terminal.
|
||||
* 3. When Claude exits (user types /exit, Ctrl-D, or closes the session),
|
||||
* control returns to the setup driver. The driver can then re-offer the
|
||||
* same step (e.g., "How did that go?" select).
|
||||
*
|
||||
* Also exports a small helper for text/password prompts: `validateWithHelpEscape`
|
||||
* wraps a validate callback so typing `?` triggers the handoff instead of
|
||||
* attempting to parse it as a real answer.
|
||||
*/
|
||||
import { execSync, spawn } from 'child_process';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
export interface HandoffContext {
|
||||
/** Channel this handoff is happening in (e.g., 'teams'). */
|
||||
channel: string;
|
||||
/** Short name of the current step the user is stuck on. */
|
||||
step: string;
|
||||
/** Human-readable summary of what the user was trying to do at this step. */
|
||||
stepDescription: string;
|
||||
/** Checklist of sub-steps already completed (displayed as `✓ <item>`). */
|
||||
completedSteps?: string[];
|
||||
/**
|
||||
* Key/value pairs of values collected so far. Callers should redact
|
||||
* secrets before passing (e.g., show last 4 chars). Used to give Claude
|
||||
* the state of the operator's progress.
|
||||
*/
|
||||
collectedValues?: Record<string, string>;
|
||||
/**
|
||||
* Repo-relative paths Claude should consider reading. Always gets
|
||||
* logs/setup.log and the relevant SKILL.md appended by the builder.
|
||||
*/
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn interactive Claude with context pre-loaded as a system-prompt
|
||||
* append. Returns when Claude exits.
|
||||
*
|
||||
* Silently no-ops (returns `false`) if `claude` isn't on PATH — setup runs
|
||||
* where the binary is guaranteed to exist (we install it in the auth step),
|
||||
* but an ultra-early flow failure could technically reach this before that
|
||||
* install, and crashing the handoff would be worse than the handoff not
|
||||
* firing.
|
||||
*/
|
||||
export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean> {
|
||||
if (!isClaudeUsable()) {
|
||||
p.log.warn(
|
||||
"Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(ctx);
|
||||
|
||||
p.note(
|
||||
[
|
||||
"I'm handing you off to Claude in interactive mode.",
|
||||
"It has the context of where you are in setup.",
|
||||
"",
|
||||
k.dim("Type /exit (or press Ctrl-D) when you're ready to come back to setup."),
|
||||
].join('\n'),
|
||||
'Handing off to Claude',
|
||||
);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const child = spawn(
|
||||
'claude',
|
||||
[
|
||||
'--append-system-prompt',
|
||||
systemPrompt,
|
||||
'--permission-mode',
|
||||
'acceptEdits',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
child.on('close', () => {
|
||||
p.log.success("Back from Claude. Let's continue.");
|
||||
resolve(true);
|
||||
});
|
||||
child.on('error', () => {
|
||||
p.log.error("Couldn't launch Claude. Continuing without handoff.");
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel returned by `validateWithHelpEscape` when the user types `?`.
|
||||
* The caller compares against this to decide whether to trigger a handoff.
|
||||
*/
|
||||
export const HELP_ESCAPE_SENTINEL = '__NANOCLAW_HELP_ESCAPE__';
|
||||
|
||||
/**
|
||||
* Wrap a clack `validate` callback so typing `?` short-circuits validation
|
||||
* and returns the HELP_ESCAPE_SENTINEL. Caller should check for the sentinel
|
||||
* after awaiting the prompt and trigger offerClaudeHandoff if matched.
|
||||
*
|
||||
* Usage:
|
||||
* const answer = await p.text({
|
||||
* message: 'Paste your Azure App ID',
|
||||
* validate: validateWithHelpEscape((v) => {
|
||||
* if (!/^[0-9a-f-]{36}$/.test(v)) return 'Expected a UUID';
|
||||
* return undefined;
|
||||
* }),
|
||||
* });
|
||||
* if (answer === HELP_ESCAPE_SENTINEL) { await offerClaudeHandoff(ctx); ... }
|
||||
*/
|
||||
export function validateWithHelpEscape(
|
||||
inner?: (value: string) => string | Error | undefined,
|
||||
): (value: string) => string | Error | undefined {
|
||||
return (value: string) => {
|
||||
if ((value ?? '').trim() === '?') {
|
||||
// Returning undefined lets clack accept the `?` as the "answer". The
|
||||
// caller sees a literal "?" and should compare + escape to handoff.
|
||||
return undefined;
|
||||
}
|
||||
return inner ? inner(value) : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the value returned by a text/password prompt should trigger a
|
||||
* handoff. Abstracts the sentinel check so callers don't have to import it
|
||||
* directly at every site.
|
||||
*/
|
||||
export function isHelpEscape(value: unknown): boolean {
|
||||
return typeof value === 'string' && value.trim() === '?';
|
||||
}
|
||||
|
||||
function isClaudeUsable(): boolean {
|
||||
try {
|
||||
execSync('command -v claude', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSystemPrompt(ctx: HandoffContext): string {
|
||||
const lines: string[] = [
|
||||
`The user is running NanoClaw's interactive \`setup:auto\` flow to wire the ${ctx.channel} channel.`,
|
||||
`They got stuck at the step: "${ctx.step}" (${ctx.stepDescription}) and asked for help.`,
|
||||
'',
|
||||
"Your job: help them complete this specific step and get back to setup.",
|
||||
"You can read files, run commands (with acceptEdits permissions), search the web,",
|
||||
"and explain concepts. Be concise. When they're ready to resume, tell them to type",
|
||||
"/exit and they'll return to the setup flow at the same step.",
|
||||
'',
|
||||
];
|
||||
|
||||
if (ctx.completedSteps && ctx.completedSteps.length > 0) {
|
||||
lines.push('Steps they have already completed:');
|
||||
for (const s of ctx.completedSteps) lines.push(` ✓ ${s}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (ctx.collectedValues && Object.keys(ctx.collectedValues).length > 0) {
|
||||
lines.push('Values collected so far (secrets redacted):');
|
||||
for (const [k, v] of Object.entries(ctx.collectedValues)) {
|
||||
lines.push(` ${k}: ${v}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const files = [
|
||||
...(ctx.files ?? []),
|
||||
'logs/setup.log',
|
||||
'logs/setup-steps/',
|
||||
`.claude/skills/add-${ctx.channel}/SKILL.md`,
|
||||
`setup/channels/${ctx.channel}.ts`,
|
||||
].filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
lines.push('Relevant files (read as needed with the Read tool):');
|
||||
for (const f of files) lines.push(` - ${f}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
271
setup/lib/teams-manifest.ts
Normal file
271
setup/lib/teams-manifest.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Build the Teams app package zip that the operator sideloads from the Teams
|
||||
* "Manage your apps" screen.
|
||||
*
|
||||
* A Teams app package is a zip containing:
|
||||
* - manifest.json — declares the bot, scopes, required permissions
|
||||
* - outline.png — 32×32 transparent outline icon
|
||||
* - color.png — 192×192 full-color icon
|
||||
*
|
||||
* Icons are generated in-process using a minimal PNG encoder so we don't
|
||||
* need ImageMagick or vendor binary icon blobs into the repo. The outline
|
||||
* icon is a simple rounded square outline; the color icon is a brand-blue
|
||||
* filled square with a small white "N" blocked in by pixel setting. Good
|
||||
* enough for a working sideload — teams admins who care can replace the
|
||||
* icons later.
|
||||
*
|
||||
* The manifest is pinned to schema v1.16 to match the skill doc.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
|
||||
const MANIFEST_SCHEMA =
|
||||
'https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json';
|
||||
const MANIFEST_VERSION = '1.16';
|
||||
|
||||
export interface ManifestOptions {
|
||||
/** The Azure AD app ID (same value used for `bots[0].botId`). */
|
||||
appId: string;
|
||||
/** Short bot name shown in Teams (<= 30 chars). */
|
||||
shortName: string;
|
||||
/** Long bot description. */
|
||||
longDescription: string;
|
||||
/** Developer website URL (required by schema — any reachable URL works). */
|
||||
websiteUrl: string;
|
||||
/** Out-dir for the generated zip + loose files. */
|
||||
outDir: string;
|
||||
}
|
||||
|
||||
export interface ManifestResult {
|
||||
zipPath: string;
|
||||
manifestPath: string;
|
||||
outlinePath: string;
|
||||
colorPath: string;
|
||||
}
|
||||
|
||||
/** Build the full app package zip and return the paths. */
|
||||
export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult {
|
||||
fs.mkdirSync(opts.outDir, { recursive: true });
|
||||
|
||||
const manifestPath = path.join(opts.outDir, 'manifest.json');
|
||||
const outlinePath = path.join(opts.outDir, 'outline.png');
|
||||
const colorPath = path.join(opts.outDir, 'color.png');
|
||||
const zipPath = path.join(opts.outDir, 'teams-app-package.zip');
|
||||
|
||||
fs.writeFileSync(manifestPath, renderManifest(opts));
|
||||
fs.writeFileSync(outlinePath, encodeOutlineIcon());
|
||||
fs.writeFileSync(colorPath, encodeColorIcon());
|
||||
|
||||
// Fresh zip every run — idempotent, no stale files.
|
||||
try {
|
||||
fs.unlinkSync(zipPath);
|
||||
} catch {
|
||||
// noop if missing
|
||||
}
|
||||
execSync(`zip -j -q "${zipPath}" "${manifestPath}" "${outlinePath}" "${colorPath}"`, {
|
||||
stdio: ['ignore', 'ignore', 'inherit'],
|
||||
});
|
||||
|
||||
return { zipPath, manifestPath, outlinePath, colorPath };
|
||||
}
|
||||
|
||||
function renderManifest(opts: ManifestOptions): string {
|
||||
const manifest = {
|
||||
$schema: MANIFEST_SCHEMA,
|
||||
manifestVersion: MANIFEST_VERSION,
|
||||
version: '1.0.0',
|
||||
id: opts.appId,
|
||||
packageName: 'com.nanoclaw.bot',
|
||||
developer: {
|
||||
name: 'NanoClaw',
|
||||
websiteUrl: opts.websiteUrl,
|
||||
privacyUrl: opts.websiteUrl,
|
||||
termsOfUseUrl: opts.websiteUrl,
|
||||
},
|
||||
name: {
|
||||
short: opts.shortName.slice(0, 30),
|
||||
full: `${opts.shortName} Assistant`,
|
||||
},
|
||||
description: {
|
||||
short: 'Your personal assistant in Teams.',
|
||||
full: opts.longDescription,
|
||||
},
|
||||
icons: { outline: 'outline.png', color: 'color.png' },
|
||||
accentColor: '#4A90D9',
|
||||
bots: [
|
||||
{
|
||||
botId: opts.appId,
|
||||
scopes: ['personal', 'team', 'groupchat'],
|
||||
supportsFiles: false,
|
||||
isNotificationOnly: false,
|
||||
},
|
||||
],
|
||||
permissions: ['identity', 'messageTeamMembers'],
|
||||
validDomains: [new URL(opts.websiteUrl).host],
|
||||
};
|
||||
return JSON.stringify(manifest, null, 2) + '\n';
|
||||
}
|
||||
|
||||
// ─── Minimal PNG encoder (solid color, no external deps) ──────────────────
|
||||
|
||||
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
// Precompute the CRC-32 table per the PNG spec. Node doesn't expose CRC32
|
||||
// directly (zlib.crc32 isn't part of the public API), so we roll our own.
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type: string, data: Buffer): Buffer {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const typeBuf = Buffer.from(type, 'ascii');
|
||||
const crcBuf = Buffer.alloc(4);
|
||||
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a solid-color RGBA image as a PNG. `pixels` is a width*height*4
|
||||
* byte array (R, G, B, A per pixel, row-major, top-to-bottom).
|
||||
*/
|
||||
function encodePng(width: number, height: number, pixels: Uint8Array): Buffer {
|
||||
// IHDR
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type: RGBA
|
||||
ihdr[10] = 0; // compression
|
||||
ihdr[11] = 0; // filter
|
||||
ihdr[12] = 0; // interlace
|
||||
|
||||
// IDAT: scanlines with filter byte 0 (None) prepended per row.
|
||||
const rowBytes = width * 4;
|
||||
const raw = Buffer.alloc(height * (rowBytes + 1));
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (rowBytes + 1)] = 0;
|
||||
for (let x = 0; x < rowBytes; x++) {
|
||||
raw[y * (rowBytes + 1) + 1 + x] = pixels[y * rowBytes + x];
|
||||
}
|
||||
}
|
||||
const idat = zlib.deflateSync(raw);
|
||||
|
||||
return Buffer.concat([
|
||||
PNG_SIG,
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', idat),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline icon: 32×32 transparent background with a simple white rounded-
|
||||
* square outline. Teams renders it against a colored background so the
|
||||
* outline needs to be visible on both light and dark.
|
||||
*/
|
||||
function encodeOutlineIcon(): Buffer {
|
||||
const size = 32;
|
||||
const pixels = new Uint8Array(size * size * 4);
|
||||
const inset = 4;
|
||||
const stroke = 2;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const onBorder =
|
||||
((x >= inset && x < inset + stroke) || (x >= size - inset - stroke && x < size - inset)) &&
|
||||
y >= inset &&
|
||||
y < size - inset;
|
||||
const onTopBot =
|
||||
((y >= inset && y < inset + stroke) || (y >= size - inset - stroke && y < size - inset)) &&
|
||||
x >= inset &&
|
||||
x < size - inset;
|
||||
const i = (y * size + x) * 4;
|
||||
if (onBorder || onTopBot) {
|
||||
pixels[i] = 255;
|
||||
pixels[i + 1] = 255;
|
||||
pixels[i + 2] = 255;
|
||||
pixels[i + 3] = 255;
|
||||
} else {
|
||||
pixels[i] = 0;
|
||||
pixels[i + 1] = 0;
|
||||
pixels[i + 2] = 0;
|
||||
pixels[i + 3] = 0; // transparent
|
||||
}
|
||||
}
|
||||
}
|
||||
return encodePng(size, size, pixels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Color icon: 192×192 brand-blue filled square with a white "N" shape drawn
|
||||
* with simple bars (left vertical, right vertical, diagonal from top-right
|
||||
* to bottom-left). Crude but recognizable at a glance.
|
||||
*/
|
||||
function encodeColorIcon(): Buffer {
|
||||
const size = 192;
|
||||
const pixels = new Uint8Array(size * size * 4);
|
||||
// Brand blue #4A90D9
|
||||
const BG_R = 0x4a;
|
||||
const BG_G = 0x90;
|
||||
const BG_B = 0xd9;
|
||||
const thickness = 24;
|
||||
const margin = 40;
|
||||
const leftBarX = margin;
|
||||
const rightBarX = size - margin - thickness;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const i = (y * size + x) * 4;
|
||||
pixels[i] = BG_R;
|
||||
pixels[i + 1] = BG_G;
|
||||
pixels[i + 2] = BG_B;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
// Vertical bars
|
||||
for (let y = margin; y < size - margin; y++) {
|
||||
for (let dx = 0; dx < thickness; dx++) {
|
||||
setWhite(pixels, size, leftBarX + dx, y);
|
||||
setWhite(pixels, size, rightBarX + dx, y);
|
||||
}
|
||||
}
|
||||
// Diagonal from top-right of left bar to bottom-left of right bar
|
||||
const diagSteps = size - margin * 2;
|
||||
for (let s = 0; s < diagSteps; s++) {
|
||||
const t = s / (diagSteps - 1);
|
||||
const cx = Math.round(leftBarX + thickness + t * (rightBarX - leftBarX - thickness));
|
||||
const cy = Math.round(margin + t * (size - margin * 2 - 1));
|
||||
for (let dx = -Math.floor(thickness / 2); dx < Math.ceil(thickness / 2); dx++) {
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
setWhite(pixels, size, cx + dx, cy + dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
return encodePng(size, size, pixels);
|
||||
}
|
||||
|
||||
function setWhite(pixels: Uint8Array, size: number, x: number, y: number): void {
|
||||
if (x < 0 || x >= size || y < 0 || y >= size) return;
|
||||
const i = (y * size + x) * 4;
|
||||
pixels[i] = 255;
|
||||
pixels[i + 1] = 255;
|
||||
pixels[i + 2] = 255;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
Reference in New Issue
Block a user