fix(msteams): persist conversation reference during DM pairing#43934
fix(msteams): persist conversation reference during DM pairing#43934shawny011717 wants to merge 1 commit intoopenclaw:mainfrom
Conversation
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
Greptile SummaryThis PR fixes a bug where the MS Teams conversation reference was never persisted for users whose first DM triggered the
Confidence Score: 4/5
Prompt To Fix All With AIThis 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 |
| 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, | ||
| }; |
There was a problem hiding this 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.:
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!
| 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"], | ||
| }; |
There was a problem hiding this 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:
- A custom
upsertPairingRequestspy (instead ofvi.fn(async () => null)). readAllowFromStorereturning[]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!
There was a problem hiding this comment.
💡 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) => { |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
When
dmPolicy=pairingis set for MS Teams, the first DM from an unknown user triggersupsertPairingRequestbut returns early beforeconversationStore.upsert()is called. This means the conversation reference is never persisted, so--notifycannot 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
dmPolicy: "pairing"openclaw pair approveopenclaw notifyto reach the user → fails because no conversation reference was storedRoot Cause
In
message-handler.ts, whenaccess.decision === "pairing", the handler callspairing.upsertPairingRequest(…)and then returns. TheconversationStore.upsert()call that persists the Teams conversation reference only happens later in the happy-path flow (afterdecision === "allow"). Since the pairing branch returns early, the reference is lost.Behaviour Changes
dmPolicy=pairing--notifyafter pairing approvalTests
Added regression test in
message-handler.authz.test.ts:dmPolicy=pairing, asserts bothupsertPairingRequestandconversationStore.upsertare called with correct arguments.All 3 tests in the authz suite pass. TypeScript compiles cleanly (only pre-existing
extensions/tlonerrors from unrelated missing deps).Sign-Off
scripts/committerlint + commit passedtsc --noEmitclean (excluding pre-existing tlon errors)lobster-biscuit