mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-09 18:57:08 +08:00
add-teams: ask before wiring the detected owner — a no defers with manual-wire guidance
The identity note ("stop here (Ctrl-C) if that's not you") becomes a real
decision: an operator note names the detected account ({{owner_upn}}), then
nc:prompt wire_owner (^(yes|no)$ — renders as a select under the driver's
enum-prompt rendering) gates the whole DM-open chain via when:wire_owner=yes.
wire_owner is only prompted on fresh creates, and an unbound var fails any
when: guard, so drop-through re-runs skip the chain exactly as before.
On no: a note explains how to wire the desired Teams user afterwards (DM-first
path needs no IDs; proactive wiring needs the person's Entra object ID and
where to find it), the wire inputs stay unresolved, and wireIfResolved falls
through to the deferred ending. Install/create/env still complete either way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -201,13 +201,34 @@ TEAMS_APP_TYPE=SingleTenant
|
||||
### Who owns this bot
|
||||
|
||||
The account signed into the Teams CLI is the account that just created the
|
||||
bot — that human is the owner the wiring step needs. Its identity comes from
|
||||
the CLI session, so this runs before the sign-out step below:
|
||||
bot — that human is the wiring target this flow suggests. Its identity comes
|
||||
from the CLI session, so this runs before the sign-out step below:
|
||||
|
||||
```nc:run effect:fetch when:have_creds=no capture:owner_upn=.username,owner_aad_id=.userObjectId validate:^.+$
|
||||
"$(npm prefix -g 2>/dev/null)/bin/teams" status --json 2>/dev/null
|
||||
```
|
||||
|
||||
### Confirm the wiring target
|
||||
|
||||
Nothing is wired without a yes here. Show the human who was detected, then
|
||||
ask — a no skips the whole [Link the bot to your account](#link-the-bot-to-your-account)
|
||||
chain, setup finishes unwired, and the note below explains how to wire the
|
||||
right person afterwards:
|
||||
|
||||
```nc:operator when:have_creds=no
|
||||
Detected the account that created the bot: {{owner_upn}}. Wiring the assistant to it means its first message arrives in that account's Teams DMs.
|
||||
```
|
||||
|
||||
```nc:prompt wire_owner when:have_creds=no validate:^(yes|no)$ normalize:lower
|
||||
Wire the assistant to this account?
|
||||
```
|
||||
|
||||
```nc:operator when:wire_owner=no
|
||||
Setup will finish without wiring. To wire the right Teams user afterwards:
|
||||
1. Easiest — no IDs needed: have that person DM the bot once in Teams ("hi" works). NanoClaw registers their identity and chat from that first message; then run /init-first-agent with your coding agent and pick them.
|
||||
2. To wire proactively instead (the assistant messages them first), your coding agent needs that person's Microsoft Entra object ID — found at entra.microsoft.com > Users > (person) > Overview > Object ID, or Teams admin center > Manage users. Hand it to /manage-channels.
|
||||
```
|
||||
|
||||
### Install the app in Teams
|
||||
|
||||
The app package is already uploaded — no manifest zip, no manual sideload.
|
||||
@@ -223,7 +244,9 @@ Once the app shows up in your Teams sidebar (or app list), continue.
|
||||
|
||||
### Link the bot to your account
|
||||
|
||||
Nothing to do in Teams yet — these are background API calls. Same move as
|
||||
Nothing to do in Teams yet — these are background API calls, and the whole
|
||||
chain runs only after the yes in
|
||||
[Confirm the wiring target](#confirm-the-wiring-target). Same move as
|
||||
Slack's `conversations.open` and Discord's `users/@me/channels`:
|
||||
create the bot↔owner 1:1 conversation proactively with the bot's own
|
||||
credentials, so the assistant messages the human first — nobody has to DM the
|
||||
@@ -233,7 +256,7 @@ below can fail once — re-running the skill is safe.
|
||||
|
||||
First a Bot Framework token from the app credentials:
|
||||
|
||||
```nc:run effect:fetch when:have_creds=no capture:bot_token validate:^eyJ
|
||||
```nc:run effect:fetch when:wire_owner=yes capture:bot_token validate:^eyJ
|
||||
curl -sf -X POST "https://login.microsoftonline.com/{{app_tenant_id}}/oauth2/v2.0/token" --data-urlencode "grant_type=client_credentials" --data-urlencode "client_id={{app_id}}" --data-urlencode "client_secret={{app_password}}" --data-urlencode "scope=https://api.botframework.com/.default" | jq -er '.access_token'
|
||||
```
|
||||
|
||||
@@ -241,7 +264,7 @@ Create the 1:1 conversation (the AAD object id from the CLI session is a
|
||||
valid member id; `smba.trafficmanager.net/teams` is the global service URL —
|
||||
the same default the adapter itself uses):
|
||||
|
||||
```nc:run effect:fetch when:have_creds=no capture:conversation_id validate:^.+$
|
||||
```nc:run effect:fetch when:wire_owner=yes capture:conversation_id validate:^.+$
|
||||
curl -sf -X POST "https://smba.trafficmanager.net/teams/v3/conversations" -H "Authorization: Bearer {{bot_token}}" -H "Content-Type: application/json" -d '{"bot":{"id":"28:{{app_id}}"},"members":[{"id":"{{owner_aad_id}}","name":"","role":"user"}],"tenantId":"{{app_tenant_id}}","channelData":{"tenant":{"id":"{{app_tenant_id}}"}},"isGroup":false}' | jq -er '.id'
|
||||
```
|
||||
|
||||
@@ -253,24 +276,17 @@ filter only guards against channels that list the bot itself (`28:` ids).
|
||||
(Don't select by `.aadObjectId` here — the field is not reliably present in
|
||||
this response and its GUID casing varies.)
|
||||
|
||||
```nc:run effect:fetch when:have_creds=no capture:owner_handle=.id,owner_name=.name validate:^.+$
|
||||
```nc:run effect:fetch when:wire_owner=yes capture:owner_handle=.id,owner_name=.name validate:^.+$
|
||||
curl -sf "https://smba.trafficmanager.net/teams/v3/conversations/{{conversation_id}}/members" -H "Authorization: Bearer {{bot_token}}" | jq -er '[.[] | select((.id // "") | startswith("28:") | not)][0] | {id, name: (.name // .givenName // "Teams user")}'
|
||||
```
|
||||
|
||||
Compose the platform id exactly as the adapter encodes thread ids
|
||||
(`teams:{b64url conversation}:{b64url service url}`):
|
||||
|
||||
```nc:run when:have_creds=no capture:platform_id validate:^teams:
|
||||
```nc:run when:wire_owner=yes capture:platform_id validate:^teams:
|
||||
node -e 'const c=process.argv[1];const s="https://smba.trafficmanager.net/teams/";console.log("teams:"+Buffer.from(c).toString("base64url")+":"+Buffer.from(s).toString("base64url"))' "{{conversation_id}}"
|
||||
```
|
||||
|
||||
Tell the user who the wiring targets — if this isn't them, they should stop
|
||||
here and wire manually with `/manage-channels` instead:
|
||||
|
||||
```nc:operator when:have_creds=no
|
||||
The bot will be wired to {{owner_name}} ({{owner_upn}}) — the account that created it — and the assistant's first message will arrive in that account's Teams DMs. If that's not you, stop here (Ctrl-C) and wire manually with /manage-channels later.
|
||||
```
|
||||
|
||||
### Sign out of the Teams CLI
|
||||
|
||||
The Microsoft 365 session was only needed to create the bot and identify its
|
||||
@@ -309,9 +325,10 @@ skill outside the wizard? Run the same wire yourself:
|
||||
pnpm exec tsx scripts/init-first-agent.ts --channel teams --user-id "teams:<owner_handle>" --platform-id "<platform_id>" --display-name "<the human's name>" --agent-name "<assistant name>" --role owner
|
||||
```
|
||||
|
||||
**Fallback (re-runs, or the link step failed):** with credentials already in
|
||||
`.env` the resolve steps are skipped, so there is nothing new to wire — the
|
||||
first run's wiring still stands. If the install was never wired at all, the
|
||||
**Fallback (re-runs, a no at the wiring confirm, or the link step failed):**
|
||||
with credentials already in `.env` the resolve steps are skipped, so there is
|
||||
nothing new to wire — the first run's wiring still stands. If the install was
|
||||
never wired at all — including a deliberate no at the confirm — the
|
||||
DM-first path always works: DM the bot once ("hi" is fine) — the router
|
||||
auto-creates the messaging group row in `data/v2.db` from that first inbound
|
||||
— then run `/init-first-agent` (or `/manage-channels`) with your coding
|
||||
|
||||
@@ -29,18 +29,20 @@ function operatorBody(md: string, n: number): string {
|
||||
|
||||
describe('gatePolicy — §5.1 parity table (real skills)', () => {
|
||||
it('teams: one gate — the install-in-Teams operator pauses before the DM-open fetches', () => {
|
||||
// Operators in order (CLI-first flow): prerequisites, install-in-Teams
|
||||
// (when:have_creds=no), the wiring-identity note. The finish-wiring
|
||||
// handoff is prose only — the wizard wires inline from the resolved vars.
|
||||
// Operators in order (CLI-first flow): prerequisites, the detected-owner
|
||||
// note, the wire-declined note (when:wire_owner=no), install-in-Teams.
|
||||
// The finish-wiring handoff is prose only — the wizard wires inline from
|
||||
// the resolved vars.
|
||||
const d = decisions(loadSkill('teams'));
|
||||
expect(d).toHaveLength(3);
|
||||
expect(d).toHaveLength(4);
|
||||
expect(d.map((g) => g.needsConfirm)).toEqual([
|
||||
false, // prereqs → prompt public_url (prompt is the barrier)
|
||||
false, // detected-owner note → prompt wire_owner (prompt is the barrier)
|
||||
false, // wire-declined note → install operator (last operator of the chain carries the barrier)
|
||||
true, // install-in-Teams → the DM-open effect:fetch chain
|
||||
false, // identity note → prompt signout (prompt is the barrier)
|
||||
]);
|
||||
// Completed-work flavor (fetch/external, not effect:step).
|
||||
expect(d[1].flavor).toBe('completed');
|
||||
expect(d[3].flavor).toBe('completed');
|
||||
});
|
||||
|
||||
it('telegram: the pairing operator gains a readiness pause before the effect:step', () => {
|
||||
@@ -140,10 +142,11 @@ describe('extractOfferUrl — §5.2 inventory', () => {
|
||||
// The install block's URL is {{install_link}} in the AUTHORED body — no
|
||||
// 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(operatorBody(md, 3)).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(); // wiring-identity note
|
||||
expect(extractOfferUrl(operatorBody(md, 1))).toBeUndefined(); // detected-owner note
|
||||
expect(extractOfferUrl(operatorBody(md, 2))).toBeUndefined(); // wire-declined note (entra link is schemeless on purpose)
|
||||
expect(extractOfferUrl(operatorBody(md, 3))).toBeUndefined(); // install-in-Teams
|
||||
});
|
||||
|
||||
it('slack — the <your-public-host> placeholder is EXCLUDED (normative negative fixture)', () => {
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('runChannelSkill adapter (Option A)', () => {
|
||||
return { ok: true, fields: { STATUS: 'success' } };
|
||||
},
|
||||
resolveRemote: () => 'origin',
|
||||
inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', signout: 'yes' },
|
||||
inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', wire_owner: 'yes', signout: 'yes' },
|
||||
confirm: async (m) => {
|
||||
log.push(`confirm:${m}`);
|
||||
return true;
|
||||
@@ -245,7 +245,7 @@ describe('runChannelSkill adapter (Option A)', () => {
|
||||
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 DM-open chain resolved the wire inputs: the owner's 29: id from the
|
||||
// conversation members (selected by AAD id) and the adapter-encoded
|
||||
// conversation members (first non-bot member) and the adapter-encoded
|
||||
// platform id from the created conversation…
|
||||
expect(res.vars.owner_handle).toBe('29:owner-xyz');
|
||||
expect(res.vars.owner_name).toBe('Dan Mill');
|
||||
@@ -273,6 +273,53 @@ describe('runChannelSkill adapter (Option A)', () => {
|
||||
expect(fullyApplied(res)).toBe(true);
|
||||
});
|
||||
|
||||
// A "no" at the wiring confirm skips the whole DM-open chain: no Bot
|
||||
// Framework token is fetched, no conversation is created, and the wire
|
||||
// inputs stay unresolved — the wizard's deferred (DM-first) ending takes
|
||||
// over. The create/install/env half still completes.
|
||||
it('Teams fresh create, wiring declined: the DM-open chain never runs and nothing resolves', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-decline-'));
|
||||
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(`exec:${c}`);
|
||||
if (c.includes('TEAMS_APP_ID=.')) return 'no';
|
||||
if (c.includes(' app create ')) {
|
||||
return JSON.stringify({
|
||||
teamsAppId: 'tapp-123',
|
||||
installLink: 'https://teams.microsoft.com/l/app/tapp-123',
|
||||
credentials: { CLIENT_ID: 'app-1', CLIENT_SECRET: 'a-much-longer-app-secret', TENANT_ID: 'tenant-1' },
|
||||
});
|
||||
}
|
||||
if (c.includes('status --json')) {
|
||||
return JSON.stringify({ loggedIn: true, username: 'dan@acme.example', userObjectId: 'aad-owner-1' });
|
||||
}
|
||||
},
|
||||
execStream: async () => ({ ok: true, fields: { STATUS: 'success' } }),
|
||||
resolveRemote: () => 'origin',
|
||||
inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', wire_owner: 'no', signout: 'yes' },
|
||||
confirm: async () => true,
|
||||
openUrl: async () => {},
|
||||
});
|
||||
|
||||
// The chain never started: no token fetch, no conversation create…
|
||||
expect(log.some((c) => c.includes('login.microsoftonline.com'))).toBe(false);
|
||||
expect(log.some((c) => c.includes('/v3/conversations'))).toBe(false);
|
||||
// …the wire inputs stayed unresolved (wireIfResolved will defer)…
|
||||
expect(res.vars.owner_handle).toBeUndefined();
|
||||
expect(res.vars.platform_id).toBeUndefined();
|
||||
// …while the credentials still landed and nothing bounced to an agent.
|
||||
expect(readFileSync(join(root, '.env'), 'utf8')).toContain('TEAMS_APP_ID=app-1');
|
||||
expect(res.agentTasks).toEqual([]);
|
||||
expect(fullyApplied(res)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
// The resolved leg of wireIfResolved, driven with a minimal fixture skill
|
||||
// (the real teams document needs a streaming exec runChannelSkill doesn't
|
||||
|
||||
Reference in New Issue
Block a user