Skip to content

fix(msteams): persist conversation reference during DM pairing#43934

Open
shawny011717 wants to merge 1 commit intoopenclaw:mainfrom
shawny011717:fix/msteams-persist-conversation-ref-on-pairing
Open

fix(msteams): persist conversation reference during DM pairing#43934
shawny011717 wants to merge 1 commit intoopenclaw:mainfrom
shawny011717:fix/msteams-persist-conversation-ref-on-pairing

Conversation

@shawny011717
Copy link
Copy Markdown

Summary

When dmPolicy=pairing is set for MS Teams, the first DM from an unknown user triggers upsertPairingRequest but returns early before conversationStore.upsert() is called. This means the conversation reference is never persisted, so --notify cannot deliver messages to the user after their pairing request is approved.

This PR moves conversationStore.upsert() into the pairing block so the reference is saved immediately upon first contact.

Repro Steps

  1. Configure MS Teams extension with dmPolicy: "pairing"
  2. Have an unpaired user send a DM to the bot
  3. Approve the pairing request via openclaw pair approve
  4. Attempt openclaw notify to reach the user → fails because no conversation reference was stored

Root Cause

In message-handler.ts, when access.decision === "pairing", the handler calls pairing.upsertPairingRequest(…) and then returns. The conversationStore.upsert() call that persists the Teams conversation reference only happens later in the happy-path flow (after decision === "allow"). Since the pairing branch returns early, the reference is lost.

Behaviour Changes

Scenario Before After
First DM with dmPolicy=pairing Conversation reference not saved Conversation reference saved before early return
--notify after pairing approval Fails (no stored reference) Works (reference persisted at first contact)
Existing allowed-user DMs No change No change
Group messages No change No change

Tests

Added regression test in message-handler.authz.test.ts:

  • "persists conversation reference on first DM during pairing" — sends a personal DM with dmPolicy=pairing, asserts both upsertPairingRequest and conversationStore.upsert are called with correct arguments.

All 3 tests in the authz suite pass. TypeScript compiles cleanly (only pre-existing extensions/tlon errors from unrelated missing deps).

Sign-Off

  • Regression test added
  • scripts/committer lint + commit passed
  • tsc --noEmit clean (excluding pre-existing tlon errors)
  • vitest suite green (3/3)

lobster-biscuit

When a user sends their first DM with dmPolicy=pairing, the handler
returned early after upsertPairingRequest without calling
conversationStore.upsert(). This meant the conversation reference was
never saved, so --notify could not reach the user after pairing was
approved.

Move conversationStore.upsert() into the pairing block before the
early return so the reference is persisted immediately.

Closes openclaw#43323
@openclaw-barnacle openclaw-barnacle bot added channel: msteams Channel integration: msteams size: S labels Mar 12, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 12, 2026

Greptile Summary

This PR fixes a bug where the MS Teams conversation reference was never persisted for users whose first DM triggered the dmPolicy=pairing flow, preventing --notify from reaching them after pairing approval. The fix correctly moves conversationStore.upsert() into the early-return pairing block, consistent with the fire-and-forget pattern already used in the happy path.

  • The core logic fix is sound: the pairing decision is only reachable for direct messages, so the missing teamId field in pairingRef is not a functional gap (it would always be undefined for DMs).
  • The regression test verifies both the pairing request and the conversation store upsert are triggered with the right arguments — good coverage of the exact bug scenario.
  • Minor maintainability concern: the pairingRef object construction (lines 216–229) is a near-verbatim duplicate of conversationRef (lines 345–359). Extracting a shared builder function would prevent future drift when StoredConversationReference grows new fields.
  • The new test bypasses the shared createDeps helper and re-creates ~50 lines of setup boilerplate, adding unnecessary duplication to the test file.

Confidence Score: 4/5

  • Safe to merge — the fix is logically correct, well-tested, and consistent with existing patterns. Only minor style concerns remain.
  • The bug fix is targeted and correct: conversationStore.upsert is now called in the pairing branch before the early return, using the same fire-and-forget pattern as the rest of the handler. The regression test directly exercises the fixed path. No logic errors or security issues were found. Score is 4 rather than 5 solely due to the duplicated reference-building code, which creates a maintenance hazard but no immediate functional risk.
  • No files require special attention beyond the style notes on message-handler.ts and message-handler.authz.test.ts.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/msteams/src/monitor-handler/message-handler.ts
Line: 215-229

Comment:
**Duplicated `StoredConversationReference` construction**

