add-teams: app-package assets as data — manifest template, mascot icons, extracted zip writer

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 <noreply@anthropic.com>
This commit is contained in:
Koshkoshinsk
2026-07-08 12:02:09 +03:00
parent 384231ab0b
commit 1292bf3db2
6 changed files with 183 additions and 286 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+36
View File
@@ -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"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

+6
View File
@@ -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);
+54 -286
View File
@@ -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 159 year-1980, 85 month, 40 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;
}
+87
View File
@@ -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 159 year-1980, 85 month, 40 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;
}