@open-everywhere/provider-teams 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +46 -2
- package/agent/channels/teams-bot-name.ts +162 -0
- package/agent/channels/teams-catch-up.ts +244 -0
- package/agent/channels/teams-connector.ts +183 -0
- package/agent/channels/teams-conversation-ref.ts +398 -0
- package/agent/channels/teams-directory.ts +193 -0
- package/agent/channels/teams-engagement.ts +391 -0
- package/agent/channels/teams-fire-eligibility.ts +152 -0
- package/agent/channels/teams-front.ts +1404 -0
- package/agent/channels/teams-graph-boundary.ts +178 -0
- package/agent/channels/teams-graph-team-id.ts +106 -0
- package/agent/channels/teams-id-restore.ts +131 -0
- package/agent/channels/teams-identity.ts +395 -0
- package/agent/channels/teams-input-response.ts +129 -0
- package/agent/channels/teams-message-ops.ts +79 -0
- package/agent/channels/teams-origin-codec.ts +104 -0
- package/agent/channels/teams-post-arm.ts +323 -0
- package/agent/channels/teams-reaction-feedback.ts +127 -0
- package/agent/channels/teams-read.ts +510 -0
- package/agent/channels/teams-roster.ts +136 -0
- package/agent/channels/teams-stop.ts +319 -0
- package/agent/channels/teams-supervisor.ts +638 -0
- package/agent/channels/teams.ts +270 -0
- package/agent/contributions.ts +31 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/ops.ts +129 -0
- package/agent/provider-events.ts +69 -0
- package/agent/register.ts +219 -0
- package/agent/surface-parity.ts +112 -0
- package/dist/agent/channels/teams-bot-name.d.ts +36 -0
- package/dist/agent/channels/teams-bot-name.d.ts.map +1 -0
- package/dist/agent/channels/teams-bot-name.js +141 -0
- package/dist/agent/channels/teams-bot-name.js.map +1 -0
- package/dist/agent/channels/teams-catch-up.d.ts +53 -0
- package/dist/agent/channels/teams-catch-up.d.ts.map +1 -0
- package/dist/agent/channels/teams-catch-up.js +221 -0
- package/dist/agent/channels/teams-catch-up.js.map +1 -0
- package/dist/agent/channels/teams-connector.d.ts +57 -0
- package/dist/agent/channels/teams-connector.d.ts.map +1 -0
- package/dist/agent/channels/teams-connector.js +132 -0
- package/dist/agent/channels/teams-connector.js.map +1 -0
- package/dist/agent/channels/teams-conversation-ref.d.ts +123 -0
- package/dist/agent/channels/teams-conversation-ref.d.ts.map +1 -0
- package/dist/agent/channels/teams-conversation-ref.js +368 -0
- package/dist/agent/channels/teams-conversation-ref.js.map +1 -0
- package/dist/agent/channels/teams-directory.d.ts +11 -0
- package/dist/agent/channels/teams-directory.d.ts.map +1 -0
- package/dist/agent/channels/teams-directory.js +179 -0
- package/dist/agent/channels/teams-directory.js.map +1 -0
- package/dist/agent/channels/teams-engagement.d.ts +174 -0
- package/dist/agent/channels/teams-engagement.d.ts.map +1 -0
- package/dist/agent/channels/teams-engagement.js +336 -0
- package/dist/agent/channels/teams-engagement.js.map +1 -0
- package/dist/agent/channels/teams-fire-eligibility.d.ts +26 -0
- package/dist/agent/channels/teams-fire-eligibility.d.ts.map +1 -0
- package/dist/agent/channels/teams-fire-eligibility.js +126 -0
- package/dist/agent/channels/teams-fire-eligibility.js.map +1 -0
- package/dist/agent/channels/teams-front.d.ts +249 -0
- package/dist/agent/channels/teams-front.d.ts.map +1 -0
- package/dist/agent/channels/teams-front.js +1143 -0
- package/dist/agent/channels/teams-front.js.map +1 -0
- package/dist/agent/channels/teams-graph-boundary.d.ts +45 -0
- package/dist/agent/channels/teams-graph-boundary.d.ts.map +1 -0
- package/dist/agent/channels/teams-graph-boundary.js +168 -0
- package/dist/agent/channels/teams-graph-boundary.js.map +1 -0
- package/dist/agent/channels/teams-graph-team-id.d.ts +30 -0
- package/dist/agent/channels/teams-graph-team-id.d.ts.map +1 -0
- package/dist/agent/channels/teams-graph-team-id.js +89 -0
- package/dist/agent/channels/teams-graph-team-id.js.map +1 -0
- package/dist/agent/channels/teams-id-restore.d.ts +34 -0
- package/dist/agent/channels/teams-id-restore.d.ts.map +1 -0
- package/dist/agent/channels/teams-id-restore.js +123 -0
- package/dist/agent/channels/teams-id-restore.js.map +1 -0
- package/dist/agent/channels/teams-identity.d.ts +148 -0
- package/dist/agent/channels/teams-identity.d.ts.map +1 -0
- package/dist/agent/channels/teams-identity.js +336 -0
- package/dist/agent/channels/teams-identity.js.map +1 -0
- package/dist/agent/channels/teams-input-response.d.ts +23 -0
- package/dist/agent/channels/teams-input-response.d.ts.map +1 -0
- package/dist/agent/channels/teams-input-response.js +102 -0
- package/dist/agent/channels/teams-input-response.js.map +1 -0
- package/dist/agent/channels/teams-message-ops.d.ts +3 -0
- package/dist/agent/channels/teams-message-ops.d.ts.map +1 -0
- package/dist/agent/channels/teams-message-ops.js +72 -0
- package/dist/agent/channels/teams-message-ops.js.map +1 -0
- package/dist/agent/channels/teams-origin-codec.d.ts +16 -0
- package/dist/agent/channels/teams-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/teams-origin-codec.js +99 -0
- package/dist/agent/channels/teams-origin-codec.js.map +1 -0
- package/dist/agent/channels/teams-post-arm.d.ts +37 -0
- package/dist/agent/channels/teams-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/teams-post-arm.js +280 -0
- package/dist/agent/channels/teams-post-arm.js.map +1 -0
- package/dist/agent/channels/teams-reaction-feedback.d.ts +22 -0
- package/dist/agent/channels/teams-reaction-feedback.d.ts.map +1 -0
- package/dist/agent/channels/teams-reaction-feedback.js +108 -0
- package/dist/agent/channels/teams-reaction-feedback.js.map +1 -0
- package/dist/agent/channels/teams-read.d.ts +127 -0
- package/dist/agent/channels/teams-read.d.ts.map +1 -0
- package/dist/agent/channels/teams-read.js +424 -0
- package/dist/agent/channels/teams-read.js.map +1 -0
- package/dist/agent/channels/teams-roster.d.ts +54 -0
- package/dist/agent/channels/teams-roster.d.ts.map +1 -0
- package/dist/agent/channels/teams-roster.js +121 -0
- package/dist/agent/channels/teams-roster.js.map +1 -0
- package/dist/agent/channels/teams-stop.d.ts +74 -0
- package/dist/agent/channels/teams-stop.d.ts.map +1 -0
- package/dist/agent/channels/teams-stop.js +252 -0
- package/dist/agent/channels/teams-stop.js.map +1 -0
- package/dist/agent/channels/teams-supervisor.d.ts +351 -0
- package/dist/agent/channels/teams-supervisor.d.ts.map +1 -0
- package/dist/agent/channels/teams-supervisor.js +366 -0
- package/dist/agent/channels/teams-supervisor.js.map +1 -0
- package/dist/agent/channels/teams.d.ts +5 -0
- package/dist/agent/channels/teams.d.ts.map +1 -0
- package/dist/agent/channels/teams.js +246 -0
- package/dist/agent/channels/teams.js.map +1 -0
- package/dist/agent/contributions.d.ts +12 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +31 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/ops.d.ts +14 -0
- package/dist/agent/ops.d.ts.map +1 -0
- package/dist/agent/ops.js +122 -0
- package/dist/agent/ops.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +67 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +16 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +198 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +106 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/package.json +183 -4
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The Teams UnifiedWakeOrigin codec + the Bot-Connector serviceUrl host allowlist — MOVED VERBATIM
|
|
2
|
+
// out of core's channels/wake-origin-codecs.ts (OPE-114 stage 4). STORED-DATA COMPATIBILITY IS THE
|
|
3
|
+
// CONTRACT: a Teams origin is JSON-serialised into the durable operational store and fail-closed
|
|
4
|
+
// re-validated on read, so every behaviour here is frozen by core's
|
|
5
|
+
// scripts/wake-origin-fixtures-test.ts (run from the app root with this bundle's boot wiring —
|
|
6
|
+
// scripts/wake-origin-fixtures-boot.ts). Validator tolerances (typeof-only optional checks, extra
|
|
7
|
+
// unknown fields) are LOAD-BEARING for rows already on disk — never tighten them here.
|
|
8
|
+
//
|
|
9
|
+
// The `UnifiedWakeOrigin` TYPE union (including its teams arm) stays in core — it is the wire
|
|
10
|
+
// schema for stored rows; only the codec BEHAVIOUR lives in the bundle, registered by
|
|
11
|
+
// registerTeamsProvider() (../register.ts). `isTrustedTeamsServiceUrl` is exported for the teams
|
|
12
|
+
// post arm (BOTH of its dispatch paths re-validate the serviceUrl before POSTing the Connector
|
|
13
|
+
// token) and applied here on the stored-row `validate` arm — every route by which a serviceUrl can
|
|
14
|
+
// reach a token POST passes it. (There was a third route, the `dmOrigin` DM-delegation-anchor mint
|
|
15
|
+
// arm, which applied the same allowlist; it went with the anchor at OPE-368 — a removed route needs
|
|
16
|
+
// no check.) Core no longer re-exports the predicate (the stale unified-window re-export was removed
|
|
17
|
+
// in stage 4).
|
|
18
|
+
|
|
19
|
+
import type { UnifiedWakeOrigin, WakeOriginCodec } from "@open-everywhere/core/agent/adapter-api.js";
|
|
20
|
+
|
|
21
|
+
type TeamsOrigin = Extract<UnifiedWakeOrigin, { platform: "teams" }>;
|
|
22
|
+
|
|
23
|
+
/** COLON-FREE by construction: a task windowId is `[^:]+` (core channels/window-key.ts), so any colon
|
|
24
|
+
* in a raw surface id (Teams' `19:...@thread.v2;messageid=` conversation ids) is folded to `_` —
|
|
25
|
+
* deterministic, so spawn + ingress still agree on identity. */
|
|
26
|
+
const noColon = (s: string): string => s.replace(/:/g, "_");
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Bot Connector `serviceUrl` host allowlist. The Teams reply POSTs a `client_credentials` bearer
|
|
30
|
+
* token to `${serviceUrl}/v3/conversations/...`, so the host must be a real Microsoft Bot
|
|
31
|
+
* Framework endpoint — not merely https. `serviceUrl` rides in off the inbound activity BODY, and
|
|
32
|
+
* eve's JWT verifier does NOT bind it to the token's `serviceurl` claim, so a scheme-only check
|
|
33
|
+
* (`startsWith("https://")`) would let a body-supplied `https://evil.example` exfiltrate the
|
|
34
|
+
* token. Constrain the host to the documented Bot Connector set. Fail-closed on any parse error.
|
|
35
|
+
*/
|
|
36
|
+
export function isTrustedTeamsServiceUrl(serviceUrl: unknown): boolean {
|
|
37
|
+
if (typeof serviceUrl !== "string") return false;
|
|
38
|
+
let host: string;
|
|
39
|
+
try {
|
|
40
|
+
const u = new URL(serviceUrl);
|
|
41
|
+
if (u.protocol !== "https:") return false;
|
|
42
|
+
host = u.hostname.toLowerCase();
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
// An entry beginning with "." is a dot-anchored suffix; otherwise an exact host.
|
|
47
|
+
return teamsServiceHostAllowlist().some((h) => (h.startsWith(".") ? host.endsWith(h) : host === h));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The GLOBAL (commercial) cloud Bot Connector hosts. Sovereign/national clouds use DIFFERENT
|
|
51
|
+
* hosts and are NOT in the default (keeping the default minimal, per the no-extrapolation rule):
|
|
52
|
+
* a GCC / GCC High / DoD / 21Vianet operator opts theirs in via OE_TEAMS_SERVICE_HOSTS. Verified
|
|
53
|
+
* values (Microsoft Learn, "Send proactive messages"): GCC `smba.infra.gcc.teams.microsoft.com`,
|
|
54
|
+
* GCC High `smba.infra.gov.teams.microsoft.us`, DoD `smba.infra.dod.teams.microsoft.us`. */
|
|
55
|
+
const DEFAULT_TEAMS_SERVICE_HOSTS: readonly string[] = [
|
|
56
|
+
"smba.trafficmanager.net",
|
|
57
|
+
".smba.trafficmanager.net",
|
|
58
|
+
"botframework.com",
|
|
59
|
+
".botframework.com",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** The effective serviceUrl host allowlist = the global default PLUS any operator-supplied
|
|
63
|
+
* OE_TEAMS_SERVICE_HOSTS (comma-separated; each entry an exact host or a ".suffix"). This is how
|
|
64
|
+
* a sovereign-cloud deployment admits its own Bot Connector host without weakening the default —
|
|
65
|
+
* the reply's bearer token only ever POSTs to a MATCHED host, all Microsoft-owned. */
|
|
66
|
+
function teamsServiceHostAllowlist(): readonly string[] {
|
|
67
|
+
const extra = (process.env.OE_TEAMS_SERVICE_HOSTS ?? "")
|
|
68
|
+
.split(",")
|
|
69
|
+
.map((s) => s.trim().toLowerCase())
|
|
70
|
+
.filter((s) => s.length > 0);
|
|
71
|
+
return extra.length > 0 ? [...DEFAULT_TEAMS_SERVICE_HOSTS, ...extra] : DEFAULT_TEAMS_SERVICE_HOSTS;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const teamsWakeOriginCodec: WakeOriginCodec<TeamsOrigin> = {
|
|
75
|
+
validate: (o) =>
|
|
76
|
+
// The Bot Connector bearer token is POSTed to serviceUrl, so it must be a Microsoft Bot
|
|
77
|
+
// Framework host, not merely https (isTrustedTeamsServiceUrl) — the reply path re-validates
|
|
78
|
+
// regardless, but a bad host must never survive into the map in the first place.
|
|
79
|
+
isTrustedTeamsServiceUrl(o.serviceUrl) &&
|
|
80
|
+
typeof o.conversationId === "string" &&
|
|
81
|
+
o.conversationId.length > 0 &&
|
|
82
|
+
(o.replyToActivityId === undefined || typeof o.replyToActivityId === "string") &&
|
|
83
|
+
(o.tenantId === undefined || typeof o.tenantId === "string"),
|
|
84
|
+
threadRef: (origin) => ({ channelKey: noColon(`teams/${origin.conversationId}`), threadKey: noColon(origin.replyToActivityId ?? origin.conversationId) }),
|
|
85
|
+
// replyToActivityId present ⇔ the trigger sits in a reply chain (the chain IS the thread);
|
|
86
|
+
// absent = a plain conversation send.
|
|
87
|
+
isThreadScoped: (origin) => typeof origin.replyToActivityId === "string" && origin.replyToActivityId.length > 0,
|
|
88
|
+
containerId: (origin) => origin.conversationId,
|
|
89
|
+
// A `dmOrigin` arm lived here (OPE-306 seam, landed OPE-315) to mint a DM delegation anchor's reply
|
|
90
|
+
// origin from the binding's `target` (`serviceUrl` + `conversationId`). OPE-368 deleted the anchor
|
|
91
|
+
// and the interface member with it — a Teams personal/group chat now diverts into its own
|
|
92
|
+
// first-class window (OPE-357), so its origin is minted and STORED at dispatch time like every other
|
|
93
|
+
// wake. NOTHING WEAKENED BY THE REMOVAL: the serviceUrl host allowlist that arm applied at mint is
|
|
94
|
+
// ALSO applied by `validate` above (every stored row) and again by the post arm before any Connector
|
|
95
|
+
// token leaves the process, so `isTrustedTeamsServiceUrl` remains on every route to a token POST.
|
|
96
|
+
//
|
|
97
|
+
// NO `placementRef` AND NO `carryPlacement`, AND THAT ABSENCE IS THE DECLARATION (OPE-1826). Teams
|
|
98
|
+
// keeps its placement INSIDE the conversation identity: `threadRef` keys on `replyToActivityId`,
|
|
99
|
+
// which is the very activity a reply hangs under, so two origins that pass `sameUnifiedConversation`
|
|
100
|
+
// already agree about where the answer lands and there is no gap for a carry to fill. Declaring an
|
|
101
|
+
// axis here would be worse than absent — it would make two origins of ONE thread compare unequal on
|
|
102
|
+
// a field the thread key has already settled. The position is recorded, and held to account against
|
|
103
|
+
// the live registry, by the `origin-placement-carry` row in core's identity/surface-parity.ts.
|
|
104
|
+
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// teams-post-arm — Teams's POST arm for the post-arms registry (OPE-114 stage 1c). Absorbs, VERBATIM,
|
|
2
|
+
// Teams's arms out of the three reply/delivery spines: reply-post.ts (resolveReplyTarget's teams case
|
|
3
|
+
// + dispatchReply's send/reply branch + the teams splitTeamsMessageText chunking used by
|
|
4
|
+
// postTerminalProse), unified-reply.ts (liveTeamsPost, incl. the trusted-serviceUrl re-validation),
|
|
5
|
+
// and unified-redrive.ts (the teams describe/container cases). The transport is eve's own Bot-Framework
|
|
6
|
+
// Connector token (minted from MICROSOFT_APP_ID/PASSWORD) over a HOST-SIDE Connector fetch — see
|
|
7
|
+
// teams-connector.ts's header for why the send/reply/update calls are no longer eve's own
|
|
8
|
+
// `sendTeamsActivity`/`replyToTeamsActivity`/`updateTeamsActivity` (OPE-1237: eve's own transport
|
|
9
|
+
// throws away the response headers, so there is no way to honour a 429's `Retry-After` by wrapping
|
|
10
|
+
// its return value).
|
|
11
|
+
//
|
|
12
|
+
// ONE DELIBERATE DIVERGENCE FROM "VERBATIM" (OPE-315): dispatchTeamsReply now applies the
|
|
13
|
+
// isTrustedTeamsServiceUrl host allowlist its unified sibling always did. That was a real gap, not a
|
|
14
|
+
// stylistic one — see the check's own comment.
|
|
15
|
+
|
|
16
|
+
import { resolveTeamsAppId, splitTeamsMessageText, TEAMS_MESSAGE_TEXT_MAX_LENGTH } from "eve/channels/teams";
|
|
17
|
+
import {
|
|
18
|
+
teamsConnectorWrite,
|
|
19
|
+
sendTeamsActivityBackedOff,
|
|
20
|
+
replyToTeamsActivityBackedOff,
|
|
21
|
+
updateTeamsActivityBackedOff,
|
|
22
|
+
} from "./teams-connector.js";
|
|
23
|
+
import { isTrustedTeamsServiceUrl } from "./teams-origin-codec.js";
|
|
24
|
+
import {
|
|
25
|
+
teamsPersonalConversationRef,
|
|
26
|
+
teamsSoleTenantRegion,
|
|
27
|
+
rememberTeamsPersonalConversation,
|
|
28
|
+
type TeamsConversationRef,
|
|
29
|
+
} from "./teams-conversation-ref.js";
|
|
30
|
+
import { restoreTeamsPersonId } from "./teams-id-restore.js";
|
|
31
|
+
import { markNotApplied, carryFailureSignals } from "@open-everywhere/core/agent/adapter-api.js";
|
|
32
|
+
import type {
|
|
33
|
+
PostArm,
|
|
34
|
+
TargetResolveBag,
|
|
35
|
+
ReplyTarget,
|
|
36
|
+
UnifiedWakeOrigin,
|
|
37
|
+
UnifiedReplyDeps,
|
|
38
|
+
EditLocus,
|
|
39
|
+
DirectMessageRequest,
|
|
40
|
+
DirectMessageDelivery,
|
|
41
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
42
|
+
|
|
43
|
+
/** Exported for `teams-message-ops.ts` (OPE-899) — the SurfaceMessageOps registration reuses this
|
|
44
|
+
* exact shape (serviceUrl + conversationId + an optional reply anchor) rather than inventing a
|
|
45
|
+
* second one; see `dispatchTeamsReply`'s own export note. */
|
|
46
|
+
export type TeamsTarget = Extract<ReplyTarget, { platform: "teams" }>;
|
|
47
|
+
type TeamsOrigin = Extract<UnifiedWakeOrigin, { platform: "teams" }>;
|
|
48
|
+
|
|
49
|
+
/** MID-TURN: a Bot-Framework message activity into the captured conversation reference. eve mints the
|
|
50
|
+
* Connector token from MICROSOFT_APP_ID/PASSWORD; check the id for an honest early error. Thread onto
|
|
51
|
+
* the trigger's root activity when the caller resolved one (a channel/group reply chains via
|
|
52
|
+
* replyToTeamsActivity — eve's exact reply semantics, matching the unified arm and channel.thread.post);
|
|
53
|
+
* a plain conversation send otherwise (personal chats, or no root id). */
|
|
54
|
+
/** Exported (OPE-899) so `teams-message-ops.ts`'s SurfaceMessageOps `postText` can post through the
|
|
55
|
+
* SAME transport this mid-turn reply arm uses, rather than a second copy of the Connector send.
|
|
56
|
+
* That module resolves its own `TeamsTarget` from the `conversation`-grain address store
|
|
57
|
+
* (`teams-conversation-ref.ts`, OPE-899) instead of from a live binding, but the target shape and
|
|
58
|
+
* every refusal below are identical either way. */
|
|
59
|
+
export async function dispatchTeamsReply(target: TeamsTarget, text: string): Promise<string> {
|
|
60
|
+
// Both pre-flight refusals are DEFINITIVE (OPE-821): the call never leaves this host, so nothing
|
|
61
|
+
// reached the room and the post spine may release its dedupe claim for an identical retry.
|
|
62
|
+
if (!process.env.MICROSOFT_APP_ID && !process.env.TEAMS_APP_ID) {
|
|
63
|
+
throw markNotApplied(new Error("reply couldn't post to Teams — MICROSOFT_APP_ID is not set."));
|
|
64
|
+
}
|
|
65
|
+
// 🔴 THE SAME HOST ALLOWLIST THE UNIFIED ARM APPLIES (OPE-315). This path POSTs a client_credentials
|
|
66
|
+
// Connector bearer token to `target.serviceUrl`, and until now it was the ONE serviceUrl route with
|
|
67
|
+
// no host check: the stored-origin path is gated by the codec validator (teams-origin-codec.ts) and
|
|
68
|
+
// re-gated in liveTeamsPost, but a mid-turn target is assembled by resolveTarget from binding
|
|
69
|
+
// attributes with only a non-empty test. Narrow (it takes a rogue Bot Framework channel on the same
|
|
70
|
+
// bot registration to place an attacker host there) but the same class as the unified arm's, and
|
|
71
|
+
// the fix is three lines. Token-free error, mirroring liveTeamsPost's.
|
|
72
|
+
if (!isTrustedTeamsServiceUrl(target.serviceUrl)) {
|
|
73
|
+
throw markNotApplied(new Error(`reply couldn't post to Teams — serviceUrl is not a Bot Framework host (conversation ${target.conversationId})`));
|
|
74
|
+
}
|
|
75
|
+
const res = target.replyToActivityId
|
|
76
|
+
? await replyToTeamsActivityBackedOff({
|
|
77
|
+
serviceUrl: target.serviceUrl,
|
|
78
|
+
conversationId: target.conversationId,
|
|
79
|
+
activityId: target.replyToActivityId,
|
|
80
|
+
body: { type: "message", text },
|
|
81
|
+
})
|
|
82
|
+
: await sendTeamsActivityBackedOff({
|
|
83
|
+
serviceUrl: target.serviceUrl,
|
|
84
|
+
conversationId: target.conversationId,
|
|
85
|
+
body: { type: "message", text },
|
|
86
|
+
});
|
|
87
|
+
return res.id;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** UNIFIED: a Bot-Framework message activity into the frozen conversation reference — the SAME
|
|
91
|
+
* transport + env credentials the mid-turn reply arm uses. Anchored into the reply CHAIN via
|
|
92
|
+
* replyToTeamsActivity when the origin froze a thread-root activity id (teamsThreadRootActivityId),
|
|
93
|
+
* else a plain conversation send. Every target field rides the host-frozen wake origin, never the
|
|
94
|
+
* model. */
|
|
95
|
+
async function liveTeamsPost(origin: TeamsOrigin, text: string): Promise<string> {
|
|
96
|
+
if (!process.env.MICROSOFT_APP_ID && !process.env.TEAMS_APP_ID) {
|
|
97
|
+
// DEFINITIVE, like the mid-turn arm's pre-flights: nothing left this host.
|
|
98
|
+
throw markNotApplied(new Error(`unified reply couldn't post to Teams — MICROSOFT_APP_ID is not set (conversation ${origin.conversationId})`));
|
|
99
|
+
}
|
|
100
|
+
// Belt-and-braces re-validation (isUnifiedWakeOrigin already enforces it): the Connector bearer
|
|
101
|
+
// token is POSTed to this host, so it must be a Microsoft Bot Framework endpoint — a scheme-only
|
|
102
|
+
// https check would let a body-supplied serviceUrl exfiltrate the token.
|
|
103
|
+
if (!isTrustedTeamsServiceUrl(origin.serviceUrl)) {
|
|
104
|
+
throw markNotApplied(new Error(`unified reply couldn't post to Teams — serviceUrl is not a Bot Framework host (conversation ${origin.conversationId})`));
|
|
105
|
+
}
|
|
106
|
+
let id: string;
|
|
107
|
+
try {
|
|
108
|
+
const res = origin.replyToActivityId
|
|
109
|
+
? await replyToTeamsActivityBackedOff({
|
|
110
|
+
serviceUrl: origin.serviceUrl,
|
|
111
|
+
conversationId: origin.conversationId,
|
|
112
|
+
activityId: origin.replyToActivityId,
|
|
113
|
+
body: { type: "message", text },
|
|
114
|
+
})
|
|
115
|
+
: await sendTeamsActivityBackedOff({
|
|
116
|
+
serviceUrl: origin.serviceUrl,
|
|
117
|
+
conversationId: origin.conversationId,
|
|
118
|
+
body: { type: "message", text },
|
|
119
|
+
});
|
|
120
|
+
id = res.id;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Token-free error: never echo the transport error body (defensive parity with the other arms).
|
|
123
|
+
// AMBIGUOUS unless the Connector gave a status to say otherwise (OPE-821) — a send whose answer
|
|
124
|
+
// never arrived may still have reached the room, so the class carries over rather than being
|
|
125
|
+
// dropped by the re-wrap. A number is the only thing carried; no body, no URL, no token.
|
|
126
|
+
throw carryFailureSignals(err, new Error(`unified reply couldn't post to Teams — the Connector post failed (conversation ${origin.conversationId})`));
|
|
127
|
+
}
|
|
128
|
+
return id || "unknown";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* EDIT (OPE-793): rewrite one of our own posted activities via eve's `updateTeamsActivity` (the Bot
|
|
133
|
+
* Framework update-activity call), on the SAME transport and credentials the post arms use. The
|
|
134
|
+
* Teams client shows an "Edited" marker on the activity, so the change is visible in the room.
|
|
135
|
+
*
|
|
136
|
+
* THE SAME TRUSTED-HOST ALLOWLIST BOTH POST ARMS APPLY. This call POSTs a client_credentials
|
|
137
|
+
* Connector bearer token to `locus.serviceUrl`, so it must be a Bot Framework host — the identical
|
|
138
|
+
* check, for the identical reason, and its absence here would be the OPE-315 gap re-opened on a new
|
|
139
|
+
* route. The serviceUrl reaches this arm from the session binding or the frozen wake origin (never
|
|
140
|
+
* model input), and it is re-validated anyway.
|
|
141
|
+
*
|
|
142
|
+
* NO SPLIT, like the Discord arm: one activity is one activity, so text past Teams' own cap is
|
|
143
|
+
* refused rather than truncated. Ownership was decided upstream against the own-post ledger.
|
|
144
|
+
*/
|
|
145
|
+
/** Exported (OPE-899) so `teams-message-ops.ts`'s SurfaceMessageOps `editMessage` reuses this SAME
|
|
146
|
+
* update-activity transport rather than a second copy of it. */
|
|
147
|
+
export async function editTeamsMessage(locus: EditLocus, messageId: string, text: string): Promise<void> {
|
|
148
|
+
// The three pre-flight refusals are DEFINITIVE (OPE-802): the call never leaves this host, so the
|
|
149
|
+
// message cannot have changed and a fresh `reply` is the right remedy. The Connector failure below
|
|
150
|
+
// is not — a failed update may still have applied — so it stays unmarked and fails closed.
|
|
151
|
+
if (!process.env.MICROSOFT_APP_ID && !process.env.TEAMS_APP_ID) {
|
|
152
|
+
throw markNotApplied(new Error("edit_reply couldn't update the Teams message — MICROSOFT_APP_ID is not set."));
|
|
153
|
+
}
|
|
154
|
+
if (!locus.serviceUrl || !isTrustedTeamsServiceUrl(locus.serviceUrl)) {
|
|
155
|
+
throw markNotApplied(
|
|
156
|
+
new Error(`edit_reply couldn't update the Teams message — serviceUrl is not a Bot Framework host (conversation ${locus.channelId})`),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (text.length > TEAMS_MESSAGE_TEXT_MAX_LENGTH) {
|
|
160
|
+
throw markNotApplied(
|
|
161
|
+
new Error(
|
|
162
|
+
`edit_reply couldn't update the Teams message — the new text is ${text.length} characters and Teams caps one message at ${TEAMS_MESSAGE_TEXT_MAX_LENGTH}. An edit cannot split a message; shorten it, or post a new reply.`,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
await updateTeamsActivityBackedOff({
|
|
168
|
+
serviceUrl: locus.serviceUrl,
|
|
169
|
+
conversationId: locus.channelId,
|
|
170
|
+
activityId: messageId,
|
|
171
|
+
body: { type: "message", text },
|
|
172
|
+
});
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// Token-free error, matching both post arms: never echo the transport error body. `err` may now
|
|
175
|
+
// be the NAMED `RateLimitExhaustedError` (OPE-1237) rather than an ordinary Connector throw —
|
|
176
|
+
// `carryFailureSignals` copies its `status` (429, always AMBIGUOUS per platform-failure.ts) onto
|
|
177
|
+
// the wrapped error, the same treatment `liveTeamsPost`'s catch below gives it. That keeps the
|
|
178
|
+
// 429 case failing closed exactly as an unclassified Connector failure already did — this stays
|
|
179
|
+
// an improvement in what a caller reading `.status` can tell, never a behaviour change.
|
|
180
|
+
throw carryFailureSignals(err, new Error(`edit_reply couldn't update the Teams message — the Connector update failed (conversation ${locus.channelId})`));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Teams' own pre-flight: without an app id nothing can mint a Connector token. DEFINITIVE — the
|
|
185
|
+
* call never leaves this host — so the caller may retry an identical send once the id is set. */
|
|
186
|
+
function requireTeamsAppCredential(what: string): void {
|
|
187
|
+
if (!process.env.MICROSOFT_APP_ID && !process.env.TEAMS_APP_ID) {
|
|
188
|
+
throw markNotApplied(new Error(`${what} couldn't reach Teams — MICROSOFT_APP_ID is not set.`));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Open (or re-open) the 1:1 chat between the bot and one person — `POST {serviceUrl}/v3/conversations`
|
|
194
|
+
* with the bot, the member and the tenant.
|
|
195
|
+
*
|
|
196
|
+
* IT IS THE ONLY WAY TO COLD-ADDRESS A TEAMS PERSON, and it is why OPE-887 exists: the Bot Framework
|
|
197
|
+
* has no `conversations.open` keyed on a bare user id, so the deployment must already know a REGION
|
|
198
|
+
* (`serviceUrl`) and a TENANT before it can ask. Both come from the proactive-address table
|
|
199
|
+
* (channels/teams-conversation-ref.ts), captured off real inbound activities.
|
|
200
|
+
*
|
|
201
|
+
* The create is IDEMPOTENT for a one-member, non-group conversation: Microsoft documents it as
|
|
202
|
+
* returning the EXISTING 1:1 chat rather than minting a second one. That is a documented fact and not
|
|
203
|
+
* a measured one — if it turned out to be false, the visible symptom would be a duplicate chat, never
|
|
204
|
+
* a lost message, and the stored reference below means the call happens at most once per person
|
|
205
|
+
* anyway.
|
|
206
|
+
*
|
|
207
|
+
* 🔴 THE REFUSAL THIS ARM MUST STAY HONEST ABOUT: Microsoft refuses the create when the Teams app is
|
|
208
|
+
* not installed for that person in the PERSONAL scope. So a Teams DM arm reaches the people who have
|
|
209
|
+
* the app, and NOT the whole tenant. The error names that, because "the message did not send" without
|
|
210
|
+
* the reason sends a caller looking in the wrong place.
|
|
211
|
+
*/
|
|
212
|
+
async function openTeamsPersonalConversation(region: { readonly serviceUrl: string; readonly tenantId: string }, principalId: string): Promise<string> {
|
|
213
|
+
const appId = await resolveTeamsAppId();
|
|
214
|
+
const res = await teamsConnectorWrite({
|
|
215
|
+
operation: "conversations.create",
|
|
216
|
+
serviceUrl: region.serviceUrl,
|
|
217
|
+
path: "/v3/conversations",
|
|
218
|
+
method: "POST",
|
|
219
|
+
body: {
|
|
220
|
+
isGroup: false,
|
|
221
|
+
bot: { id: `28:${appId}` },
|
|
222
|
+
members: [{ id: principalId }],
|
|
223
|
+
channelData: { tenant: { id: region.tenantId } },
|
|
224
|
+
tenantId: region.tenantId,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
const id = res.ok && res.body && typeof res.body === "object" ? (res.body as { id?: unknown }).id : undefined;
|
|
228
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
229
|
+
// Token-free, and never the Connector's own body: the response can echo request material.
|
|
230
|
+
throw new Error(
|
|
231
|
+
`couldn't open a Teams 1:1 chat with that account (Bot Framework answered ${res.status}). Microsoft refuses this when the Teams app is not installed for that person in the personal scope.`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return id;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* DM (OPE-887): deliver `text` to one person's 1:1 Teams chat. The THIRD provider to implement this
|
|
239
|
+
* member, after Slack and Discord, and the one whose transport needed a durable seam first.
|
|
240
|
+
*
|
|
241
|
+
* WHAT IT COSTS TO GET HERE, in one paragraph. `DirectMessageRequest` names WHO and never WHERE, which
|
|
242
|
+
* is right and which Slack and Discord satisfy from a bot token alone. Teams cannot: a send needs a
|
|
243
|
+
* regional `serviceUrl` and a Microsoft-minted conversation id, and neither is derivable from a person
|
|
244
|
+
* id. So the deployment captures both off inbound activities (channels/teams-conversation-ref.ts) and
|
|
245
|
+
* this arm reads them back. The caller's contract is unchanged.
|
|
246
|
+
*
|
|
247
|
+
* THREE OUTCOMES, and each is a real answer:
|
|
248
|
+
* 1. a 1:1 reference is stored ⇒ one send.
|
|
249
|
+
* 2. no 1:1 reference, but this deployment knows exactly one tenant and its region ⇒ open the chat,
|
|
250
|
+
* remember it, send.
|
|
251
|
+
* 3. neither ⇒ REFUSE, loudly and token-free. That is not a defect to paper over: this deployment
|
|
252
|
+
* genuinely has no address for that person, and a silent no-op would leave a consent ask
|
|
253
|
+
* apparently sent and actually nowhere.
|
|
254
|
+
*
|
|
255
|
+
* `actions` ARE IGNORED, deliberately and per the registry's "undefined = unsupported, never faked":
|
|
256
|
+
* this arm posts a plain message activity, and a Teams button would need an Adaptive Card plus an
|
|
257
|
+
* invoke route to receive the tap. Core's copy always stands on its own without a button
|
|
258
|
+
* ({@link DirectMessageAction}'s own rule), so the text delivered here is complete.
|
|
259
|
+
*/
|
|
260
|
+
async function deliverTeamsDirectMessage(input: DirectMessageRequest): Promise<DirectMessageDelivery> {
|
|
261
|
+
requireTeamsAppCredential("the direct message");
|
|
262
|
+
// 🔴 RESTORE THE RAW `29:` ID ONCE, HERE, BEFORE ANY USE (OPE-1078). A granted `teams:person/<id>`
|
|
263
|
+
// address carries the SURFACE-KEY spelling — the only one the resource-grant grammar admits — and
|
|
264
|
+
// every use below needs the raw Bot Framework id: the stored-reference lookup keys on it, and
|
|
265
|
+
// `openTeamsPersonalConversation` puts it in the Connector's `members` list, where a wrong spelling
|
|
266
|
+
// is a refused create rather than a delivered message. A caller that already holds the raw id (the
|
|
267
|
+
// recipient's own binding) is answered unchanged and pays no store read.
|
|
268
|
+
const principalId = await restoreTeamsPersonId(input.principalId.trim());
|
|
269
|
+
if (principalId.length === 0) throw markNotApplied(new Error("the direct message couldn't reach Teams — no account id was given."));
|
|
270
|
+
|
|
271
|
+
const stored = await teamsPersonalConversationRef(principalId);
|
|
272
|
+
let ref: TeamsConversationRef;
|
|
273
|
+
if (stored) {
|
|
274
|
+
ref = stored;
|
|
275
|
+
} else {
|
|
276
|
+
const region = await teamsSoleTenantRegion();
|
|
277
|
+
if (!region) {
|
|
278
|
+
// DEFINITIVE: nothing left this host. Naming the two ways an address is acquired is what lets an
|
|
279
|
+
// operator act — the alternative is a message that never arrives and no reason why.
|
|
280
|
+
throw markNotApplied(
|
|
281
|
+
new Error(
|
|
282
|
+
"the direct message couldn't reach Teams — this deployment holds no Bot Framework conversation reference for that account, and no single Teams tenant it could open one in. A reference is captured when the person messages the bot, or when the bot sees a channel in exactly one tenant.",
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
ref = { ...region, conversationId: await openTeamsPersonalConversation(region, principalId) };
|
|
287
|
+
// Best-effort: a failed write costs a repeated create next time, never a failed send.
|
|
288
|
+
await rememberTeamsPersonalConversation(principalId, ref);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// READ-TIME allowlist, for the same reason every other Teams write applies one: this POSTs a
|
|
292
|
+
// Connector bearer token to the host. The stored reference was checked on the way in too.
|
|
293
|
+
if (!isTrustedTeamsServiceUrl(ref.serviceUrl)) {
|
|
294
|
+
throw markNotApplied(new Error("the direct message couldn't reach Teams — the stored serviceUrl is not a Bot Framework host."));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Teams caps one activity, so a long notice is split exactly as every other Teams post is; the
|
|
298
|
+
// FIRST part's id is the returned anchor, matching the Discord arm.
|
|
299
|
+
const parts = splitTeamsMessageText(input.text);
|
|
300
|
+
let messageId = "";
|
|
301
|
+
for (const part of parts) {
|
|
302
|
+
const res = await sendTeamsActivityBackedOff({ serviceUrl: ref.serviceUrl, conversationId: ref.conversationId, body: { type: "message", text: part } });
|
|
303
|
+
if (messageId.length === 0) messageId = res.id;
|
|
304
|
+
}
|
|
305
|
+
return { surfaceId: ref.conversationId, messageId: messageId || "unknown" };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export const teamsPostArm: PostArm = {
|
|
309
|
+
platform: "teams",
|
|
310
|
+
resolveTarget: (bag: TargetResolveBag): ReplyTarget | null => {
|
|
311
|
+
// Proactive Teams sends need the Bot-Framework conversation reference (serviceUrl + conversation
|
|
312
|
+
// id) the inbound turn captured — you cannot cold-address a Teams chat.
|
|
313
|
+
const serviceUrl = bag.str("serviceUrl");
|
|
314
|
+
const conversationId = bag.str("conversationId");
|
|
315
|
+
return serviceUrl && conversationId ? { platform: "teams", serviceUrl, conversationId } : null;
|
|
316
|
+
},
|
|
317
|
+
dispatchReply: (target, text) => dispatchTeamsReply(target as TeamsTarget, text),
|
|
318
|
+
dispatchUnified: (origin, text, deps: UnifiedReplyDeps) => (deps.postTeams ?? liveTeamsPost)(origin as TeamsOrigin, text),
|
|
319
|
+
describeRedriveTarget: (origin) => `teams conversation ${(origin as TeamsOrigin).conversationId}`,
|
|
320
|
+
redriveContainerId: (origin) => (origin as TeamsOrigin).conversationId,
|
|
321
|
+
editMessage: editTeamsMessage,
|
|
322
|
+
deliverDirectMessage: deliverTeamsDirectMessage,
|
|
323
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// THE TEAMS REACTION-FEEDBACK ARM (OPE-1502) — a reaction on the agent's own message becomes a
|
|
2
|
+
// durable feedback signal, the same way it already does on Slack and Discord.
|
|
3
|
+
//
|
|
4
|
+
// The inbound half is FREE on this surface: Teams already delivers `messageReaction` activities, and
|
|
5
|
+
// the stop arm (channels/teams-stop.ts) already parses them. What was missing is the second consumer
|
|
6
|
+
// — the one that turns a 👍 or a 👎 on the agent's own reply into a row a person can query. This
|
|
7
|
+
// module is that consumer, and it adds NO second parse and NO second ingress: it takes the activity
|
|
8
|
+
// the stop arm already parsed and hands it core's surface-neutral `ingestReaction`.
|
|
9
|
+
//
|
|
10
|
+
// ── WHAT MICROSOFT DELIVERS, AND WHY IT SHAPES THE GUARDS
|
|
11
|
+
//
|
|
12
|
+
// 1. 🔴 TEAMS SENDS THE EVENT ONLY FOR MESSAGES THE AGENT ITSELF SENT ("a user adds or removes
|
|
13
|
+
// reactions to a message, which was sent by your agent" — learn.microsoft.com, "Conversation
|
|
14
|
+
// events"). That FENCE is what makes the feedback case the common case here rather than the rare
|
|
15
|
+
// one: a reaction on somebody else's message never reaches this arm at all.
|
|
16
|
+
// 2. 🔴 `replyToId` NAMES THAT MESSAGE, AND ITS PRESENCE IS THE WHOLE `onBotReply` TEST — WHICH IS
|
|
17
|
+
// NOT THE CHECK THE OTHER SURFACES MAKE. Slack, Discord and Linear each compare the reacted
|
|
18
|
+
// message's AUTHOR to the bot's own id. Teams delivers no author for the reacted message, so no
|
|
19
|
+
// such comparison exists here: the guarantee is Microsoft's fence (point 1), and the code checks
|
|
20
|
+
// only that a target id is present. An activity carrying `replyToId` for a message the agent did
|
|
21
|
+
// not write would be recorded as feedback, and nothing in this repository could notice. The
|
|
22
|
+
// absence of `replyToId` reads as "no message to name" — telemetry only, never a feedback row.
|
|
23
|
+
// 3. THE REACTION VOCABULARY IS SIX FIXED VALUES: `angry`, `heart`, `laugh`, `like`, `sad`,
|
|
24
|
+
// `surprised`. Core's `classifyEmoji` buckets them; `surprised` is `other`, which is correct —
|
|
25
|
+
// surprise is not a verdict.
|
|
26
|
+
// 4. The activity carries no message content, so nothing here reads or needs any.
|
|
27
|
+
//
|
|
28
|
+
// ── WHY THE STOP REACTION NEVER REACHES HERE
|
|
29
|
+
//
|
|
30
|
+
// `angry` is this surface's stop trigger (channels/teams-stop.ts). ✋ is a MECHANISM, not feedback,
|
|
31
|
+
// and the same rule holds for the glyph that stands in for it: an ADDED `angry` means "stop this" and
|
|
32
|
+
// must never ALSO be recorded as a sentiment about the message it sits on. The stop arm therefore
|
|
33
|
+
// keeps that activity and this arm never sees it. A REMOVED `angry` is not a stop — nothing cancels
|
|
34
|
+
// on a removal — so it arrives here as an ordinary retraction, exactly as a removed ✋ does on Slack.
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
buildIngestedReaction,
|
|
38
|
+
ingestReaction,
|
|
39
|
+
liveReactionSink,
|
|
40
|
+
resolvePartition,
|
|
41
|
+
type ReactionIngestOutcome,
|
|
42
|
+
type ReactionSinkDeps,
|
|
43
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
44
|
+
import { buildTeamsAuth } from "./teams-identity.js";
|
|
45
|
+
import type { TeamsReactionActivity } from "./teams-stop.js";
|
|
46
|
+
|
|
47
|
+
/** Injected seams, so parse + guard + record is unit-tested against stubs with no Bot registration. */
|
|
48
|
+
export interface TeamsReactionFeedbackDeps {
|
|
49
|
+
readonly sink: ReactionSinkDeps;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Production deps: the live audit/telemetry sink core already owns. There is no Teams-specific
|
|
53
|
+
* sink — the whole point of the seam is that the durable row is written in ONE place. */
|
|
54
|
+
export function liveTeamsReactionFeedbackDeps(): TeamsReactionFeedbackDeps {
|
|
55
|
+
return { sink: liveReactionSink() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The Bot Framework's own prefix for an application id. A reactor wearing it is a bot whatever the
|
|
59
|
+
* `recipient` field says — the same belt-and-braces check the stop arm makes. */
|
|
60
|
+
function teamsReactorIsMachine(rx: TeamsReactionActivity): boolean {
|
|
61
|
+
return rx.reactorId === rx.agentId || rx.reactorId?.startsWith("28:") === true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Ingest every reaction one Teams `messageReaction` activity carries. Returns one outcome per
|
|
66
|
+
* reaction — added first, then removed — so a caller's log says what happened to each.
|
|
67
|
+
*
|
|
68
|
+
* It NEVER THROWS into the caller: core's sinks are best-effort by contract, and a lost feedback row
|
|
69
|
+
* must never cost the ingress its ack.
|
|
70
|
+
*
|
|
71
|
+
* An activity with nothing to ingest returns an EMPTY array. That is the honest answer for a bot's
|
|
72
|
+
* own reaction, an activity with no reactor, and an activity naming no conversation — none of the
|
|
73
|
+
* three is a reaction from a person on a message this deployment can key.
|
|
74
|
+
*/
|
|
75
|
+
export async function routeTeamsReactionFeedback(
|
|
76
|
+
rx: TeamsReactionActivity,
|
|
77
|
+
deps: TeamsReactionFeedbackDeps,
|
|
78
|
+
): Promise<readonly ReactionIngestOutcome[]> {
|
|
79
|
+
// The agent's own reactions ride the same activity stream and are never feedback. 🔴 THE `28:`
|
|
80
|
+
// PREFIX IS THE LOAD-BEARING HALF of this guard, not the `recipient` comparison: with an
|
|
81
|
+
// unresolved agent id the prefix is the only thing left, and — unlike the ingest paths on Slack and
|
|
82
|
+
// Discord, where a missed bot reactor costs a stray telemetry row — a missed one HERE costs a
|
|
83
|
+
// durable feedback row attributed to a machine, because `onBotReply` rests on a present
|
|
84
|
+
// `replyToId` rather than on an author comparison (header, point 2).
|
|
85
|
+
if (!rx.reactorId || teamsReactorIsMachine(rx)) return [];
|
|
86
|
+
if (!rx.conversationId) return [];
|
|
87
|
+
|
|
88
|
+
const partition = resolvePartition({
|
|
89
|
+
channel: { kind: "teams" },
|
|
90
|
+
session: {
|
|
91
|
+
auth: {
|
|
92
|
+
current: buildTeamsAuth({
|
|
93
|
+
userId: rx.reactorId,
|
|
94
|
+
conversationId: rx.conversationId,
|
|
95
|
+
...(rx.tenantId ? { tenantId: rx.tenantId } : {}),
|
|
96
|
+
...(rx.teamsChannelId ? { channelId: rx.teamsChannelId } : {}),
|
|
97
|
+
...(rx.teamId ? { teamId: rx.teamId } : {}),
|
|
98
|
+
...(rx.scope ? { scope: rx.scope } : {}),
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// A PRESENCE CHECK, not an author comparison — the header's point 2 says why this surface offers
|
|
105
|
+
// no other, and what that costs. No `replyToId` means no message to name, so the reaction is
|
|
106
|
+
// telemetry only.
|
|
107
|
+
const onBotReply = Boolean(rx.replyToId);
|
|
108
|
+
const outcomes: ReactionIngestOutcome[] = [];
|
|
109
|
+
for (const [verb, emojis] of [
|
|
110
|
+
["added", rx.added],
|
|
111
|
+
["removed", rx.removed],
|
|
112
|
+
] as const) {
|
|
113
|
+
for (const emoji of emojis) {
|
|
114
|
+
const reaction = buildIngestedReaction({
|
|
115
|
+
surface: "teams",
|
|
116
|
+
verb,
|
|
117
|
+
reactorId: rx.reactorId,
|
|
118
|
+
emoji,
|
|
119
|
+
target: { channelId: rx.conversationId, messageId: rx.replyToId ?? "" },
|
|
120
|
+
onBotReply,
|
|
121
|
+
partition,
|
|
122
|
+
});
|
|
123
|
+
outcomes.push(await ingestReaction(reaction, deps.sink));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return outcomes;
|
|
127
|
+
}
|