`pairingRef` is a near-verbatim copy of `conversationRef` built later at lines 344–359, with the only structural difference being the absence of `teamId`. For DMs (the only path that can reach `access.decision === "pairing"`), `teamId` is always `undefined`, so the two objects are functionally equivalent — but the duplication creates a maintenance hazard: if new fields are ever added to `StoredConversationReference`, this second construction site is easy to miss.

Consider extracting a shared helper, e.g.:

```ts
function buildConversationRef(
  activity: ...,
  from: ...,
  conversationId: string,
  conversationType: string,
  teamId?: string,
): StoredConversationReference {
  const agent = activity.recipient;
  return {
    activityId: activity.id,
    user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId },
    agent,
    bot: agent ? { id: agent.id, name: agent.name } : undefined,
    conversation: {
      id: conversationId,
      conversationType,
      tenantId: activity.conversation?.tenantId,
    },
    teamId,
    channelId: activity.channelId,
    serviceUrl: activity.serviceUrl,
    locale: activity.locale,
  };
}
```

Both the pairing block and the happy-path block could then call this helper, eliminating the drift risk.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/msteams/src/monitor-handler/message-handler.authz.test.ts
Line: 156-212

Comment:
**Test manually re-creates `deps` instead of using shared `createDeps` helper**

The new test duplicates ~50 lines of boilerplate that `createDeps` already encapsulates (runtime mock, `conversationStore`, `pollStore`, `log`, etc.). The only real differences from `createDeps` are:

1. A custom `upsertPairingRequest` spy (instead of `vi.fn(async () => null)`).
2. `readAllowFromStore` returning `[]` instead of `["attacker-aad"]`.

Both could be covered by extending `createDeps` with optional overrides:

```ts
const { conversationStore, deps } = createDeps(
  {
    channels: { msteams: { dmPolicy: "pairing", allowFrom: [] } },
  } as OpenClawConfig,
  { upsertPairingRequestReturnValue: { id: "user-aad", channel: "msteams" }, storeAllowFrom: [] }
);
```

This keeps the test file DRY and makes the intent of each test clearer.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: a74702b

Comment on lines +215 to +229
const agent = activity.recipient;
const pairingRef: StoredConversationReference = {
activityId: activity.id,
user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId },
agent,
bot: agent ? { id: agent.id, name: agent.name } : undefined,
conversation: {
id: conversationId,
conversationType,
tenantId: conversation?.tenantId,
},
channelId: activity.channelId,
serviceUrl: activity.serviceUrl,
locale: activity.locale,
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated StoredConversationReference construction

pairingRef is a near-verbatim copy of conversationRef built later at lines 344–359, with the only structural difference being the absence of teamId. For DMs (the only path that can reach access.decision === "pairing"), teamId is always undefined, so the two objects are functionally equivalent — but the duplication creates a maintenance hazard: if new fields are ever added to StoredConversationReference, this second construction site is easy to miss.

Consider extracting a shared helper, e.g.:

function buildConversationRef(
  activity: ...,
  from: ...,
  conversationId: string,
  conversationType: string,
  teamId?: string,
): StoredConversationReference {
  const agent = activity.recipient;
  return {
    activityId: activity.id,
    user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId },
    agent,
    bot: agent ? { id: agent.id, name: agent.name } : undefined,
    conversation: {
      id: conversationId,
      conversationType,
      tenantId: activity.conversation?.tenantId,
    },
    teamId,
    channelId: activity.channelId,
    serviceUrl: activity.serviceUrl,
    locale: activity.locale,
  };
}

Both the pairing block and the happy-path block could then call this helper, eliminating the drift risk.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/monitor-handler/message-handler.ts
Line: 215-229

Comment:
**Duplicated `StoredConversationReference` construction**

`pairingRef` is a near-verbatim copy of `conversationRef` built later at lines 344–359, with the only structural difference being the absence of `teamId`. For DMs (the only path that can reach `access.decision === "pairing"`), `teamId` is always `undefined`, so the two objects are functionally equivalent — but the duplication creates a maintenance hazard: if new fields are ever added to `StoredConversationReference`, this second construction site is easy to miss.

Consider extracting a shared helper, e.g.:

```ts
function buildConversationRef(
  activity: ...,
  from: ...,
  conversationId: string,
  conversationType: string,
  teamId?: string,
): StoredConversationReference {
  const agent = activity.recipient;
  return {
    activityId: activity.id,
    user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId },
    agent,
    bot: agent ? { id: agent.id, name: agent.name } : undefined,
    conversation: {
      id: conversationId,
      conversationType,
      tenantId: activity.conversation?.tenantId,
    },
    teamId,
    channelId: activity.channelId,
    serviceUrl: activity.serviceUrl,
    locale: activity.locale,
  };
}
```

