diff --git a/setup/assets/teams/color.png b/setup/assets/teams/color.png new file mode 100644 index 000000000..b36664f1a Binary files /dev/null and b/setup/assets/teams/color.png differ diff --git a/setup/assets/teams/manifest.template.json b/setup/assets/teams/manifest.template.json new file mode 100644 index 000000000..74557f998 --- /dev/null +++ b/setup/assets/teams/manifest.template.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json", + "manifestVersion": "1.16", + "version": "1.0.0", + "id": "00000000-0000-0000-0000-000000000000", + "packageName": "com.nanoclaw.bot", + "developer": { + "name": "NanoClaw", + "websiteUrl": "https://nanoclaw.invalid", + "privacyUrl": "https://nanoclaw.invalid", + "termsOfUseUrl": "https://nanoclaw.invalid" + }, + "name": { + "short": "NanoClaw", + "full": "NanoClaw Assistant" + }, + "description": { + "short": "Your personal assistant in Teams.", + "full": "NanoClaw personal assistant." + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#4A90D9", + "bots": [ + { + "botId": "00000000-0000-0000-0000-000000000000", + "scopes": ["personal", "team", "groupchat"], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": ["identity", "messageTeamMembers"], + "validDomains": ["nanoclaw.invalid"] +} diff --git a/setup/assets/teams/outline.png b/setup/assets/teams/outline.png new file mode 100644 index 000000000..937b38388 Binary files /dev/null and b/setup/assets/teams/outline.png differ diff --git a/setup/lib/teams-manifest.test.ts b/setup/lib/teams-manifest.test.ts index 2a1e08c84..e5e041df7 100644 --- a/setup/lib/teams-manifest.test.ts +++ b/setup/lib/teams-manifest.test.ts @@ -113,6 +113,12 @@ describe('buildTeamsAppPackage', () => { expect(manifest.icons).toEqual({ outline: 'outline.png', color: 'color.png' }); }); + it('leaves no template placeholder values in the rendered manifest', () => { + const raw = entries[0].data.toString('utf8'); + expect(raw).not.toContain('00000000-0000-0000-0000-000000000000'); + expect(raw).not.toContain('nanoclaw.invalid'); + }); + it('emits real PNGs for both icons', () => { expect(entries[1].data.subarray(0, 8).equals(PNG_SIG)).toBe(true); expect(entries[2].data.subarray(0, 8).equals(PNG_SIG)).toBe(true); diff --git a/setup/lib/teams-manifest.ts b/setup/lib/teams-manifest.ts index 472e27d8e..69ee5bc5b 100644 --- a/setup/lib/teams-manifest.ts +++ b/setup/lib/teams-manifest.ts @@ -7,23 +7,19 @@ * - outline.png — 32×32 transparent outline icon * - color.png — 192×192 full-color icon * - * Icons are generated in-process using a minimal PNG encoder, and the zip is - * written in-process too, so we need neither ImageMagick nor a `zip` binary - * on the host, nor vendored binary blobs in 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. + * The static parts live in setup/assets/teams/ — manifest.template.json + * (pinned to schema v1.16 to match the skill doc) plus the two icons — so + * the manifest is reviewable as plain JSON. This module only fills in the + * per-install fields (app id, name, domain, optional RSC block) and zips the + * three files in-process via ./zip.ts, so no `zip` binary is needed on the + * host. */ 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'; +import { buildZip } from './zip.js'; + +const ASSETS_DIR = new URL('../assets/teams/', import.meta.url); export interface ManifestOptions { /** The Azure AD app ID (same value used for `bots[0].botId`). */ @@ -50,6 +46,19 @@ export interface ManifestResult { colorPath: string; } +/** The manifest.template.json fields this module rewrites per install. */ +interface ManifestTemplate { + version: string; + id: string; + developer: { name: string; websiteUrl: string; privacyUrl: string; termsOfUseUrl: string }; + name: { short: string; full: string }; + description: { short: string; full: string }; + bots: [{ botId: string }]; + validDomains: string[]; + webApplicationInfo?: { id: string; resource: string }; + authorization?: { permissions: { resourceSpecific: Array<{ name: string; type: string }> } }; +} + /** Build the full app package zip and return the paths. */ export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult { fs.mkdirSync(opts.outDir, { recursive: true }); @@ -60,8 +69,8 @@ export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult { const zipPath = path.join(opts.outDir, 'teams-app-package.zip'); const manifest = Buffer.from(renderManifest(opts)); - const outline = encodeOutlineIcon(); - const color = encodeColorIcon(); + const outline = fs.readFileSync(new URL('outline.png', ASSETS_DIR)); + const color = fs.readFileSync(new URL('color.png', ASSETS_DIR)); fs.writeFileSync(manifestPath, manifest); fs.writeFileSync(outlinePath, outline); @@ -78,280 +87,39 @@ export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult { return { zipPath, manifestPath, outlinePath, colorPath }; } -// ─── Minimal ZIP writer (no external deps, no `zip` binary) ─────────────── -// -// Entries are stored uncompressed (method 0): the package is three tiny -// files, and stored entries keep this trivially small and byte-deterministic -// (fixed 1980-01-01 timestamp), which the test relies on. - -interface ZipEntry { - name: string; - data: Buffer; -} - -// DOS-format date for 1980-01-01: bits 15–9 year-1980, 8–5 month, 4–0 day. -const ZIP_DOS_DATE = (1 << 5) | 1; - -function buildZip(entries: ZipEntry[]): Buffer { - const locals: Buffer[] = []; - const centrals: Buffer[] = []; - let offset = 0; - - for (const { name, data } of entries) { - const nameBuf = Buffer.from(name, 'ascii'); - const crc = crc32(data); - - const local = Buffer.alloc(30); - local.writeUInt32LE(0x04034b50, 0); // local file header signature - local.writeUInt16LE(20, 4); // version needed to extract (2.0) - local.writeUInt16LE(0, 8); // compression method: stored - local.writeUInt16LE(ZIP_DOS_DATE, 12); - local.writeUInt32LE(crc, 14); - local.writeUInt32LE(data.length, 18); // compressed size - local.writeUInt32LE(data.length, 22); // uncompressed size - local.writeUInt16LE(nameBuf.length, 26); - locals.push(local, nameBuf, data); - - const central = Buffer.alloc(46); - central.writeUInt32LE(0x02014b50, 0); // central directory header signature - central.writeUInt16LE(20, 4); // version made by - central.writeUInt16LE(20, 6); // version needed to extract - central.writeUInt16LE(0, 10); // compression method: stored - central.writeUInt16LE(ZIP_DOS_DATE, 14); - central.writeUInt32LE(crc, 16); - central.writeUInt32LE(data.length, 20); // compressed size - central.writeUInt32LE(data.length, 24); // uncompressed size - central.writeUInt16LE(nameBuf.length, 28); - central.writeUInt32LE(offset, 42); // offset of local header - centrals.push(central, nameBuf); - - offset += 30 + nameBuf.length + data.length; - } - - const centralDir = Buffer.concat(centrals); - const eocd = Buffer.alloc(22); - eocd.writeUInt32LE(0x06054b50, 0); // end-of-central-directory signature - eocd.writeUInt16LE(entries.length, 8); // entries on this disk - eocd.writeUInt16LE(entries.length, 10); // entries total - eocd.writeUInt32LE(centralDir.length, 12); - eocd.writeUInt32LE(offset, 16); // central directory offset - - return Buffer.concat([...locals, centralDir, eocd]); -} - function renderManifest(opts: ManifestOptions): string { - const manifest = { - $schema: MANIFEST_SCHEMA, - manifestVersion: MANIFEST_VERSION, + const manifest = JSON.parse( + fs.readFileSync(new URL('manifest.template.json', ASSETS_DIR), 'utf8'), + ) as ManifestTemplate; + + manifest.id = opts.appId; + manifest.bots[0].botId = opts.appId; + manifest.name.short = opts.shortName.slice(0, 30); + manifest.name.full = `${opts.shortName} Assistant`; + manifest.description.full = opts.longDescription; + manifest.developer.websiteUrl = opts.websiteUrl; + manifest.developer.privacyUrl = opts.websiteUrl; + manifest.developer.termsOfUseUrl = opts.websiteUrl; + manifest.validDomains = [new URL(opts.websiteUrl).host]; + + if (opts.rsc) { // Teams app-update flows want a higher version than the already-uploaded // package, so the RSC variant (typically a re-upload) bumps it. - version: opts.rsc ? '1.1.0' : '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, + manifest.version = '1.1.0'; + // RSC grants bind to webApplicationInfo.id, not bots[].botId — without + // this block the permissions are never attached to the app and the bot + // silently keeps requiring @-mention. `resource` must be non-empty but + // its value is unused for RSC-only apps. + manifest.webApplicationInfo = { id: opts.appId, resource: 'https://notapplicable' }; + manifest.authorization = { + permissions: { + resourceSpecific: [ + { name: 'ChannelMessage.Read.Group', type: 'Application' }, + { name: 'ChatMessage.Read.Chat', type: 'Application' }, + ], }, - ], - permissions: ['identity', 'messageTeamMembers'], - validDomains: [new URL(opts.websiteUrl).host], - ...(opts.rsc && { - // RSC grants bind to webApplicationInfo.id, not bots[].botId — without - // this block the permissions are never attached to the app and the bot - // silently keeps requiring @-mention. `resource` must be non-empty but - // its value is unused for RSC-only apps. - webApplicationInfo: { id: opts.appId, resource: 'https://notapplicable' }, - authorization: { - permissions: { - resourceSpecific: [ - { name: 'ChannelMessage.Read.Group', type: 'Application' }, - { name: 'ChatMessage.Read.Chat', type: 'Application' }, - ], - }, - }, - }), - }; + }; + } + 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; -} - diff --git a/setup/lib/zip.ts b/setup/lib/zip.ts new file mode 100644 index 000000000..2c9f8e6f0 --- /dev/null +++ b/setup/lib/zip.ts @@ -0,0 +1,87 @@ +/** + * Minimal in-process ZIP writer — needs no external dep and no `zip` binary + * on the host (minimal Linux images often lack one). + * + * Entries are stored uncompressed (method 0): callers package a few tiny + * files, and stored entries keep the output trivially small and + * byte-deterministic (fixed 1980-01-01 timestamp), which tests rely on. + * ASCII entry names only; no zip64 — fine below 4 GB and 65k entries. + */ + +export interface ZipEntry { + name: string; + data: Buffer; +} + +// DOS-format date for 1980-01-01: bits 15–9 year-1980, 8–5 month, 4–0 day. +const ZIP_DOS_DATE = (1 << 5) | 1; + +export function buildZip(entries: ZipEntry[]): Buffer { + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + + for (const { name, data } of entries) { + const nameBuf = Buffer.from(name, 'ascii'); + const crc = crc32(data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); // local file header signature + local.writeUInt16LE(20, 4); // version needed to extract (2.0) + local.writeUInt16LE(0, 8); // compression method: stored + local.writeUInt16LE(ZIP_DOS_DATE, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); // compressed size + local.writeUInt32LE(data.length, 22); // uncompressed size + local.writeUInt16LE(nameBuf.length, 26); + locals.push(local, nameBuf, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); // central directory header signature + central.writeUInt16LE(20, 4); // version made by + central.writeUInt16LE(20, 6); // version needed to extract + central.writeUInt16LE(0, 10); // compression method: stored + central.writeUInt16LE(ZIP_DOS_DATE, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); // compressed size + central.writeUInt32LE(data.length, 24); // uncompressed size + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt32LE(offset, 42); // offset of local header + centrals.push(central, nameBuf); + + offset += 30 + nameBuf.length + data.length; + } + + const centralDir = Buffer.concat(centrals); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); // end-of-central-directory signature + eocd.writeUInt16LE(entries.length, 8); // entries on this disk + eocd.writeUInt16LE(entries.length, 10); // entries total + eocd.writeUInt32LE(centralDir.length, 12); + eocd.writeUInt32LE(offset, 16); // central directory offset + + return Buffer.concat([...locals, centralDir, eocd]); +} + +// Precompute the CRC-32 table per the ZIP spec. zlib.crc32 only became +// public API in Node 20.15/22.2, so we roll our own rather than gamble on +// the host's Node version. +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; +}