From 1292bf3db24267ea84349464ecb3f724cb5738f2 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 8 Jul 2026 12:02:09 +0300 Subject: [PATCH] =?UTF-8?q?add-teams:=20app-package=20assets=20as=20data?= =?UTF-8?q?=20=E2=80=94=20manifest=20template,=20mascot=20icons,=20extract?= =?UTF-8?q?ed=20zip=20writer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest is now a checked-in manifest.template.json (code only patches the per-install fields: app id, name, domain, optional RSC block), the two icons are committed PNGs rendered from the NanoClaw mascot instead of a 160-line in-process PNG encoder, and the minimal stored-zip writer moves to setup/lib/zip.ts. buildTeamsAppPackage's interface is unchanged. Co-Authored-By: Claude Fable 5 --- setup/assets/teams/color.png | Bin 0 -> 13466 bytes setup/assets/teams/manifest.template.json | 36 +++ setup/assets/teams/outline.png | Bin 0 -> 392 bytes setup/lib/teams-manifest.test.ts | 6 + setup/lib/teams-manifest.ts | 340 ++++------------------ setup/lib/zip.ts | 87 ++++++ 6 files changed, 183 insertions(+), 286 deletions(-) create mode 100644 setup/assets/teams/color.png create mode 100644 setup/assets/teams/manifest.template.json create mode 100644 setup/assets/teams/outline.png create mode 100644 setup/lib/zip.ts diff --git a/setup/assets/teams/color.png b/setup/assets/teams/color.png new file mode 100644 index 0000000000000000000000000000000000000000..b36664f1af8e166657e0d244bc29ae3a7bc782ce GIT binary patch literal 13466 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 mNUuSIzLwY`dDwMMNyY%uOLr**b-Pvo0000 { 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; +}