Both the pairing block and the happy-path block could then call this helper, eliminating the drift risk.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +156 to +212
it("persists conversation reference on first DM during pairing", async () => {
const upsertPairingRequest = vi.fn(async () => ({ id: "user-aad", channel: "msteams" }));
setMSTeamsRuntime({
logging: { shouldLogVerbose: () => false },
channel: {
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: <T>(params: {
onFlush: (entries: T[]) => Promise<void>;
}): { enqueue: (entry: T) => Promise<void> } => ({
enqueue: async (entry: T) => {
await params.onFlush([entry]);
},
}),
},
pairing: {
readAllowFromStore: vi.fn(async () => []),
upsertPairingRequest,
},
text: {
hasControlCommand: () => false,
},
},
} as unknown as PluginRuntime);

const conversationStore = {
upsert: vi.fn(async () => undefined),
};

const deps: MSTeamsMessageHandlerDeps = {
cfg: {
channels: {
msteams: {
dmPolicy: "pairing",
allowFrom: [],
},
},
} as OpenClawConfig,
runtime: { error: vi.fn() } as unknown as RuntimeEnv,
appId: "test-app",
adapter: {} as MSTeamsMessageHandlerDeps["adapter"],
tokenProvider: {
getAccessToken: vi.fn(async () => "token"),
},
textLimit: 4000,
mediaMaxBytes: 1024 * 1024,
conversationStore:
conversationStore as unknown as MSTeamsMessageHandlerDeps["conversationStore"],
pollStore: {
recordVote: vi.fn(async () => null),
} as unknown as MSTeamsMessageHandlerDeps["pollStore"],
log: {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
} as unknown as MSTeamsMessageHandlerDeps["log"],
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test manually re-creates deps instead of using shared createDeps helper

The new test duplicates ~50 lines of boilerplate that createDeps already encapsulates (runtime mock, conversationStore, pollStore, log, etc.). The only real differences from createDeps are:

  1. A custom upsertPairingRequest spy (instead of vi.fn(async () => null)).
  2. readAllowFromStore returning [] instead of ["attacker-aad"].

Both could be covered by extending createDeps with optional overrides:

const { conversationStore, deps } = createDeps(
  {
    channels: { msteams: { dmPolicy: "pairing", allowFrom: [] } },
  } as OpenClawConfig,
  { upsertPairingRequestReturnValue: { id: "user-aad", channel: "msteams" }, storeAllowFrom: [] }
);

This keeps the test file DRY and makes the intent of each test clearer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/monitor-handler/message-handler.authz.test.ts
Line: 156-212

Comment:
**Test manually re-creates `deps` instead of using shared `createDeps` helper**

The new test duplicates ~50 lines of boilerplate that `createDeps` already encapsulates (runtime mock, `conversationStore`, `pollStore`, `log`, etc.). The only real differences from `createDeps` are:

1. A custom `upsertPairingRequest` spy (instead of `vi.fn(async () => null)`).
2. `readAllowFromStore` returning `[]` instead of `["attacker-aad"]`.

Both could be covered by extending `createDeps` with optional overrides:

```ts
const { conversationStore, deps } = createDeps(
  {
    channels: { msteams: { dmPolicy: "pairing", allowFrom: [] } },
  } as OpenClawConfig,
  { upsertPairingRequestReturnValue: { id: "user-aad", channel: "msteams" }, storeAllowFrom: [] }
);
```

This keeps the test file DRY and makes the intent of each test clearer.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a74702b7b8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

serviceUrl: activity.serviceUrl,
locale: activity.locale,
};
conversationStore.upsert(conversationId, pairingRef).catch((err) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid storing unapproved DM refs in main conversation cache

Calling conversationStore.upsert for every access.decision === "pairing" DM writes unpaired users into the same store used for proactive sends, and that store is hard-capped to 1000 entries with oldest-entry eviction (extensions/msteams/src/conversation-store-fs.ts:15,29-45,133-147). In pairing mode, a burst of first-contact DMs can now evict existing approved users' conversation references, causing later --notify/proactive sends for legitimate users to fail even though they were previously reachable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@BradGroux BradGroux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick triage against #43414: this appears to solve the same DM pairing persistence problem but with a broader change set. Recommend converging on #43414 as the minimal path and closing/superseding this PR to avoid split fixes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: msteams Channel integration: msteams size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants