mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8644e6bedb | |||
| 571d72ba47 | |||
| 37e2d2b2bb | |||
| ca298d3714 | |||
| d9859fd59a | |||
| ff557092d8 | |||
| f6901e01a4 | |||
| 36a7e3cf81 |
@@ -18,19 +18,38 @@ rm -f src/channels/teams.ts src/channels/teams-registration.test.ts
|
|||||||
|
|
||||||
## 2. Remove credentials
|
## 2. Remove credentials
|
||||||
|
|
||||||
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`, then re-sync to the container:
|
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`.
|
||||||
|
|
||||||
```bash
|
## 3. Sign out the Teams CLI, then remove the packages
|
||||||
mkdir -p data/env && cp .env data/env/env
|
|
||||||
```
|
`teams login` caches a Microsoft 365 session on disk that outlives the package —
|
||||||
|
sign out first (skip if the CLI was never installed):
|
||||||
## 3. Remove the package
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
teams logout
|
||||||
|
npm uninstall -g @microsoft/teams.cli
|
||||||
pnpm uninstall @chat-adapter/teams
|
pnpm uninstall @chat-adapter/teams
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. Rebuild and restart
|
## 4. Remove local artifacts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf data/teams
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Clean up cloud resources
|
||||||
|
|
||||||
|
Uninstall the app from Teams (Apps > Manage your apps). Then, on **both**
|
||||||
|
paths, delete the Entra app registration in Azure Portal > App registrations —
|
||||||
|
that is the step that actually revokes the client secret. Additionally:
|
||||||
|
|
||||||
|
- **Teams CLI path**: delete the app listing in the Teams Developer Portal
|
||||||
|
(https://dev.teams.microsoft.com/apps) — removing it there alone does NOT
|
||||||
|
revoke the secret.
|
||||||
|
- **Manual Azure path**: delete the Azure Bot resource, and the `nanoclaw-rg`
|
||||||
|
resource group if you created one (`az group delete --name nanoclaw-rg`).
|
||||||
|
|
||||||
|
## 6. Rebuild and restart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm run build
|
pnpm run build
|
||||||
|
|||||||
+261
-168
@@ -14,11 +14,14 @@ reads the prose and applies them, and a parser can apply them deterministically
|
|||||||
from the same document. Every directive is idempotent, so the whole skill is
|
from the same document. Every directive is idempotent, so the whole skill is
|
||||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||||
|
|
||||||
Teams is the most involved channel NanoClaw supports — there's no "paste a
|
Teams has no "paste a token" shortcut — a bot has to exist in Microsoft's cloud
|
||||||
token" shortcut. You'll walk through about seven Azure portal steps (app
|
before it can receive a message. The Microsoft Teams CLI collapses that into
|
||||||
registration, client secret, Azure Bot resource, messaging endpoint, Teams
|
one sign-in and one create command: it registers the Entra app, generates the
|
||||||
channel, app package, sideload). Take them one at a time; the prompts below
|
client secret, registers a Teams-managed bot (through the Teams Developer
|
||||||
collect each value as you produce it.
|
Portal — **no Azure subscription needed**), uploads the app package, and hands
|
||||||
|
back an install link. The old ~7-step Azure portal walk survives only as a
|
||||||
|
fallback in [Alternatives](#alternatives) for tenants where the Developer
|
||||||
|
Portal is blocked.
|
||||||
|
|
||||||
## Apply
|
## Apply
|
||||||
|
|
||||||
@@ -71,139 +74,156 @@ runs.
|
|||||||
## Credentials
|
## Credentials
|
||||||
|
|
||||||
The adapter is installed and registered, but it can't receive a message until a
|
The adapter is installed and registered, but it can't receive a message until a
|
||||||
bot exists in Azure, points at this machine, and is sideloaded into Teams. None
|
bot exists, points at this machine, and is installed into Teams. The Teams CLI
|
||||||
of those steps can be clicked through by a parser, so they're operator
|
does all of that below.
|
||||||
instructions — relay each one, then collect the value it produces.
|
|
||||||
|
|
||||||
Before you start, tell the user:
|
### Check for existing credentials
|
||||||
|
|
||||||
```nc:operator
|
Re-running `teams app create` provisions a brand-new app registration and bot
|
||||||
|
each time — it never reuses the first one. So the flow starts with a probe:
|
||||||
|
when `.env` already carries a Teams credential — either key; a partial pair
|
||||||
|
means a half-finished setup that creating ANOTHER app would only corrupt —
|
||||||
|
every step below (prompts included) is skipped and the flow drops straight
|
||||||
|
through to [Restart](#restart). To rotate credentials or finish a partial
|
||||||
|
configuration, see [Troubleshooting](#troubleshooting); if your tunnel URL
|
||||||
|
changed, the fix is `teams app update`, not a re-run (also in Troubleshooting).
|
||||||
|
|
||||||
|
```nc:run capture:have_creds
|
||||||
|
( grep -q '^TEAMS_APP_ID=.' .env 2>/dev/null || grep -q '^TEAMS_APP_PASSWORD=.' .env 2>/dev/null ) && echo yes || echo no
|
||||||
|
```
|
||||||
|
|
||||||
|
Before creating anything, tell the user:
|
||||||
|
|
||||||
|
```nc:operator when:have_creds=no
|
||||||
Confirm you have everything Teams setup needs:
|
Confirm you have everything Teams setup needs:
|
||||||
1. 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 with Teams admin or developer rights.
|
1. A Microsoft 365 account that can create Entra app registrations and upload custom apps (sideloading) — free personal Teams does NOT qualify; you need a Microsoft 365 Business / EDU / developer tenant.
|
||||||
2. A way to expose an HTTPS endpoint from this machine (ngrok, a Cloudflare Tunnel, or a reverse-proxied VPS). Azure Bot Service delivers activities to it.
|
2. A way to expose an HTTPS endpoint that forwards to this machine's webhook port 3000 (ngrok, a Cloudflare Tunnel, or a reverse-proxied VPS). Start it now if it isn't running — e.g. `ngrok http 3000` — the create step needs the URL up front.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Public URL
|
### Public URL
|
||||||
|
|
||||||
Azure Bot Service delivers messages to an HTTPS endpoint you control; it has to
|
Microsoft delivers bot messages to an HTTPS endpoint you control; it has to
|
||||||
reach this machine's webhook server (port 3000) at `/api/webhooks/teams`. If you
|
reach this machine's webhook server (port 3000, configurable via
|
||||||
don't have a tunnel running yet, start one in another terminal first — e.g.
|
`WEBHOOK_PORT`) at `/webhook/teams`.
|
||||||
`ngrok http 3000` gives you `https://abcd1234.ngrok.io`.
|
|
||||||
|
|
||||||
```nc:prompt public_url validate:^https:// normalize:rstrip-slash
|
```nc:prompt public_url when:have_creds=no validate:^https:// normalize:rstrip-slash
|
||||||
Paste your public base URL (https://…, no trailing path) — e.g. https://abcd1234.ngrok.io.
|
Paste the public https:// base URL that forwards to this machine's port 3000 (no trailing path) — e.g. https://abcd1234.ngrok.io from `ngrok http 3000`.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Register the Azure app
|
### App name and tenant
|
||||||
|
|
||||||
Tell the user:
|
Two more choices belong to the human before anything is created. The name is
|
||||||
|
used everywhere at once: the Entra app registration, the bot, and the Teams
|
||||||
|
app are all created under it. There is no client-secret name to pick on this
|
||||||
|
path — the CLI generates the secret itself (Entra displayName `default`,
|
||||||
|
2-year expiry); rotating it later is in [Troubleshooting](#troubleshooting).
|
||||||
|
|
||||||
```nc:operator
|
```nc:prompt app_name when:have_creds=no validate:^[\sA-Za-z0-9._-]{1,30}$ normalize:trim
|
||||||
Create the Azure AD app registration:
|
What should the bot be called? One name covers the Entra app registration, the bot, and the Teams app (letters, digits, spaces, . _ -; max 30 characters) — e.g. NanoClaw.
|
||||||
1. In https://portal.azure.com, search "App registrations" → "New registration".
|
|
||||||
2. Name it (e.g. "NanoClaw").
|
|
||||||
3. Supported account types: Single tenant (your org only — most common for self-host) OR Multi tenant (any Microsoft 365 tenant can add the bot).
|
|
||||||
4. Click Register.
|
|
||||||
5. On the Overview page, copy the Application (client) ID and, for a single-tenant app, the Directory (tenant) ID.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```nc:prompt app_id validate:^[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}$
|
```nc:prompt tenant when:have_creds=no validate:^(single|multi)$ normalize:lower
|
||||||
Paste the Application (client) ID — App registration Overview page.
|
Who should be able to install the bot — answer "single" (only your own Microsoft 365 tenant; the safe default for a self-hosted assistant) or "multi" (any Microsoft 365 tenant).
|
||||||
```
|
|
||||||
```nc:prompt app_type validate:^(SingleTenant|MultiTenant)$
|
|
||||||
Enter the app type — `SingleTenant` or `MultiTenant` (must match the account type you picked).
|
|
||||||
```
|
|
||||||
```nc:prompt app_tenant_id when:app_type=SingleTenant validate:^[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}$
|
|
||||||
Paste the Directory (tenant) ID — App registration Overview page (Single Tenant only).
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Create a client secret
|
### Install the Teams CLI
|
||||||
|
|
||||||
Tell the user:
|
Installed globally with npm — not as a workspace dependency — deliberately:
|
||||||
|
the CLI's credential store (keytar) is a native module whose install script
|
||||||
|
must run to fetch its prebuilt binary, and pnpm's supply-chain policy blocks
|
||||||
|
dependency build scripts — a workspace install leaves the sign-in unable to
|
||||||
|
persist. The global install matches Microsoft's own instruction and keeps the
|
||||||
|
workspace policy intact. Pinned; re-running is a no-op. (If npm reports
|
||||||
|
EACCES here, your global prefix needs root — prefer a user-level Node like
|
||||||
|
nvm, or `npm config set prefix ~/.npm-global`.) `--loglevel=error` because
|
||||||
|
npm runs inside a pnpm script here and warns about every pnpm config var it
|
||||||
|
inherits — pure noise; real errors still print.
|
||||||
|
|
||||||
```nc:operator
|
```nc:run effect:external when:have_creds=no
|
||||||
Create the client secret:
|
npm install -g @microsoft/teams.cli@3.0.2 --loglevel=error
|
||||||
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. COPY THE VALUE NOW — Azure only shows it once (the Value column, not the Secret ID).
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```nc:prompt app_password secret validate:^.{20,}$
|
npm's global bin directory is not reliably on PATH (custom prefixes rarely
|
||||||
Paste the client secret Value — Certificates & secrets (shown only once, at least 20 characters).
|
are), so every step below calls the CLI by its absolute path,
|
||||||
|
`$(npm prefix -g)/bin/teams` (stderr of the prefix lookup silenced — same
|
||||||
|
pnpm-config noise as above). Where this document says to run `teams …` by
|
||||||
|
hand, use that path too if plain `teams` isn't found.
|
||||||
|
|
||||||
|
### Sign in to Microsoft 365
|
||||||
|
|
||||||
|
Every `teams` command is a separate process, so the sign-in must survive into
|
||||||
|
the next one via the CLI's on-disk token cache. On Linux that cache needs the
|
||||||
|
libsecret library — without it the session silently evaporates when the login
|
||||||
|
process exits, and the create step fails with `AUTH_REQUIRED`. Install it
|
||||||
|
first on Linux: `sudo apt-get install -y libsecret-1-0` (Debian/Ubuntu). A
|
||||||
|
"libsecret not found — token cache will be stored unencrypted" warning on a
|
||||||
|
headless box is expected and harmless even WITH libsecret installed: there is
|
||||||
|
no keyring daemon to talk to, so the CLI uses its plaintext cache file, which
|
||||||
|
persists fine (encryption-at-rest is all you give up). The
|
||||||
|
step below verifies persistence by re-reading the session from a fresh
|
||||||
|
process after login. In an interactive terminal the login opens a browser;
|
||||||
|
on a headless box (SSH) it prints a device code — open
|
||||||
|
microsoft.com/devicelogin on any machine and enter it. If this step fails,
|
||||||
|
run `teams login` then `teams status` by hand: status must say logged in, or
|
||||||
|
the cache is not persisting (see Troubleshooting).
|
||||||
|
|
||||||
|
```nc:run effect:step when:have_creds=no
|
||||||
|
"$(npm prefix -g 2>/dev/null)/bin/teams" login && "$(npm prefix -g 2>/dev/null)/bin/teams" status --json 2>/dev/null | grep -q '"loggedIn": true' && printf '=== NANOCLAW SETUP: TEAMS-LOGIN ===\nSTATUS: success\n=== END ===\n'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create the bot
|
||||||
|
|
||||||
|
One command registers the Entra app, generates a client secret (Graph can take
|
||||||
|
~30s to see the new app — the CLI retries), registers a Teams-managed bot, and
|
||||||
|
uploads the app package to the Teams Developer Portal. It needs the sign-in
|
||||||
|
from the previous step (`AUTH_REQUIRED` means run that first). The tenant
|
||||||
|
answer picks the variant — they differ only in `--sign-in-audience`, and the
|
||||||
|
single-tenant one also captures the tenant ID (which the multi-tenant `.env`
|
||||||
|
pairing must omit). A `when:tenant=…` guard implies a fresh create: the tenant
|
||||||
|
prompt is only asked when the credentials probe answered no, so with existing
|
||||||
|
credentials both variants are skipped.
|
||||||
|
|
||||||
|
```nc:run effect:external when:tenant=single capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,app_tenant_id=.credentials.TENANT_ID,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$
|
||||||
|
"$(npm prefix -g 2>/dev/null)/bin/teams" app create --name "{{app_name}}" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience myOrg --json
|
||||||
|
```
|
||||||
|
|
||||||
|
```nc:run effect:external when:tenant=multi capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$
|
||||||
|
"$(npm prefix -g 2>/dev/null)/bin/teams" app create --name "{{app_name}}" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience multipleOrgs --json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Store the credentials
|
### Store the credentials
|
||||||
|
|
||||||
The adapter reads these from `.env` (set-if-absent, so a value you've already
|
The adapter reads these from `.env` (set-if-absent — a value you've already
|
||||||
filled in is never overwritten).
|
filled in is never overwritten). The pairing matters, and the tenant branch
|
||||||
`TEAMS_APP_TENANT_ID` is written only for a Single Tenant app; Multi Tenant
|
encodes it: `SingleTenant` requires `TEAMS_APP_TENANT_ID`, and a multi-tenant
|
||||||
doesn't need it.
|
app must instead set `TEAMS_APP_TYPE=MultiTenant` with **no** tenant ID — a
|
||||||
|
mismatch makes the adapter authenticate against the wrong authority and every
|
||||||
|
message fails with a 401 from Bot Framework.
|
||||||
|
|
||||||
```nc:env-set
|
```nc:env-set when:tenant=single
|
||||||
TEAMS_APP_ID={{app_id}}
|
TEAMS_APP_ID={{app_id}}
|
||||||
TEAMS_APP_PASSWORD={{app_password}}
|
TEAMS_APP_PASSWORD={{app_password}}
|
||||||
TEAMS_APP_TYPE={{app_type}}
|
|
||||||
```
|
|
||||||
```nc:env-set when:app_type=SingleTenant
|
|
||||||
TEAMS_APP_TENANT_ID={{app_tenant_id}}
|
TEAMS_APP_TENANT_ID={{app_tenant_id}}
|
||||||
|
TEAMS_APP_TYPE=SingleTenant
|
||||||
```
|
```
|
||||||
### Create the Azure Bot resource
|
|
||||||
|
|
||||||
|
```nc:env-set when:tenant=multi
|
||||||
|
TEAMS_APP_ID={{app_id}}
|
||||||
|
TEAMS_APP_PASSWORD={{app_password}}
|
||||||
|
TEAMS_APP_TYPE=MultiTenant
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install the app in Teams
|
||||||
|
|
||||||
|
The app package is already uploaded — no manifest zip, no manual sideload.
|
||||||
Tell the user:
|
Tell the user:
|
||||||
|
|
||||||
```nc:operator
|
```nc:operator when:have_creds=no
|
||||||
Create the Azure Bot resource and point it at this machine:
|
Install the bot into Teams:
|
||||||
1. In https://portal.azure.com, search "Azure Bot" → Create.
|
1. Open {{install_link}} — Teams opens with the app's install dialog. Click Add.
|
||||||
2. Bot handle: a unique name, e.g. nanoclaw-bot.
|
2. If you need the link again later, run: teams app get {{teams_app_id}} --install-link
|
||||||
3. Type of App: {{app_type}} — Creation type: Use existing app registration.
|
3. If Teams refuses with a custom-app-upload error, a tenant admin must enable sideloading: Teams Admin Center > Teams apps > Setup policies > Global > "Upload custom apps" = On.
|
||||||
4. App ID: {{app_id}}.
|
Once the app shows up in your Teams sidebar (or app list), continue.
|
||||||
5. After creating, open the bot → Configuration and set Messaging endpoint to {{public_url}}/api/webhooks/teams, then Apply.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Enable the Teams channel
|
|
||||||
|
|
||||||
Tell the user (finish every Azure step above before continuing — the package
|
|
||||||
built next bakes in the app registration, so the Azure app and bot must already
|
|
||||||
exist):
|
|
||||||
|
|
||||||
```nc:operator
|
|
||||||
Enable the Microsoft Teams channel on the bot:
|
|
||||||
1. Open your Azure Bot resource → Channels.
|
|
||||||
2. Click Microsoft Teams → Accept terms → Apply.
|
|
||||||
When the Azure app, bot resource, and Teams channel are all in place, continue.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build the Teams app package
|
|
||||||
|
|
||||||
The manifest bakes in the Application (client) ID, so the Azure app registration
|
|
||||||
above must be done first — building with a blank `app_id` produces a package that
|
|
||||||
no bot can claim. Confirm it's set before generating the zip:
|
|
||||||
|
|
||||||
```nc:run effect:check
|
|
||||||
[ -n "{{app_id}}" ]
|
|
||||||
```
|
|
||||||
|
|
||||||
Generate the zip you'll sideload into Teams (manifest + icons, written to
|
|
||||||
`data/teams/teams-app-package.zip`). Re-running regenerates a fresh zip, so this
|
|
||||||
is safe to repeat.
|
|
||||||
|
|
||||||
```nc:run effect:external
|
|
||||||
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id "{{app_id}}" --url "{{public_url}}"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sideload the app into Teams
|
|
||||||
|
|
||||||
Tell the user (do this before the restart below — the service should come up with
|
|
||||||
the app already sideloaded):
|
|
||||||
|
|
||||||
```nc:operator
|
|
||||||
Sideload the generated app package into Teams:
|
|
||||||
1. Open Microsoft Teams → Apps → Manage your apps → Upload an app.
|
|
||||||
2. Click "Upload a custom app" (or "Upload for me or my teams").
|
|
||||||
3. Select data/teams/teams-app-package.zip and click Add.
|
|
||||||
4. If "Upload a custom app" is missing, your tenant admin has disabled sideloading — enable it in Teams Admin Center → Teams apps → Setup policies → Global → Upload custom apps = On.
|
|
||||||
Once the app is added in Teams, continue.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Restart
|
## Restart
|
||||||
@@ -224,7 +244,7 @@ bot has seen its first message. Tell the user:
|
|||||||
|
|
||||||
```nc:operator
|
```nc:operator
|
||||||
The Teams adapter is live and the service is running. One thing is left: your Teams bot's platform ID (which NanoClaw needs to wire it to an agent group) only becomes known after you DM the bot for the first time. To finish:
|
The Teams adapter is live and the service is running. One thing is left: your Teams bot's platform ID (which NanoClaw needs to wire it to an agent group) only becomes known after you DM the bot for the first time. To finish:
|
||||||
1. Find your bot in Teams (search by name, or via the app you just sideloaded) and send it a message ("hi" is fine).
|
1. Find your bot in Teams (search by name, or via the app you just installed) and send it a message ("hi" is fine).
|
||||||
2. Tail logs/nanoclaw.log for the inbound — the router auto-creates a row in messaging_groups in data/v2.db.
|
2. Tail logs/nanoclaw.log for the inbound — the router auto-creates a row in messaging_groups in data/v2.db.
|
||||||
3. Run scripts/init-first-agent.ts with --channel teams, the discovered platform_id, and your AAD user id — OR run /manage-channels to wire it interactively.
|
3. Run scripts/init-first-agent.ts with --channel teams, the discovered platform_id, and your AAD user id — OR run /manage-channels to wire it interactively.
|
||||||
```
|
```
|
||||||
@@ -247,57 +267,54 @@ once you've DM'd the bot, wire this channel with `/init-first-agent` (or
|
|||||||
|
|
||||||
## Alternatives
|
## Alternatives
|
||||||
|
|
||||||
### Auto: Teams CLI
|
### Manual Azure portal path
|
||||||
|
|
||||||
The Credentials flow above walks the manual Azure Portal path. If you'd rather
|
For tenants where the Teams Developer Portal is blocked. Unlike the CLI path,
|
||||||
script it, the Microsoft Teams CLI creates the Entra app, client secret, and bot
|
the Azure Bot resource in step 3 requires an active **Azure subscription**.
|
||||||
registration in one command. Requires Node.js 18+, a Microsoft 365 account with
|
This is the classic walk; every value it produces maps onto the same `.env`
|
||||||
sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel,
|
keys. The choices are the human's here just as on the CLI path — ask before
|
||||||
or similar).
|
creating anything: the app registration name, single vs multi tenant, a
|
||||||
|
client secret description, and (this path only) a separate Azure Bot handle.
|
||||||
|
|
||||||
1. Install the CLI:
|
1. **App registration**: in https://portal.azure.com, search "App registrations"
|
||||||
|
→ "New registration". Name it (e.g. "NanoClaw"); Supported account types:
|
||||||
|
Single tenant (most common for self-host) or Multi tenant. From the Overview
|
||||||
|
page copy the **Application (client) ID** and — single tenant only — the
|
||||||
|
**Directory (tenant) ID**.
|
||||||
|
2. **Client secret**: in the app registration, "Certificates & secrets" → "New
|
||||||
|
client secret" (expires 180 days or longer). **Copy the Value now** — Azure
|
||||||
|
shows it once (the Value column, not the Secret ID).
|
||||||
|
3. **Azure Bot resource**: search "Azure Bot" → Create. Bot handle: any unique
|
||||||
|
name; Type of App: must match step 1; Creation type: "Use existing app
|
||||||
|
registration" with the App ID from step 1. After creating, open the bot →
|
||||||
|
Configuration and set **Messaging endpoint** to
|
||||||
|
`https://your-domain/webhook/teams`, then Apply.
|
||||||
|
4. **Enable the Teams channel**: Azure Bot resource → Channels → Microsoft
|
||||||
|
Teams → Accept terms → Apply.
|
||||||
|
5. **Store the credentials** in `.env` (the same 401 pairing rule applies —
|
||||||
|
`SingleTenant` needs the tenant ID, `MultiTenant` must omit it):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g @microsoft/teams.cli@preview
|
TEAMS_APP_ID=<Application (client) ID>
|
||||||
|
TEAMS_APP_PASSWORD=<client secret Value>
|
||||||
|
TEAMS_APP_TYPE=SingleTenant
|
||||||
|
TEAMS_APP_TENANT_ID=<Directory (tenant) ID>
|
||||||
```
|
```
|
||||||
|
6. **Build the app package** (manifest + icons, written in-process to
|
||||||
2. Sign in and verify:
|
`data/teams/teams-app-package.zip` — no `zip` binary needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
teams login
|
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain
|
||||||
teams status
|
|
||||||
```
|
```
|
||||||
|
7. **Sideload**: Microsoft Teams → Apps → Manage your apps → Upload an app →
|
||||||
|
"Upload a custom app" → select the zip → Add.
|
||||||
|
8. Continue from [Restart](#restart).
|
||||||
|
|
||||||
3. Create the Entra app, client secret, and bot registration:
|
Or create the bot resource with the Azure CLI instead of the portal:
|
||||||
|
|
||||||
```bash
|
|
||||||
teams app create \
|
|
||||||
--name "NanoClaw" \
|
|
||||||
--endpoint "https://your-domain/api/webhooks/teams"
|
|
||||||
```
|
|
||||||
|
|
||||||
The CLI prints the credentials as `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Map them to NanoClaw's env keys:
|
|
||||||
|
|
||||||
- `CLIENT_ID` → `TEAMS_APP_ID`
|
|
||||||
- `CLIENT_SECRET` → `TEAMS_APP_PASSWORD`
|
|
||||||
- `TENANT_ID` → `TEAMS_APP_TENANT_ID`
|
|
||||||
|
|
||||||
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
|
|
||||||
|
|
||||||
### Azure CLI for the bot resource
|
|
||||||
|
|
||||||
The Azure Bot resource and Teams channel can also be created with `az` instead of
|
|
||||||
clicking through the portal:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
az group create --name nanoclaw-rg --location eastus
|
az group create --name nanoclaw-rg --location eastus
|
||||||
az bot create \
|
az bot create --resource-group nanoclaw-rg --name nanoclaw-bot --app-type SingleTenant --appid YOUR_APP_ID --tenant-id YOUR_TENANT_ID --endpoint "https://your-domain/webhook/teams"
|
||||||
--resource-group nanoclaw-rg \
|
|
||||||
--name nanoclaw-bot \
|
|
||||||
--app-type SingleTenant \
|
|
||||||
--appid YOUR_APP_ID \
|
|
||||||
--tenant-id YOUR_TENANT_ID \
|
|
||||||
--endpoint "https://your-domain/api/webhooks/teams"
|
|
||||||
az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
|
az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -305,43 +322,119 @@ az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
|
|||||||
|
|
||||||
### Receive all channel messages (without @-mention)
|
### Receive all channel messages (without @-mention)
|
||||||
|
|
||||||
By default the bot only receives messages when @-mentioned. To receive every
|
By default the bot only receives messages when @-mentioned. With a CLI-created
|
||||||
message in a channel, add an RSC (resource-specific consent) permission to your
|
bot, grant the resource-specific-consent (RSC) permissions directly — no
|
||||||
Teams app `manifest.json`:
|
manifest edit, no re-upload; the app version is bumped automatically:
|
||||||
|
|
||||||
```json
|
```bash
|
||||||
{
|
teams app rsc add <teams-app-id> ChannelMessage.Read.Group --type Application
|
||||||
"authorization": {
|
teams app rsc add <teams-app-id> ChatMessage.Read.Chat --type Application
|
||||||
"permissions": {
|
|
||||||
"resourceSpecific": [
|
|
||||||
{ "name": "ChannelMessage.Read.Group", "type": "Application" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Re-sideload the updated app package for the change to take effect.
|
Then update/reinstall the app in the team so the new permissions get consented.
|
||||||
|
(`<teams-app-id>` is the Teams App ID shown in the install step — recover it
|
||||||
|
any time with `teams app list`, or find the app at
|
||||||
|
https://dev.teams.microsoft.com/apps.)
|
||||||
|
|
||||||
|
On the manual path, regenerate the package with RSC baked in and sideload it
|
||||||
|
again (the manifest version is bumped so the upload supersedes the original):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain --rsc
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "Upload a custom app" is missing in Teams
|
### "Upload a custom app" is missing / sideloading blocked
|
||||||
|
|
||||||
|
`teams status` shows whether sideloading is enabled at both tenant
|
||||||
|
and user level; the login output prints the same check.
|
||||||
|
|
||||||
|
- **Tenant level off**: Teams Admin Center → **Teams apps** → **Setup
|
||||||
|
policies** → **Global** → **Upload custom apps** = On.
|
||||||
|
- **"Enabled for the tenant, but your user policy blocks it"**: the per-user
|
||||||
|
policy is the blocker — Teams Admin Center → **Users** → find the user →
|
||||||
|
**Policies** → **App setup policy** → assign one with **Upload custom
|
||||||
|
apps** = On. Policy changes can take a while to propagate.
|
||||||
|
|
||||||
Your tenant admin has disabled sideloading. Enable it in Teams Admin Center →
|
|
||||||
**Teams apps** → **Setup policies** → **Global** → **Upload custom apps** = On.
|
|
||||||
Free personal Teams does not support sideloading at all — use a Microsoft 365
|
Free personal Teams does not support sideloading at all — use a Microsoft 365
|
||||||
Business / EDU / developer tenant.
|
Business / EDU / developer tenant.
|
||||||
|
|
||||||
|
### `teams: command not found`
|
||||||
|
|
||||||
|
The CLI installed fine but npm's global bin directory isn't on your PATH — a
|
||||||
|
common state with custom npm prefixes. Find it with `npm prefix -g` (the
|
||||||
|
binary is at `<prefix>/bin/teams`), then either add that directory to PATH or
|
||||||
|
symlink the binary somewhere already on it. The skill's own steps are immune —
|
||||||
|
they invoke the absolute path.
|
||||||
|
|
||||||
|
### Create fails immediately with `AUTH_REQUIRED` after a successful sign-in
|
||||||
|
|
||||||
|
The sign-in didn't persist: each `teams` command is a separate process, and
|
||||||
|
when the CLI's credential store can't load it silently falls back to an
|
||||||
|
in-memory cache that dies with the login process. Symptom check:
|
||||||
|
`teams status` says logged out right after a login succeeded. Two causes:
|
||||||
|
|
||||||
|
- **CLI installed as a pnpm workspace dependency**: pnpm's supply-chain policy
|
||||||
|
skips dependency build scripts, so keytar (the CLI's native credential
|
||||||
|
store) never gets its binary and the whole store fails to load. Use the
|
||||||
|
global npm install this skill performs — and `pnpm uninstall
|
||||||
|
@microsoft/teams.cli` if a workspace copy lingers, so `teams` resolves to
|
||||||
|
the global one.
|
||||||
|
- **libsecret missing (Linux)**: keytar links against it at load time. Fix:
|
||||||
|
`sudo apt-get install -y libsecret-1-0` (Debian/Ubuntu). On a headless box
|
||||||
|
with no keyring daemon the CLI then falls back to a plaintext cache file
|
||||||
|
(with a warning) — expected.
|
||||||
|
|
||||||
|
After fixing, sign in again and confirm `teams status` shows logged in, then
|
||||||
|
re-run this skill.
|
||||||
|
|
||||||
### Bot never receives messages
|
### Bot never receives messages
|
||||||
|
|
||||||
1. The tunnel is up and the messaging endpoint matches it: Azure Bot → **Configuration** → **Messaging endpoint** must be `https://<your-domain>/api/webhooks/teams`, and your tunnel (e.g. `ngrok http 3000`) must be forwarding to this machine's port 3000.
|
1. The app is actually installed in Teams — if setup was interrupted before
|
||||||
2. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
|
the install step, nothing got installed. Recover the install link:
|
||||||
3. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TYPE`).
|
`teams app list` shows the Teams App ID, then
|
||||||
|
`teams app get <teams-app-id> --install-link`.
|
||||||
|
2. The tunnel is up and the messaging endpoint matches it — the endpoint must
|
||||||
|
be `https://<your-domain>/webhook/teams`, and your tunnel (e.g.
|
||||||
|
`ngrok http 3000`) must be forwarding to this machine's port 3000. Check
|
||||||
|
with `teams app doctor <teams-app-id>` (CLI-created bots) or Azure
|
||||||
|
Bot → **Configuration** (manual path).
|
||||||
|
3. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
|
||||||
|
4. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`,
|
||||||
|
`TEAMS_APP_TYPE`).
|
||||||
|
|
||||||
|
### Tunnel URL changed
|
||||||
|
|
||||||
|
Point the bot at the new endpoint:
|
||||||
|
`teams app update <teams-app-id> --endpoint "https://new-domain/webhook/teams"`
|
||||||
|
(manual path: Azure Bot → Configuration → Messaging endpoint).
|
||||||
|
|
||||||
### `Unauthorized` / 401 from Azure Bot Service
|
### `Unauthorized` / 401 from Azure Bot Service
|
||||||
|
|
||||||
The client secret is wrong or expired, or — for a Single Tenant app — `TEAMS_APP_TENANT_ID` is missing. Regenerate the secret in **Certificates & secrets** (copy the Value, not the Secret ID), update `.env`, and restart.
|
Either the credential pairing is wrong, or the secret is dead:
|
||||||
|
|
||||||
|
- **Pairing**: `TEAMS_APP_TYPE=SingleTenant` requires `TEAMS_APP_TENANT_ID`;
|
||||||
|
`MultiTenant` must have **no** tenant ID set. A mismatch authenticates
|
||||||
|
against the wrong authority and every send/receive 401s.
|
||||||
|
- **Secret**: expired or mispasted. Rotate with
|
||||||
|
`teams app auth secret create <teams-app-id>` (or Azure portal →
|
||||||
|
Certificates & secrets), update `TEAMS_APP_PASSWORD` in `.env`, and restart.
|
||||||
|
|
||||||
|
### Rotate or recreate credentials
|
||||||
|
|
||||||
|
The credentials flow skips creation while `.env` has `TEAMS_APP_ID` **or**
|
||||||
|
`TEAMS_APP_PASSWORD` — deleting just one line does not make the skill
|
||||||
|
regenerate it (that would pair a new app with stale keys). To rotate only the
|
||||||
|
secret, use the 401 section above. To start over completely: delete **all**
|
||||||
|
`TEAMS_*` lines from `.env`, optionally delete the old app at
|
||||||
|
https://dev.teams.microsoft.com/apps (CLI path) or in Azure Portal → App
|
||||||
|
registrations (manual path), then re-run this skill. Re-running
|
||||||
|
`teams app create` with old credentials still in `.env` would otherwise create
|
||||||
|
a second, orphaned app.
|
||||||
|
|
||||||
### Replies land in the wrong place
|
### Replies land in the wrong place
|
||||||
|
|
||||||
A Teams bot's platform ID is derived from the first inbound activity, so wire the messaging group that the router auto-creates after you DM the bot — don't guess the platform ID. See **Finish wiring** above.
|
A Teams bot's platform ID is derived from the first inbound activity, so wire
|
||||||
|
the messaging group that the router auto-creates after you DM the bot — don't
|
||||||
|
guess the platform ID. See **Finish wiring** above.
|
||||||
|
|||||||
@@ -28,24 +28,18 @@ function operatorBody(md: string, n: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('gatePolicy — §5.1 parity table (real skills)', () => {
|
describe('gatePolicy — §5.1 parity table (real skills)', () => {
|
||||||
it('teams: exactly the two authored gates — and NO confirm on the operator-chain head', () => {
|
it('teams: one gate — the install-in-Teams operator pauses before the restart', () => {
|
||||||
// Operators in order: prerequisites, portal app, client secret, bot resource
|
// Operators in order (CLI-first flow): prerequisites, install-in-Teams
|
||||||
// (chain head), enable-channel (chain tail), sideload, final handoff.
|
// (when:have_creds=no), final handoff.
|
||||||
const d = decisions(loadSkill('teams'));
|
const d = decisions(loadSkill('teams'));
|
||||||
expect(d).toHaveLength(7);
|
expect(d).toHaveLength(3);
|
||||||
expect(d.map((g) => g.needsConfirm)).toEqual([
|
expect(d.map((g) => g.needsConfirm)).toEqual([
|
||||||
false, // → prompt public_url (prompt is the barrier)
|
false, // prereqs → prompt public_url (prompt is the barrier)
|
||||||
false, // → prompt app_id
|
true, // install-in-Teams → run effect:restart
|
||||||
false, // → prompt app_password
|
|
||||||
false, // bot-resource block → next compatible is another OPERATOR (rule 2):
|
|
||||||
// the chain's LAST operator carries the barrier — never double-confirm
|
|
||||||
true, // enable-channel → run effect:check (the manifest hazard gate)
|
|
||||||
true, // sideload → run effect:restart
|
|
||||||
false, // final handoff → end of document
|
false, // final handoff → end of document
|
||||||
]);
|
]);
|
||||||
// Both confirms are completed-work flavor (check/restart, not effect:step).
|
// Completed-work flavor (restart, not effect:step).
|
||||||
expect(d[4].flavor).toBe('completed');
|
expect(d[1].flavor).toBe('completed');
|
||||||
expect(d[5].flavor).toBe('completed');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('telegram: the pairing operator gains a readiness pause before the effect:step', () => {
|
it('telegram: the pairing operator gains a readiness pause before the effect:step', () => {
|
||||||
@@ -140,10 +134,15 @@ describe('gatePolicy — rules on synthetic fixtures', () => {
|
|||||||
// §5.2 URL-offer inventory — every operator body in the tree, plus the
|
// §5.2 URL-offer inventory — every operator body in the tree, plus the
|
||||||
// normative negative fixture (slack's placeholder URL).
|
// normative negative fixture (slack's placeholder URL).
|
||||||
describe('extractOfferUrl — §5.2 inventory', () => {
|
describe('extractOfferUrl — §5.2 inventory', () => {
|
||||||
it('teams: both portal blocks offer a clean https://portal.azure.com (trailing comma stripped)', () => {
|
it('teams: raw bodies stay offer-free — the install link is a {{var}} until substitution', () => {
|
||||||
const md = loadSkill('teams');
|
const md = loadSkill('teams');
|
||||||
expect(extractOfferUrl(operatorBody(md, 1))).toBe('https://portal.azure.com'); // app registration
|
// The install block's URL is {{install_link}} in the AUTHORED body — no
|
||||||
expect(extractOfferUrl(operatorBody(md, 3))).toBe('https://portal.azure.com'); // bot resource (new offer)
|
// candidate matches here; the offer materializes at runtime from the
|
||||||
|
// rendered body (proven in run-channel-skill.test.ts's fresh-create case).
|
||||||
|
expect(operatorBody(md, 1)).toContain('{{install_link}}');
|
||||||
|
expect(extractOfferUrl(operatorBody(md, 0))).toBeUndefined(); // prereqs
|
||||||
|
expect(extractOfferUrl(operatorBody(md, 1))).toBeUndefined(); // install-in-Teams
|
||||||
|
expect(extractOfferUrl(operatorBody(md, 2))).toBeUndefined(); // final handoff
|
||||||
});
|
});
|
||||||
|
|
||||||
it('slack — the <your-public-host> placeholder is EXCLUDED (normative negative fixture)', () => {
|
it('slack — the <your-public-host> placeholder is EXCLUDED (normative negative fixture)', () => {
|
||||||
|
|||||||
@@ -642,6 +642,14 @@ async function main(): Promise<void> {
|
|||||||
p.outro(k.yellow('Almost there. A few things still need your attention.'));
|
p.outro(k.yellow('Almost there. A few things still need your attention.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Deferred wire (Teams): verify passes with zero groups because the
|
||||||
|
// platform id only exists after the first DM — remind, don't alarm.
|
||||||
|
if (res.terminal?.fields.WIRING === 'pending_first_dm') {
|
||||||
|
note(
|
||||||
|
'• Finish wiring: DM your bot once, then run /init-first-agent (or /manage-channels).',
|
||||||
|
"What's left",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows: [string, string][] = [
|
const rows: [string, string][] = [
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
import { execSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import { runChannelSkill } from './run-channel-skill.js';
|
import { runChannelSkill } from './run-channel-skill.js';
|
||||||
|
import { runSkill } from '../lib/skill-driver.js';
|
||||||
|
import { fullyApplied } from '../../scripts/skill-apply.js';
|
||||||
|
import { parseDirectives } from '../../scripts/skill-directives.js';
|
||||||
import { BACK_TO_CHANNEL_SELECTION, backGate } from '../lib/back-nav.js';
|
import { BACK_TO_CHANNEL_SELECTION, backGate } from '../lib/back-nav.js';
|
||||||
|
|
||||||
// Drive the first-prompt back gate (back-nav's brightSelect) from a queue
|
// Drive the first-prompt back gate (back-nav's brightSelect) from a queue
|
||||||
@@ -73,25 +77,27 @@ describe('runChannelSkill adapter (Option A)', () => {
|
|||||||
|
|
||||||
// Teams' platform_id only exists after the first inbound, so its SKILL.md
|
// Teams' platform_id only exists after the first inbound, so its SKILL.md
|
||||||
// installs + hands off and runChannelSkill is called with deferWire — it must
|
// installs + hands off and runChannelSkill is called with deferWire — it must
|
||||||
// run the skill but never reach the shared wire. This is the driver-policy
|
// run the skill but never reach the shared wire. The credentials flow is
|
||||||
// parity fixture: it runs the DEFAULT onEvent handler (never an injected
|
// CLI-first and guarded by the have_creds probe: this fixture answers the
|
||||||
// onEvent, which would replace the policy — §5.0) and injects the
|
// probe with "yes" (credentials already in .env), so every creation step —
|
||||||
// confirm/openUrl seams to prove both natural barriers fire and the portal
|
// the teams-login step, teams app create, the env writes, the install-link
|
||||||
// URL offer survives from the operator prose alone.
|
// operator — is when:-skipped and the run drops straight through to restart.
|
||||||
it('deferWire (Teams): default policy fires the gate barriers + portal URL offer, never reaches the shared wire', async () => {
|
it('deferWire (Teams): existing credentials skip the whole CLI create flow, never reach the shared wire', async () => {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-'));
|
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-'));
|
||||||
mkdirSync(join(root, 'src/channels'), { recursive: true });
|
mkdirSync(join(root, 'src/channels'), { recursive: true });
|
||||||
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
|
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
|
||||||
writeFileSync(join(root, '.env'), '');
|
writeFileSync(join(root, '.env'), 'TEAMS_APP_ID=existing\nTEAMS_APP_PASSWORD=existing-password\n');
|
||||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||||
|
|
||||||
const log: string[] = [];
|
const log: string[] = [];
|
||||||
const opened: string[] = [];
|
|
||||||
const wired: unknown[] = [];
|
const wired: unknown[] = [];
|
||||||
|
|
||||||
await runChannelSkill('teams', 'Acme Corp', {
|
await runChannelSkill('teams', 'Acme Corp', {
|
||||||
projectRoot: root,
|
projectRoot: root,
|
||||||
exec: (c) => void log.push(`exec:${c}`),
|
exec: (c) => {
|
||||||
|
log.push(`exec:${c}`);
|
||||||
|
if (c.includes('TEAMS_APP_ID=.')) return 'yes'; // the have_creds probe
|
||||||
|
},
|
||||||
resolveRemote: () => 'origin',
|
resolveRemote: () => 'origin',
|
||||||
reuse: false,
|
reuse: false,
|
||||||
deferWire: true,
|
deferWire: true,
|
||||||
@@ -102,13 +108,13 @@ describe('runChannelSkill adapter (Option A)', () => {
|
|||||||
log.push(`confirm:${m}`);
|
log.push(`confirm:${m}`);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
openUrl: async (u) => void opened.push(u),
|
openUrl: async () => undefined,
|
||||||
// a MultiTenant app, so the SingleTenant-guarded app_tenant_id prompt is skipped
|
// NO inputs: the public_url prompt is when:have_creds=no-guarded, so the
|
||||||
inputs: {
|
// drop-through path must never ask for it. If the guard regressed, the
|
||||||
public_url: 'https://acme.example',
|
// prompt would defer (resolveInput undefined) and fail() would be called.
|
||||||
app_id: '12345678-1234-1234-1234-123456789abc',
|
resolveInput: async () => undefined,
|
||||||
app_type: 'MultiTenant',
|
fail: async (step, msg) => {
|
||||||
app_password: 'a-much-longer-app-password', // 20+ chars — valid for the declared shape
|
throw new Error(`fail() called on drop-through path: ${step} — ${msg}`);
|
||||||
},
|
},
|
||||||
wire: (a) => {
|
wire: (a) => {
|
||||||
wired.push(a);
|
wired.push(a);
|
||||||
@@ -116,21 +122,177 @@ describe('runChannelSkill adapter (Option A)', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// install + manifest ran…
|
// the adapter install ran, but no bot was created and no login step fired…
|
||||||
expect(log.some((c) => c.includes('teams-manifest-build'))).toBe(true);
|
expect(log.some((c) => c.includes('pnpm add @chat-adapter/teams'))).toBe(true);
|
||||||
// …the Azure portal offer came from the operator BODY text (policy §5.2)…
|
expect(log.some((c) => c.includes('app create'))).toBe(false);
|
||||||
expect(opened.some((u) => /portal\.azure\.com/.test(u))).toBe(true);
|
expect(log.some((c) => c.includes('login'))).toBe(false);
|
||||||
// …a natural-barrier confirm (not a URL offer) fired BEFORE the manifest
|
// …the Teams CLI install is also skipped (nothing to create)…
|
||||||
// build (the manifest-before-the-app hazard fix, now derived from document
|
expect(log.some((c) => c.includes('npm install -g @microsoft/teams.cli'))).toBe(false);
|
||||||
// structure instead of an authored gate attr)…
|
// …the service still restarts (adapter + existing credentials load)…
|
||||||
const firstGate = log.findIndex((c) => c.startsWith('confirm:') && !c.startsWith('confirm:Open '));
|
expect(log.some((c) => c.includes('restart.sh'))).toBe(true);
|
||||||
const manifestAt = log.findIndex((c) => c.includes('teams-manifest-build'));
|
// …the pre-existing .env values were left alone…
|
||||||
expect(firstGate).toBeGreaterThanOrEqual(0);
|
expect(readFileSync(join(root, '.env'), 'utf8')).toContain('TEAMS_APP_ID=existing');
|
||||||
expect(firstGate).toBeLessThan(manifestAt);
|
// …and the shared wire was never reached (no owner_handle/platform_id needed)
|
||||||
// …but the shared wire was never reached (no owner_handle/platform_id needed)
|
|
||||||
expect(wired).toHaveLength(0);
|
expect(wired).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The probe's shell one-liner is dispatched by substring in the fixtures
|
||||||
|
// above — its actual semantics (EITHER key present ⇒ yes; a partial pair
|
||||||
|
// must NOT trigger a second `teams app create`) are asserted here by running
|
||||||
|
// the REAL command from the REAL SKILL.md against real .env states. Parsed
|
||||||
|
// from the document so the test can't drift from what ships.
|
||||||
|
it('Teams have_creds probe: either credential key present answers yes', () => {
|
||||||
|
const md = readFileSync(join(process.cwd(), '.claude/skills/add-teams/SKILL.md'), 'utf8');
|
||||||
|
const probe = parseDirectives(md).find(
|
||||||
|
(d) => d.kind === 'run' && d.attrs.capture === 'have_creds',
|
||||||
|
);
|
||||||
|
expect(probe).toBeDefined();
|
||||||
|
const cmd = probe!.body.join('\n');
|
||||||
|
|
||||||
|
const cases: Array<[string | null, string]> = [
|
||||||
|
['TEAMS_APP_ID=a\nTEAMS_APP_PASSWORD=b\n', 'yes'],
|
||||||
|
['TEAMS_APP_ID=a\n', 'yes'], // partial pair: creating another app would corrupt it
|
||||||
|
['TEAMS_APP_PASSWORD=b\n', 'yes'],
|
||||||
|
['OTHER=x\n', 'no'],
|
||||||
|
['TEAMS_APP_ID=\n', 'no'], // empty value counts as unset (mirrors env-set)
|
||||||
|
[null, 'no'], // no .env at all
|
||||||
|
];
|
||||||
|
for (const [env, expected] of cases) {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'rcs-probe-'));
|
||||||
|
if (env !== null) writeFileSync(join(dir, '.env'), env);
|
||||||
|
const out = execSync(cmd, { cwd: dir, shell: '/bin/bash', encoding: 'utf8' }).trim();
|
||||||
|
expect(out, `env=${JSON.stringify(env)}`).toBe(expected);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The fresh-create leg of the same document, driven at the runSkill level so
|
||||||
|
// the effect:step gets an injected streaming exec (runChannelSkill exposes no
|
||||||
|
// execStream seam — CI must never spawn a real `teams login`). Proves the
|
||||||
|
// CLI-first chain end-to-end: login step → create's JSON multi-capture → the
|
||||||
|
// env writes → the substituted install link surviving into the URL offer.
|
||||||
|
it('Teams fresh create: login step + JSON capture drive the env writes and the install-link offer', async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-create-'));
|
||||||
|
mkdirSync(join(root, 'src/channels'), { recursive: true });
|
||||||
|
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
|
||||||
|
writeFileSync(join(root, '.env'), '');
|
||||||
|
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||||
|
|
||||||
|
const INSTALL_LINK =
|
||||||
|
'https://teams.microsoft.com/l/app/tapp-123?installAppPackage=true&appTenantId=tenant-1';
|
||||||
|
const log: string[] = [];
|
||||||
|
const opened: string[] = [];
|
||||||
|
const steps: string[] = [];
|
||||||
|
|
||||||
|
const res = await runSkill('.claude/skills/add-teams', {
|
||||||
|
projectRoot: root,
|
||||||
|
exec: (c) => {
|
||||||
|
log.push(`exec:${c}`);
|
||||||
|
if (c.includes('TEAMS_APP_ID=.')) return 'no'; // the have_creds probe: nothing configured yet
|
||||||
|
if (c.includes(' app create ')) {
|
||||||
|
// the --json shape teams.cli@3.0.2 prints (credentials keys are UPPERCASE)
|
||||||
|
return JSON.stringify({
|
||||||
|
appName: 'NanoClaw',
|
||||||
|
teamsAppId: 'tapp-123',
|
||||||
|
botId: '12345678-1234-1234-1234-123456789abc',
|
||||||
|
installLink: INSTALL_LINK,
|
||||||
|
portalLink: 'https://dev.teams.microsoft.com/apps/tapp-123',
|
||||||
|
credentials: {
|
||||||
|
CLIENT_ID: '12345678-1234-1234-1234-123456789abc',
|
||||||
|
CLIENT_SECRET: 'a-much-longer-app-secret',
|
||||||
|
TENANT_ID: '87654321-4321-4321-4321-cba987654321',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
execStream: async (cmd) => {
|
||||||
|
steps.push(cmd);
|
||||||
|
return { ok: true, fields: { STATUS: 'success' } };
|
||||||
|
},
|
||||||
|
resolveRemote: () => 'origin',
|
||||||
|
inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', tenant: 'single' },
|
||||||
|
confirm: async (m) => {
|
||||||
|
log.push(`confirm:${m}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
openUrl: async (u) => void opened.push(u),
|
||||||
|
});
|
||||||
|
|
||||||
|
// the CLI installed globally (npm runs keytar's install script; pnpm's
|
||||||
|
// build-script policy would leave the credential store unbuildable)…
|
||||||
|
expect(log.some((c) => c.includes('npm install -g @microsoft/teams.cli@3.0.2'))).toBe(true);
|
||||||
|
// …the login ran as a streaming step, never a plain exec (the CLI is
|
||||||
|
// invoked by absolute path — $(npm prefix -g)/bin/teams — so match loosely)…
|
||||||
|
expect(steps.some((c) => c.includes('/bin/teams" login'))).toBe(true);
|
||||||
|
expect(log.some((c) => c.startsWith('exec:') && c.includes(' login'))).toBe(false);
|
||||||
|
// …create got the collected public URL on the real /webhook/teams route,
|
||||||
|
// and the prompted name + tenant choice landed as --name/--sign-in-audience…
|
||||||
|
expect(log.some((c) => c.includes('--endpoint "https://acme.example/webhook/teams"'))).toBe(true);
|
||||||
|
expect(log.some((c) => c.includes('--name "NanoClaw"') && c.includes('--sign-in-audience myOrg'))).toBe(true);
|
||||||
|
// …the captured credentials landed in .env with the safe SingleTenant pairing…
|
||||||
|
const env = readFileSync(join(root, '.env'), 'utf8');
|
||||||
|
expect(env).toContain('TEAMS_APP_ID=12345678-1234-1234-1234-123456789abc');
|
||||||
|
expect(env).toContain('TEAMS_APP_PASSWORD=a-much-longer-app-secret');
|
||||||
|
expect(env).toContain('TEAMS_APP_TENANT_ID=87654321-4321-4321-4321-cba987654321');
|
||||||
|
expect(env).toContain('TEAMS_APP_TYPE=SingleTenant');
|
||||||
|
// …the install-link operator offered the SUBSTITUTED link (policy §5.2 runs on
|
||||||
|
// the rendered body — an unsubstituted {{var}} would have been excluded)…
|
||||||
|
expect(opened).toContain(INSTALL_LINK);
|
||||||
|
// …a natural-barrier confirm fired between the install operator and restart…
|
||||||
|
const gateAt = log.findIndex((c) => c.startsWith('confirm:') && !c.startsWith('confirm:Open '));
|
||||||
|
const restartAt = log.findIndex((c) => c.includes('restart.sh'));
|
||||||
|
expect(gateAt).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(gateAt).toBeLessThan(restartAt);
|
||||||
|
// …and the whole document applied with nothing deferred or bounced.
|
||||||
|
expect(res.deferred).toEqual([]);
|
||||||
|
expect(res.agentTasks).toEqual([]);
|
||||||
|
expect(fullyApplied(res)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The multi-tenant leg of the tenant branch: tenant=multi must pick the
|
||||||
|
// multipleOrgs create variant and the MultiTenant env pairing, which omits
|
||||||
|
// TEAMS_APP_TENANT_ID entirely — writing it would flip the adapter to the
|
||||||
|
// wrong authority (the 401 pairing rule the document itself states).
|
||||||
|
it('Teams multi-tenant: tenant=multi drives the multipleOrgs create and the tenant-ID-free env pairing', async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-multi-'));
|
||||||
|
mkdirSync(join(root, 'src/channels'), { recursive: true });
|
||||||
|
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
|
||||||
|
writeFileSync(join(root, '.env'), '');
|
||||||
|
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||||
|
|
||||||
|
const log: string[] = [];
|
||||||
|
const res = await runSkill('.claude/skills/add-teams', {
|
||||||
|
projectRoot: root,
|
||||||
|
exec: (c) => {
|
||||||
|
log.push(c);
|
||||||
|
if (c.includes('TEAMS_APP_ID=.')) return 'no'; // the have_creds probe
|
||||||
|
if (c.includes(' app create ')) {
|
||||||
|
// TENANT_ID is still present in the CLI's JSON (the creating tenant) —
|
||||||
|
// the multi branch simply must not capture or store it.
|
||||||
|
return JSON.stringify({
|
||||||
|
teamsAppId: 'tapp-456',
|
||||||
|
installLink: 'https://teams.microsoft.com/l/app/tapp-456?installAppPackage=true',
|
||||||
|
credentials: { CLIENT_ID: 'client-456', CLIENT_SECRET: 'secret-456', TENANT_ID: 'tenant-456' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
execStream: async () => ({ ok: true, fields: { STATUS: 'success' } }),
|
||||||
|
resolveRemote: () => 'origin',
|
||||||
|
// app_name with a space also exercises the prompt's validate charset.
|
||||||
|
inputs: { public_url: 'https://acme.example', app_name: 'Acme Bot', tenant: 'multi' },
|
||||||
|
confirm: async () => true,
|
||||||
|
openUrl: async () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(log.some((c) => c.includes('--name "Acme Bot"') && c.includes('--sign-in-audience multipleOrgs'))).toBe(true);
|
||||||
|
expect(log.some((c) => c.includes('--sign-in-audience myOrg'))).toBe(false); // the single variant was skipped
|
||||||
|
const env = readFileSync(join(root, '.env'), 'utf8');
|
||||||
|
expect(env).toContain('TEAMS_APP_ID=client-456');
|
||||||
|
expect(env).toContain('TEAMS_APP_TYPE=MultiTenant');
|
||||||
|
expect(env).not.toContain('TEAMS_APP_TENANT_ID');
|
||||||
|
expect(fullyApplied(res)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
// The engine reads `.claude/skills/add-<channel>/SKILL.md` relative to cwd (the
|
// The engine reads `.claude/skills/add-<channel>/SKILL.md` relative to cwd (the
|
||||||
// repo root in tests — same as the real add-slack the test above drives), so a
|
// repo root in tests — same as the real add-slack the test above drives), so a
|
||||||
// bounce-fixture skill is created there and torn down afterward.
|
// bounce-fixture skill is created there and torn down afterward.
|
||||||
|
|||||||
@@ -5,14 +5,17 @@
|
|||||||
* external image deps) already lives in `setup/lib/teams-manifest.ts`; this just
|
* external image deps) already lives in `setup/lib/teams-manifest.ts`; this just
|
||||||
* maps a couple of CLI flags onto it and prints the resulting zip path.
|
* maps a couple of CLI flags onto it and prints the resulting zip path.
|
||||||
*
|
*
|
||||||
* Mirrors the bespoke `stepGenerateManifest` in the old setup/channels/teams.ts:
|
* The short name comes from NANOCLAW_AGENT_NAME (falling back to "NanoClaw"), the
|
||||||
* the short name comes from NANOCLAW_AGENT_NAME (falling back to "NanoClaw"), the
|
|
||||||
* description is "<name> personal assistant powered by NanoClaw.", the website
|
* description is "<name> personal assistant powered by NanoClaw.", the website
|
||||||
* URL is the operator's public base URL, and the output lands in data/teams/.
|
* URL is the operator's public base URL, and the output lands in data/teams/.
|
||||||
*
|
*
|
||||||
|
* `--rsc` adds the resource-specific-consent permissions (receive all channel /
|
||||||
|
* group-chat messages without @-mention) and bumps the manifest version so the
|
||||||
|
* re-upload supersedes the original package.
|
||||||
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* pnpm exec tsx setup/channels/teams-manifest-build.ts \
|
* pnpm exec tsx setup/channels/teams-manifest-build.ts \
|
||||||
* --app-id <azure-app-id> --url https://your-domain [--out data/teams]
|
* --app-id <azure-app-id> --url https://your-domain [--out data/teams] [--rsc]
|
||||||
*/
|
*/
|
||||||
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
|
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
|
||||||
|
|
||||||
@@ -24,11 +27,12 @@ function flag(name: string): string | undefined {
|
|||||||
const appId = flag('app-id');
|
const appId = flag('app-id');
|
||||||
const url = flag('url');
|
const url = flag('url');
|
||||||
const outDir = flag('out') ?? 'data/teams';
|
const outDir = flag('out') ?? 'data/teams';
|
||||||
|
const rsc = process.argv.includes('--rsc');
|
||||||
const shortName = process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
|
const shortName = process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
|
||||||
|
|
||||||
if (!appId || !url) {
|
if (!appId || !url) {
|
||||||
console.error(
|
console.error(
|
||||||
'usage: teams-manifest-build.ts --app-id <azure-app-id> --url <https-url> [--out <dir>]',
|
'usage: teams-manifest-build.ts --app-id <azure-app-id> --url <https-url> [--out <dir>] [--rsc]',
|
||||||
);
|
);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
@@ -39,6 +43,7 @@ const result = buildTeamsAppPackage({
|
|||||||
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
|
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
|
||||||
websiteUrl: url,
|
websiteUrl: url,
|
||||||
outDir,
|
outDir,
|
||||||
|
rsc,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Teams app package: ${result.zipPath}`);
|
console.log(`Teams app package: ${result.zipPath}`);
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export const STEP_FILES: Record<string, string[]> = {
|
|||||||
'slack-validate': ['setup/channels/slack.ts'],
|
'slack-validate': ['setup/channels/slack.ts'],
|
||||||
'imessage-install': ['.claude/skills/add-imessage/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/imessage.ts'],
|
'imessage-install': ['.claude/skills/add-imessage/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/imessage.ts'],
|
||||||
'imessage': ['setup/channels/imessage.ts'],
|
'imessage': ['setup/channels/imessage.ts'],
|
||||||
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/teams.ts'],
|
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/run-channel-skill.ts'],
|
||||||
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
|
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams-manifest-build.ts'],
|
||||||
'init-first-agent': [
|
'init-first-agent': [
|
||||||
'scripts/init-first-agent.ts',
|
'scripts/init-first-agent.ts',
|
||||||
'setup/channels/telegram.ts',
|
'setup/channels/telegram.ts',
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* The zip is written by our own in-process writer (no `zip` binary), so this
|
||||||
|
* test parses the output with an independent minimal reader: EOCD → central
|
||||||
|
* directory → local headers → stored data. If the writer emits a structure
|
||||||
|
* Teams (or any unzip tool) would reject, these assertions go red.
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import zlib from 'zlib';
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildTeamsAppPackage } from './teams-manifest.js';
|
||||||
|
|
||||||
|
const APP_ID = '11111111-2222-3333-4444-555555555555';
|
||||||
|
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
|
||||||
|
interface ParsedEntry {
|
||||||
|
name: string;
|
||||||
|
crc: number;
|
||||||
|
data: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Independent stored-entry zip reader — deliberately shares no code with the writer. */
|
||||||
|
function readZip(zip: Buffer): ParsedEntry[] {
|
||||||
|
// No archive comment is written, so EOCD is exactly the last 22 bytes.
|
||||||
|
const eocd = zip.subarray(zip.length - 22);
|
||||||
|
expect(eocd.readUInt32LE(0)).toBe(0x06054b50);
|
||||||
|
const entryCount = eocd.readUInt16LE(10);
|
||||||
|
let pos = eocd.readUInt32LE(16); // central directory offset
|
||||||
|
|
||||||
|
const entries: ParsedEntry[] = [];
|
||||||
|
for (let i = 0; i < entryCount; i++) {
|
||||||
|
expect(zip.readUInt32LE(pos)).toBe(0x02014b50);
|
||||||
|
expect(zip.readUInt16LE(pos + 10)).toBe(0); // stored, no compression
|
||||||
|
const crc = zip.readUInt32LE(pos + 16);
|
||||||
|
const size = zip.readUInt32LE(pos + 24);
|
||||||
|
const nameLen = zip.readUInt16LE(pos + 28);
|
||||||
|
const extraLen = zip.readUInt16LE(pos + 30);
|
||||||
|
const commentLen = zip.readUInt16LE(pos + 32);
|
||||||
|
const localOffset = zip.readUInt32LE(pos + 42);
|
||||||
|
const name = zip.subarray(pos + 46, pos + 46 + nameLen).toString('ascii');
|
||||||
|
|
||||||
|
expect(zip.readUInt32LE(localOffset)).toBe(0x04034b50);
|
||||||
|
expect(zip.readUInt32LE(localOffset + 14)).toBe(crc);
|
||||||
|
const localNameLen = zip.readUInt16LE(localOffset + 26);
|
||||||
|
const localExtraLen = zip.readUInt16LE(localOffset + 28);
|
||||||
|
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
|
||||||
|
entries.push({ name, crc, data: zip.subarray(dataStart, dataStart + size) });
|
||||||
|
|
||||||
|
pos += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(outDir: string) {
|
||||||
|
return buildTeamsAppPackage({
|
||||||
|
appId: APP_ID,
|
||||||
|
shortName: 'TestBot',
|
||||||
|
longDescription: 'TestBot assistant for the manifest test.',
|
||||||
|
websiteUrl: 'https://nanoclaw.example.test',
|
||||||
|
outDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildTeamsAppPackage', () => {
|
||||||
|
// Built in beforeAll (not at describe-collection time) so a writer regression
|
||||||
|
// fails the tests that own the assertions instead of erroring the whole suite.
|
||||||
|
let outDir: string;
|
||||||
|
let rscDir: string;
|
||||||
|
let result: ReturnType<typeof build>;
|
||||||
|
let zip: Buffer;
|
||||||
|
let entries: ParsedEntry[];
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teams-manifest-'));
|
||||||
|
rscDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teams-manifest-rsc-'));
|
||||||
|
result = build(outDir);
|
||||||
|
zip = fs.readFileSync(result.zipPath);
|
||||||
|
entries = readZip(zip);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(outDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(rscDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('packages exactly manifest.json + both icons, flat', () => {
|
||||||
|
expect(entries.map((e) => e.name)).toEqual(['manifest.json', 'outline.png', 'color.png']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores each entry byte-identical to the loose file, with a correct CRC', () => {
|
||||||
|
const loose: Record<string, string> = {
|
||||||
|
'manifest.json': result.manifestPath,
|
||||||
|
'outline.png': result.outlinePath,
|
||||||
|
'color.png': result.colorPath,
|
||||||
|
};
|
||||||
|
for (const entry of entries) {
|
||||||
|
expect(entry.data.equals(fs.readFileSync(loose[entry.name]))).toBe(true);
|
||||||
|
// zlib.crc32 is public API from Node 20.15 — cross-check when present.
|
||||||
|
if (typeof zlib.crc32 === 'function') {
|
||||||
|
expect(entry.crc).toBe(zlib.crc32(entry.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a valid manifest wired to the app id', () => {
|
||||||
|
const manifest = JSON.parse(entries[0].data.toString('utf8'));
|
||||||
|
expect(manifest.id).toBe(APP_ID);
|
||||||
|
expect(manifest.bots[0].botId).toBe(APP_ID);
|
||||||
|
expect(manifest.validDomains).toEqual(['nanoclaw.example.test']);
|
||||||
|
expect(manifest.icons).toEqual({ outline: 'outline.png', color: 'color.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic and idempotent across rebuilds', () => {
|
||||||
|
const again = build(outDir);
|
||||||
|
expect(fs.readFileSync(again.zipPath).equals(zip)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds RSC permissions and bumps the version when rsc is set', () => {
|
||||||
|
const rscResult = buildTeamsAppPackage({
|
||||||
|
appId: APP_ID,
|
||||||
|
shortName: 'TestBot',
|
||||||
|
longDescription: 'TestBot assistant for the manifest test.',
|
||||||
|
websiteUrl: 'https://nanoclaw.example.test',
|
||||||
|
outDir: rscDir,
|
||||||
|
rsc: true,
|
||||||
|
});
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(rscResult.manifestPath, 'utf8'));
|
||||||
|
expect(manifest.version).toBe('1.1.0');
|
||||||
|
expect(manifest.authorization.permissions.resourceSpecific).toEqual([
|
||||||
|
{ name: 'ChannelMessage.Read.Group', type: 'Application' },
|
||||||
|
{ name: 'ChatMessage.Read.Chat', type: 'Application' },
|
||||||
|
]);
|
||||||
|
// RSC consent binds to webApplicationInfo.id — required alongside the block above.
|
||||||
|
expect(manifest.webApplicationInfo).toEqual({ id: APP_ID, resource: 'https://notapplicable' });
|
||||||
|
});
|
||||||
|
});
|
||||||
+106
-20
@@ -7,16 +7,16 @@
|
|||||||
* - outline.png — 32×32 transparent outline icon
|
* - outline.png — 32×32 transparent outline icon
|
||||||
* - color.png — 192×192 full-color icon
|
* - color.png — 192×192 full-color icon
|
||||||
*
|
*
|
||||||
* Icons are generated in-process using a minimal PNG encoder so we don't
|
* Icons are generated in-process using a minimal PNG encoder, and the zip is
|
||||||
* need ImageMagick or vendor binary icon blobs into the repo. The outline
|
* written in-process too, so we need neither ImageMagick nor a `zip` binary
|
||||||
* icon is a simple rounded square outline; the color icon is a brand-blue
|
* on the host, nor vendored binary blobs in the repo. The outline icon is a
|
||||||
* filled square with a small white "N" blocked in by pixel setting. Good
|
* simple rounded square outline; the color icon is a brand-blue filled
|
||||||
* enough for a working sideload — teams admins who care can replace the
|
* square with a small white "N" blocked in by pixel setting. Good enough
|
||||||
* icons later.
|
* 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 manifest is pinned to schema v1.16 to match the skill doc.
|
||||||
*/
|
*/
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import zlib from 'zlib';
|
import zlib from 'zlib';
|
||||||
@@ -36,6 +36,11 @@ export interface ManifestOptions {
|
|||||||
websiteUrl: string;
|
websiteUrl: string;
|
||||||
/** Out-dir for the generated zip + loose files. */
|
/** Out-dir for the generated zip + loose files. */
|
||||||
outDir: string;
|
outDir: string;
|
||||||
|
/**
|
||||||
|
* Include RSC permissions (ChannelMessage.Read.Group, ChatMessage.Read.Chat)
|
||||||
|
* so the bot receives all channel/group-chat messages without @-mention.
|
||||||
|
*/
|
||||||
|
rsc?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManifestResult {
|
export interface ManifestResult {
|
||||||
@@ -54,28 +59,93 @@ export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult {
|
|||||||
const colorPath = path.join(opts.outDir, 'color.png');
|
const colorPath = path.join(opts.outDir, 'color.png');
|
||||||
const zipPath = path.join(opts.outDir, 'teams-app-package.zip');
|
const zipPath = path.join(opts.outDir, 'teams-app-package.zip');
|
||||||
|
|
||||||
fs.writeFileSync(manifestPath, renderManifest(opts));
|
const manifest = Buffer.from(renderManifest(opts));
|
||||||
fs.writeFileSync(outlinePath, encodeOutlineIcon());
|
const outline = encodeOutlineIcon();
|
||||||
fs.writeFileSync(colorPath, encodeColorIcon());
|
const color = encodeColorIcon();
|
||||||
|
|
||||||
// Fresh zip every run — idempotent, no stale files.
|
fs.writeFileSync(manifestPath, manifest);
|
||||||
try {
|
fs.writeFileSync(outlinePath, outline);
|
||||||
fs.unlinkSync(zipPath);
|
fs.writeFileSync(colorPath, color);
|
||||||
} catch {
|
fs.writeFileSync(
|
||||||
// noop if missing
|
zipPath,
|
||||||
}
|
buildZip([
|
||||||
execSync(`zip -j -q "${zipPath}" "${manifestPath}" "${outlinePath}" "${colorPath}"`, {
|
{ name: 'manifest.json', data: manifest },
|
||||||
stdio: ['ignore', 'ignore', 'inherit'],
|
{ name: 'outline.png', data: outline },
|
||||||
});
|
{ name: 'color.png', data: color },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
return { zipPath, manifestPath, outlinePath, colorPath };
|
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 {
|
function renderManifest(opts: ManifestOptions): string {
|
||||||
const manifest = {
|
const manifest = {
|
||||||
$schema: MANIFEST_SCHEMA,
|
$schema: MANIFEST_SCHEMA,
|
||||||
manifestVersion: MANIFEST_VERSION,
|
manifestVersion: MANIFEST_VERSION,
|
||||||
version: '1.0.0',
|
// 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,
|
id: opts.appId,
|
||||||
packageName: 'com.nanoclaw.bot',
|
packageName: 'com.nanoclaw.bot',
|
||||||
developer: {
|
developer: {
|
||||||
@@ -104,6 +174,21 @@ function renderManifest(opts: ManifestOptions): string {
|
|||||||
],
|
],
|
||||||
permissions: ['identity', 'messageTeamMembers'],
|
permissions: ['identity', 'messageTeamMembers'],
|
||||||
validDomains: [new URL(opts.websiteUrl).host],
|
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';
|
return JSON.stringify(manifest, null, 2) + '\n';
|
||||||
}
|
}
|
||||||
@@ -269,3 +354,4 @@ function setWhite(pixels: Uint8Array, size: number, x: number, y: number): void
|
|||||||
pixels[i + 2] = 255;
|
pixels[i + 2] = 255;
|
||||||
pixels[i + 3] = 255;
|
pixels[i + 3] = 255;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,37 @@ describe('determineVerifyStatus', () => {
|
|||||||
).toBe('failed');
|
).toBe('failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deferred wire (Teams): configured but zero groups is pending operator
|
||||||
|
// action (first DM), not a broken install — success, not failed.
|
||||||
|
it('accepts zero groups when wiring is pending a first DM', () => {
|
||||||
|
expect(
|
||||||
|
determineVerifyStatus({
|
||||||
|
...healthyBase,
|
||||||
|
registeredGroups: 0,
|
||||||
|
wiringPending: true,
|
||||||
|
}),
|
||||||
|
).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pending wiring never rescues a stopped service or missing credentials', () => {
|
||||||
|
expect(
|
||||||
|
determineVerifyStatus({
|
||||||
|
...healthyBase,
|
||||||
|
registeredGroups: 0,
|
||||||
|
wiringPending: true,
|
||||||
|
service: 'stopped',
|
||||||
|
}),
|
||||||
|
).toBe('failed');
|
||||||
|
expect(
|
||||||
|
determineVerifyStatus({
|
||||||
|
...healthyBase,
|
||||||
|
registeredGroups: 0,
|
||||||
|
wiringPending: true,
|
||||||
|
credentials: 'missing',
|
||||||
|
}),
|
||||||
|
).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
it('fails when the service is not running', () => {
|
it('fails when the service is not running', () => {
|
||||||
expect(
|
expect(
|
||||||
determineVerifyStatus({
|
determineVerifyStatus({
|
||||||
|
|||||||
+25
-2
@@ -217,15 +217,27 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
mountAllowlist = 'configured';
|
mountAllowlist = 'configured';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deferred-wire channels can't have a group yet: their platform id only
|
||||||
|
// exists after the first inbound DM (see add-teams' "Finish wiring"), so
|
||||||
|
// configured-but-unwired is pending operator action, not a broken install.
|
||||||
|
// Only claim pending when EVERY configured channel is defer-wire — a
|
||||||
|
// wire-during-setup channel (slack, telegram, …) with zero groups is a
|
||||||
|
// genuine failure this must not mask.
|
||||||
|
const wiringPending =
|
||||||
|
registeredGroups === 0 &&
|
||||||
|
configuredChannels.length > 0 &&
|
||||||
|
configuredChannels.every((c) => DEFER_WIRE_CHANNELS.has(c));
|
||||||
|
|
||||||
// Determine overall status. The cli-agent step earlier in setup already
|
// Determine overall status. The cli-agent step earlier in setup already
|
||||||
// proved the agent round-trip works; verify is a static health check.
|
// proved the agent round-trip works; verify is a static health check.
|
||||||
const status = determineVerifyStatus({
|
const status = determineVerifyStatus({
|
||||||
service,
|
service,
|
||||||
credentials,
|
credentials,
|
||||||
registeredGroups,
|
registeredGroups,
|
||||||
|
wiringPending,
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info('Verification complete', { status, channelAuth });
|
log.info('Verification complete', { status, channelAuth, wiringPending });
|
||||||
|
|
||||||
emitStatus('VERIFY', {
|
emitStatus('VERIFY', {
|
||||||
SERVICE: service,
|
SERVICE: service,
|
||||||
@@ -235,6 +247,7 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
CHANNEL_AUTH: JSON.stringify(channelAuth),
|
CHANNEL_AUTH: JSON.stringify(channelAuth),
|
||||||
REGISTERED_GROUPS: registeredGroups,
|
REGISTERED_GROUPS: registeredGroups,
|
||||||
MOUNT_ALLOWLIST: mountAllowlist,
|
MOUNT_ALLOWLIST: mountAllowlist,
|
||||||
|
...(wiringPending ? { WIRING: 'pending_first_dm' } : {}),
|
||||||
STATUS: status,
|
STATUS: status,
|
||||||
LOG: 'logs/setup.log',
|
LOG: 'logs/setup.log',
|
||||||
});
|
});
|
||||||
@@ -242,14 +255,24 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
if (status === 'failed') process.exit(1);
|
if (status === 'failed') process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channels whose wiring only completes after the first inbound message —
|
||||||
|
* the platform id doesn't exist until the bot is DM'd, so setup ends with
|
||||||
|
* the channel configured but no group wired. Kept in lockstep with the
|
||||||
|
* deferWire call sites in setup/auto.ts.
|
||||||
|
*/
|
||||||
|
export const DEFER_WIRE_CHANNELS = new Set(['teams']);
|
||||||
|
|
||||||
export function determineVerifyStatus(input: {
|
export function determineVerifyStatus(input: {
|
||||||
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
|
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
|
||||||
credentials: string;
|
credentials: string;
|
||||||
registeredGroups: number;
|
registeredGroups: number;
|
||||||
|
/** Zero groups but every configured channel defers wiring to the first DM. */
|
||||||
|
wiringPending?: boolean;
|
||||||
}): 'success' | 'failed' {
|
}): 'success' | 'failed' {
|
||||||
return input.service === 'running' &&
|
return input.service === 'running' &&
|
||||||
input.credentials !== 'missing' &&
|
input.credentials !== 'missing' &&
|
||||||
input.registeredGroups > 0
|
(input.registeredGroups > 0 || input.wiringPending === true)
|
||||||
? 'success'
|
? 'success'
|
||||||
: 'failed';
|
: 'failed';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user