@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,244 @@
|
|
|
1
|
+
// teams-catch-up — Teams' registered SEAT CATCH-UP FETCH (OPE-1726): the seat's own channel (or reply
|
|
2
|
+
// chain) since its last wake, normalized for core's shared catch-up path
|
|
3
|
+
// (channels/seat-catch-up.ts).
|
|
4
|
+
//
|
|
5
|
+
// 🔴 THIS IS THE SURFACE WHOSE OWN HEADER SAID IT COULD NOT DO THIS, and the correction is worth
|
|
6
|
+
// stating: `teams-supervisor.ts` records "NO PLATFORM-FETCH backscroll", which is true of the BOT
|
|
7
|
+
// CONNECTOR — the transport a Teams activity arrives on carries no history API at all. Teams reads
|
|
8
|
+
// its history over MICROSOFT GRAPH instead, which is a different credential and a different
|
|
9
|
+
// transport, and `read_channel` has used it since the read adapter landed. The catch-up uses the same
|
|
10
|
+
// one, so the seat reads exactly what a person asking it to `read_channel` would.
|
|
11
|
+
//
|
|
12
|
+
// WHAT "THE SEAT'S CONTAINER" IS HERE. Graph addresses a channel read as
|
|
13
|
+
// `/teams/{team}/channels/{channel}/messages`, so BOTH ids are needed and neither is derivable from
|
|
14
|
+
// the other. The request's `container` therefore carries them joined as `<teamId>/<channelId>` —
|
|
15
|
+
// {@link parseTeamsCatchUpContainer} is the one place that spelling is written down. `threadRoot`
|
|
16
|
+
// present ⇒ this wake sits in a reply chain, and the read ADDS that chain's replies to the channel's
|
|
17
|
+
// own top-level messages — Graph serves the two from different endpoints and neither holds the other.
|
|
18
|
+
//
|
|
19
|
+
// 🔴 THAT `<teamId>` IS THE MICROSOFT 365 GROUP ID, NOT THE ONE THE ACTIVITY CARRIES. An inbound
|
|
20
|
+
// activity's `channelData.team.id` is the BOT CONNECTOR's team id (`19:…@thread.tacv2`), which Graph
|
|
21
|
+
// does not address — it keeps that spelling as a team's `internalId`. `channels/teams-graph-team-id.ts`
|
|
22
|
+
// resolves the one into the other before the wake is dispatched (OPE-1808). Handing the raw activity
|
|
23
|
+
// id to the URLs below is a 404, and the catch below turns a 404 into an empty window — which is why
|
|
24
|
+
// the audit row this read writes states the status, and not only the count.
|
|
25
|
+
//
|
|
26
|
+
// 🔴 THE VENDOR CANNOT FILTER BY TIME on these endpoints (Graph channel messages support no
|
|
27
|
+
// `$filter` on `createdDateTime` and no `$orderby`), so the bound is applied on this side and again
|
|
28
|
+
// by the shared path.
|
|
29
|
+
//
|
|
30
|
+
// FAIL-SOFT PER RESOURCE: one Graph endpoint refusing does not erase messages returned by the other.
|
|
31
|
+
// If every requested endpoint fails, the fetch propagates to core's shared fail-soft boundary, which
|
|
32
|
+
// keeps the wake alive and records `seat.catch_up outcome=failed` (OPE-1842).
|
|
33
|
+
//
|
|
34
|
+
// 🔴 FAIL-SOFT WITHOUT A ROW IS A BLIND SPOT, AND OPE-1817 IS WHAT IT COSTS. Every outcome below —
|
|
35
|
+
// a 404, a 200 with an empty page, a page the since-bound excluded, an unparsable container — used
|
|
36
|
+
// to reach the seat as the SAME empty list, so a red on a live ship could not say which one
|
|
37
|
+
// happened. {@link TEAMS_CATCH_UP_ACTION} now records one row per GRAPH CALL: the resource, the
|
|
38
|
+
// status, the RAW Graph count and the KEPT count. `raw=0` is Graph answering nothing; `raw>0,
|
|
39
|
+
// kept=0` is this side's bound or visibility gate. NO MESSAGE BODIES AND NO AUTHOR NAMES go in the
|
|
40
|
+
// row — the ids it carries are the same addressing ids `teams.dispatched` already records.
|
|
41
|
+
|
|
42
|
+
import { channelMessagesUrl, fetchGraphPages, liveTeamsGraphRequest, teamsTimedLinesFromRaw, threadRepliesUrl, type TeamsGraphRequester, type TeamsRenderedLine } from "./teams-read.js";
|
|
43
|
+
import { appendAudit, catchUpSender, deploymentOrg, type AuditInput, type NormalizedMessage, type SeatCatchUpFetch } from "@open-everywhere/core/agent/adapter-api.js";
|
|
44
|
+
|
|
45
|
+
/** One page's worth. The catch-up window is "since the seat last woke", and a channel that collected
|
|
46
|
+
* more than this in that window is one core's token cap would trim regardless. */
|
|
47
|
+
const CATCH_UP_LIMIT = 100;
|
|
48
|
+
|
|
49
|
+
/** Split the `<teamId>/<channelId>` container this surface's catch-up is addressed by. Returns
|
|
50
|
+
* undefined for anything else rather than guessing a half — a partial address would read another
|
|
51
|
+
* team's channel, the silent mis-address the wake-container design refuses. */
|
|
52
|
+
export function parseTeamsCatchUpContainer(container: string): { teamId: string; channelId: string } | undefined {
|
|
53
|
+
const slash = container.indexOf("/");
|
|
54
|
+
if (slash <= 0) return undefined;
|
|
55
|
+
const teamId = container.slice(0, slash);
|
|
56
|
+
const channelId = container.slice(slash + 1);
|
|
57
|
+
if (!teamId || !channelId) return undefined;
|
|
58
|
+
return { teamId, channelId };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Normalize ONE rendered Graph line, applying the VISIBILITY GATE every fetch adapter owes core: a
|
|
63
|
+
* line with no text, no message id, no readable create time or NO ATTRIBUTABLE AUTHOR is dropped.
|
|
64
|
+
* 🔴 THE AUTHOR ARM IS THE LOAD-BEARING ONE. `teams-read.ts toIntermediate` produces three shapes —
|
|
65
|
+
* a person (`authorId` set), an application (`bot` set), and NEITHER, which is a message Graph gave
|
|
66
|
+
* no `from` for (a removed account, a tenant-side redaction). Without this arm that third shape would
|
|
67
|
+
* reach {@link catchUpSender} with no bot signal and enter the seat as an unattributed PERSON, which
|
|
68
|
+
* is exactly the body a seat must never be handed — the gate is what makes the shared rule's
|
|
69
|
+
* "an absent signal is a person" safe on this surface. An APPLICATION author (`bot`) is KEPT and tagged
|
|
70
|
+
* — attributable machine awareness the domain's admission policy then accepts or refuses — and the
|
|
71
|
+
* deployment's own bot id resolves `self`.
|
|
72
|
+
*/
|
|
73
|
+
function normalizeLine(
|
|
74
|
+
l: TeamsRenderedLine & { readonly createdMs: number },
|
|
75
|
+
selfActorId: string | undefined,
|
|
76
|
+
threadRoot: string | undefined,
|
|
77
|
+
): NormalizedMessage | undefined {
|
|
78
|
+
const text = l.text.trim();
|
|
79
|
+
if (text.length === 0 || l.createdMs <= 0 || l.ts.length === 0) return undefined;
|
|
80
|
+
if (l.authorId === undefined && l.bot !== true) return undefined; // unattributable sender ⇒ fail closed
|
|
81
|
+
const isSelf = selfActorId !== undefined && l.authorId === selfActorId;
|
|
82
|
+
return {
|
|
83
|
+
// 🔴 `ts` ON A RENDERED TEAMS LINE IS THE MESSAGE ID, not a timestamp (teams-read.ts
|
|
84
|
+
// `toIntermediate` puts `m.id` there and keeps the time on `createdMs`). So the id slot takes it
|
|
85
|
+
// directly and the time comes from the timed variant — reading `ts` as a date is the mistake this
|
|
86
|
+
// comment exists to stop.
|
|
87
|
+
id: l.ts,
|
|
88
|
+
ms: l.createdMs,
|
|
89
|
+
// The bot's OWN posts carry no author id, matching every other surface's normalized shape.
|
|
90
|
+
...(isSelf || l.authorId === undefined ? {} : { authorId: l.authorId }),
|
|
91
|
+
// THE SHARED SENDER RULE (OPE-1857): this surface contributes the vendor's own signal — the
|
|
92
|
+
// rendered line's `bot` bit, which Graph sets from the message's `from` identity set — and core
|
|
93
|
+
// decides what it means.
|
|
94
|
+
sender: catchUpSender({ self: isSelf, bot: l.bot }),
|
|
95
|
+
// A reply chain's messages are thread-scoped; a channel read is top-level.
|
|
96
|
+
...(threadRoot ? { type: "thread" as const, threadId: threadRoot } : { type: "channel" as const }),
|
|
97
|
+
text,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Injected seam so the fetch drives offline in tests; defaults to the live Graph transport. */
|
|
102
|
+
export interface TeamsCatchUpDeps {
|
|
103
|
+
readonly request: TeamsGraphRequester;
|
|
104
|
+
/** The deployment's own Entra object id, so its prior posts read as `self`. Absent ⇒ they arrive
|
|
105
|
+
* tagged `bot` (an application author), which is visible and honest, never a crash. */
|
|
106
|
+
readonly selfActorId?: string;
|
|
107
|
+
/** The audit writer. Injected so the contract test reads the row this fetch emits instead of a
|
|
108
|
+
* store; live callers get {@link appendAudit}, which never throws by its own contract. */
|
|
109
|
+
readonly audit?: (entry: AuditInput) => Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const liveDeps: TeamsCatchUpDeps = { request: liveTeamsGraphRequest };
|
|
113
|
+
|
|
114
|
+
/** The audit action this read records under. One family, one spelling — a query for the next red
|
|
115
|
+
* ship is `action = "teams.catch_up"` and nothing else. */
|
|
116
|
+
export const TEAMS_CATCH_UP_ACTION = "teams.catch_up";
|
|
117
|
+
|
|
118
|
+
/** The `<platform>/<workspace>/<surface>` domain-grain key the shared path hands down, split for the
|
|
119
|
+
* audit row's own WHERE columns. Returns undefined for an absent key or a non-Teams one rather than
|
|
120
|
+
* guessing a workspace — the row then says `unknown`, which is a fact, not a filled-in blank.
|
|
121
|
+
* Pure; exported so the contract test pins the parse rather than re-deriving it. */
|
|
122
|
+
export function teamsCatchUpScopeFromChannelKey(channelKey: string | undefined): { workspace: string; surface: string } | undefined {
|
|
123
|
+
if (!channelKey) return undefined;
|
|
124
|
+
const parts = channelKey.split("/");
|
|
125
|
+
if (parts.length !== 3 || parts[0] !== "teams" || !parts[1] || !parts[2]) return undefined;
|
|
126
|
+
return { workspace: parts[1], surface: parts[2] };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The HTTP status inside the requester's own thrown message (`… (HTTP 404) for <url> — <body>`), or
|
|
130
|
+
* undefined when the failure carried none (a transport throw, an abort). ONLY the three digits are
|
|
131
|
+
* read: the message also carries a slice of the vendor's response body, which never enters a row. */
|
|
132
|
+
function httpStatusOf(err: unknown): number | undefined {
|
|
133
|
+
const m = /\(HTTP (\d{3})\)/.exec(err instanceof Error ? err.message : String(err));
|
|
134
|
+
return m ? Number(m[1]) : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The Graph resource path, host and query stripped — what the row names so the next red ship can
|
|
138
|
+
* see WHICH call this read made without reconstructing it from the ids. */
|
|
139
|
+
function graphPathOf(url: string): string {
|
|
140
|
+
const q = url.indexOf("?");
|
|
141
|
+
const bare = q < 0 ? url : url.slice(0, q);
|
|
142
|
+
const base = bare.indexOf("/v1.0/");
|
|
143
|
+
return base < 0 ? bare : bare.slice(base + "/v1.0".length);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The fetch, with its seams exposed so the contract test drives it with no network. */
|
|
147
|
+
export async function fetchTeamsCatchUpWindow(
|
|
148
|
+
req: { container: string; threadRoot?: string; sinceMs: number; channelKey?: string },
|
|
149
|
+
deps: TeamsCatchUpDeps = liveDeps,
|
|
150
|
+
): Promise<readonly NormalizedMessage[]> {
|
|
151
|
+
const scope = teamsCatchUpScopeFromChannelKey(req.channelKey);
|
|
152
|
+
const record = async (detail: Readonly<Record<string, unknown>>): Promise<void> => {
|
|
153
|
+
try {
|
|
154
|
+
await (deps.audit ?? appendAudit)({
|
|
155
|
+
org: deploymentOrg(),
|
|
156
|
+
platform: "teams",
|
|
157
|
+
workspace: scope?.workspace ?? "unknown",
|
|
158
|
+
...(scope ? { surface: scope.surface } : {}),
|
|
159
|
+
action: TEAMS_CATCH_UP_ACTION,
|
|
160
|
+
detail: { ...detail, since: new Date(req.sinceMs).toISOString() },
|
|
161
|
+
});
|
|
162
|
+
} catch {
|
|
163
|
+
/* the row is diagnostics; losing it must not cost the seat its catch-up */
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const target = parseTeamsCatchUpContainer(req.container);
|
|
168
|
+
if (!target) {
|
|
169
|
+
// The ONE outcome that never reaches Graph: a container this surface cannot split into the two
|
|
170
|
+
// ids every Graph URL needs. Recorded so it reads differently from an empty room.
|
|
171
|
+
await record({ status: "unaddressable", resource: "none", raw: 0, kept: 0 });
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 🔴 TWO CALLS, NOT A CHOICE BETWEEN THEM (OPE-1817). Graph splits one channel's history across two
|
|
176
|
+
// endpoints and neither is a superset of the other:
|
|
177
|
+
// • `/channels/{c}/messages` returns the CHAIN ROOTS only — a reply is never in it;
|
|
178
|
+
// • `/channels/{c}/messages/{root}/replies` returns that ONE chain's replies, and not the root.
|
|
179
|
+
// The seat is CHANNEL-grain (its session hangs off the domain key), while the wake it is answering
|
|
180
|
+
// sits in ONE reply chain. So both halves are the seat's own window, the two answers never overlap,
|
|
181
|
+
// and core's shared path orders, bounds and caps the union (channels/seat-catch-up.ts). Reading only
|
|
182
|
+
// the channel is what ship #206 and #207 measured: a chain that HAD carried the message read empty.
|
|
183
|
+
// Reading only the chain would drop every top-level message the channel collected — the same defect
|
|
184
|
+
// pointed the other way.
|
|
185
|
+
const reads: Array<{ readonly resource: "channel-messages" | "thread-replies"; readonly url: string; readonly threadRoot?: string }> = [
|
|
186
|
+
{ resource: "channel-messages", url: channelMessagesUrl(target.teamId, target.channelId) },
|
|
187
|
+
...(req.threadRoot ? [{ resource: "thread-replies" as const, url: threadRepliesUrl(target.teamId, target.channelId, req.threadRoot), threadRoot: req.threadRoot }] : []),
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
const out: NormalizedMessage[] = [];
|
|
191
|
+
let failedReads = 0;
|
|
192
|
+
for (const read of reads) {
|
|
193
|
+
// Fail-soft PER READ, and it covers the normalization too: one endpoint refusing — or one
|
|
194
|
+
// malformed page — must not throw away what the other read returned. When every read fails the
|
|
195
|
+
// shared boundary below this adapter owns the wake-level fail-soft behavior.
|
|
196
|
+
try {
|
|
197
|
+
const raw = await fetchGraphPages(deps.request, read.url, CATCH_UP_LIMIT);
|
|
198
|
+
let kept = 0;
|
|
199
|
+
for (const line of teamsTimedLinesFromRaw(raw)) {
|
|
200
|
+
const n = normalizeLine(line, deps.selfActorId, read.threadRoot);
|
|
201
|
+
if (!n || n.ms < req.sinceMs) continue;
|
|
202
|
+
out.push(n);
|
|
203
|
+
kept++;
|
|
204
|
+
}
|
|
205
|
+
// `raw` is what GRAPH returned; `kept` is what survived the visibility gate and the since-bound.
|
|
206
|
+
// The two together are the whole diagnosis: raw 0 is the vendor's answer, raw>0 with kept 0 is ours.
|
|
207
|
+
await record({ status: "ok", resource: read.resource, path: graphPathOf(read.url), raw: raw.length, kept });
|
|
208
|
+
} catch (err) {
|
|
209
|
+
failedReads++;
|
|
210
|
+
const status = httpStatusOf(err);
|
|
211
|
+
await record({ status: "error", resource: read.resource, path: graphPathOf(read.url), raw: 0, kept: 0, ...(status === undefined ? {} : { httpStatus: status }) });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Preserve the two-endpoint partial-read contract: one refusing endpoint must not erase what the
|
|
215
|
+
// other returned. When EVERY attempted read failed, however, propagate to the shared boundary so
|
|
216
|
+
// its seat.catch_up row says `failed` rather than falsely describing the channel as quiet.
|
|
217
|
+
if (failedReads === reads.length) throw new Error("Teams catch-up: every Graph read failed");
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A Graph message's `createdDateTime` carries MILLISECOND grain (OPE-1847,
|
|
223
|
+
* `core/agent/channels/seat-catch-up.ts` {@link SeatCatchUpDeclaration.timestampPrecisionMs}).
|
|
224
|
+
* 🔴 ONE MILLISECOND IS NOT ZERO WIDENING. The road widens the LOWER bound by nothing here and the
|
|
225
|
+
* UPPER bound by exactly one millisecond, so a message stamped at the trigger's own instant is
|
|
226
|
+
* INSIDE the window and `excludeId` is the only thing that removes the trigger. A rendered Teams
|
|
227
|
+
* line carries the Graph message id in its `ts` slot, which is the value the wake path passes as
|
|
228
|
+
* `excludeId`.
|
|
229
|
+
* 🔴 IT IS STILL DECLARED, and the parity test requires it. The declaration is what makes "this
|
|
230
|
+
* surface needs the finest unit" a CHECKED claim rather than an omission, and an omission is
|
|
231
|
+
* indistinguishable from the GitHub and Notion defect this whole mechanism exists to close.
|
|
232
|
+
*/
|
|
233
|
+
export const TEAMS_TIMESTAMP_PRECISION_MS = 1;
|
|
234
|
+
|
|
235
|
+
/** Teams' registered {@link SeatCatchUpFetch} — the one thing this surface contributes to the shared path. */
|
|
236
|
+
export const teamsSeatCatchUpFetch: SeatCatchUpFetch = (req) =>
|
|
237
|
+
fetchTeamsCatchUpWindow({
|
|
238
|
+
container: req.container,
|
|
239
|
+
...(req.threadRoot ? { threadRoot: req.threadRoot } : {}),
|
|
240
|
+
sinceMs: req.sinceMs,
|
|
241
|
+
// The domain-grain key the row is filed under. Dropped before OPE-1817, which left every row
|
|
242
|
+
// this read writes unattributable to a surface.
|
|
243
|
+
...(req.channelKey ? { channelKey: req.channelKey } : {}),
|
|
244
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// teams-connector — the ONE place a Teams Bot Framework Connector write leaves this host (OPE-1237).
|
|
2
|
+
//
|
|
3
|
+
// BEFORE THIS, every Teams write (`dispatchTeamsReply`, `liveTeamsPost`, `editTeamsMessage`,
|
|
4
|
+
// `openTeamsPersonalConversation`, `deliverTeamsDirectMessage` in teams-post-arm.ts) called eve's own
|
|
5
|
+
// `sendTeamsActivity`/`replyToTeamsActivity`/`updateTeamsActivity`/`callTeamsConnectorApi`
|
|
6
|
+
// (`eve/channels/teams`) directly. eve's `callTeamsConnectorApi` is a bare `fetch` with no 429
|
|
7
|
+
// handling, and — unlike GitHub's raw fetch, which this repo already owned before OPE-1235 —
|
|
8
|
+
// it also THROWS AWAY the response headers before returning
|
|
9
|
+
// (`node_modules/eve/dist/src/public/channels/teams/api.js`: `{body, ok, status}`, no `headers`), so
|
|
10
|
+
// there was no way to read a `Retry-After` even by wrapping eve's own return value. Honouring it
|
|
11
|
+
// means doing the Connector fetch host-side, the same move OPE-1235 made for GitHub's write seam
|
|
12
|
+
// (providers/github/agent/channels/github-post-arm.ts's `githubWrite`) and OPE-1211 made for Slack's
|
|
13
|
+
// (providers/slack/agent/channels/slack-web-api.ts).
|
|
14
|
+
//
|
|
15
|
+
// TOKEN RESOLUTION STAYS eve's: `resolveTeamsAccessToken` (the client-credentials OAuth flow, with
|
|
16
|
+
// eve's own in-process cache and refresh-safety margin) is reused UNCHANGED. Reimplementing that flow
|
|
17
|
+
// host-side would duplicate real behaviour for no backoff benefit — this ticket's claim is the
|
|
18
|
+
// CONNECTOR request (one per write, where a 429 costs a lost reply), not the OAuth token mint (held
|
|
19
|
+
// warm for its whole life, the same "low exposure, different ticket" shape OPE-1288 states for
|
|
20
|
+
// Linear's mint).
|
|
21
|
+
//
|
|
22
|
+
// WHAT IT DOES, and only this — mirrors github-post-arm.ts's `githubWrite`:
|
|
23
|
+
// • Builds the request — the Connector base (serviceUrl), the path, the bearer token, a JSON body.
|
|
24
|
+
// • Honours a 429's `Retry-After` through `channels/rate-limit-retry.ts`'s `withRetryAfter`,
|
|
25
|
+
// bounded in attempts, in one wait, and in total wait.
|
|
26
|
+
// • Ends an outlasted 429 in the NAMED `RateLimitExhaustedError`.
|
|
27
|
+
// • Parses the response body the same way eve's own `parseResponseBody` does (empty → null,
|
|
28
|
+
// JSON → parsed, otherwise the raw text) so every call site's `res.body` reads exactly as it did
|
|
29
|
+
// when eve returned it.
|
|
30
|
+
|
|
31
|
+
import { resolveTeamsAccessToken } from "eve/channels/teams";
|
|
32
|
+
import { withRetryAfter, type RateLimitRetryOptions } from "@open-everywhere/core/agent/adapter-api.js";
|
|
33
|
+
|
|
34
|
+
/** The subset of the global `fetch` this seam uses — injectable so a test drives it without Teams. */
|
|
35
|
+
export type TeamsFetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
36
|
+
|
|
37
|
+
/** Transport and retry seams. A test injects them; live code passes none and takes the shared
|
|
38
|
+
* defaults from `channels/rate-limit-retry.ts`. */
|
|
39
|
+
export interface TeamsConnectorDeps {
|
|
40
|
+
readonly fetch?: TeamsFetchLike;
|
|
41
|
+
readonly retry?: Pick<RateLimitRetryOptions, "maxAttempts" | "capMs" | "fallbackMs" | "totalBudgetMs" | "sleep" | "now">;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Mirrors eve's `TeamsApiResponse` — the shape every write call site in teams-post-arm.ts reads. */
|
|
45
|
+
export interface TeamsConnectorResult {
|
|
46
|
+
readonly ok: boolean;
|
|
47
|
+
readonly status: number;
|
|
48
|
+
readonly body: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function trimTrailingSlash(url: string): string {
|
|
52
|
+
return url.replace(/\/+$/, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Mirrors eve's `parseResponseBody`: empty body → null, JSON → parsed, otherwise the raw text. */
|
|
56
|
+
async function parseTeamsResponseBody(res: Response): Promise<unknown> {
|
|
57
|
+
const text = await res.text();
|
|
58
|
+
if (!text) return null;
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(text);
|
|
61
|
+
} catch {
|
|
62
|
+
return text;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Issue one Bot Framework Connector REST call under a client-credentials bearer token, with the 429
|
|
68
|
+
* backoff applied.
|
|
69
|
+
*
|
|
70
|
+
* `operation` is the vendor route name `RateLimitExhaustedError` reports on exhaustion — never the
|
|
71
|
+
* resolved path, which would put a conversation id into a failure that promises every field on it is
|
|
72
|
+
* safe to log (the same discipline `githubWrite`'s `operation` follows).
|
|
73
|
+
*/
|
|
74
|
+
export async function teamsConnectorWrite(
|
|
75
|
+
input: {
|
|
76
|
+
readonly operation: string;
|
|
77
|
+
readonly serviceUrl: string;
|
|
78
|
+
readonly path: string;
|
|
79
|
+
readonly method?: "GET" | "POST" | "PUT" | "DELETE";
|
|
80
|
+
readonly body?: unknown;
|
|
81
|
+
},
|
|
82
|
+
deps: TeamsConnectorDeps = {},
|
|
83
|
+
): Promise<TeamsConnectorResult> {
|
|
84
|
+
const token = await resolveTeamsAccessToken();
|
|
85
|
+
const send = (): Promise<Response> =>
|
|
86
|
+
(deps.fetch ?? (globalThis.fetch as TeamsFetchLike))(`${trimTrailingSlash(input.serviceUrl)}${input.path}`, {
|
|
87
|
+
method: input.method ?? "POST",
|
|
88
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json; charset=utf-8" },
|
|
89
|
+
...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}),
|
|
90
|
+
});
|
|
91
|
+
const res = await withRetryAfter(send, { ...deps.retry, surface: "teams", operation: input.operation });
|
|
92
|
+
return { ok: res.ok, status: res.status, body: await parseTeamsResponseBody(res) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Narrows eve's `toPostedActivity` (`{id, raw}`) to just `{id}` — the ONLY field any call site in
|
|
96
|
+
* this package reads (`rg '\.raw'` over the teams package: zero hits). An activity id is always a
|
|
97
|
+
* string when present; anything else (including a missing field) reports the empty string,
|
|
98
|
+
* matching eve's own fallback. */
|
|
99
|
+
function toPostedTeamsActivity(body: unknown): { id: string } {
|
|
100
|
+
const id = body !== null && typeof body === "object" && typeof (body as { id?: unknown }).id === "string" ? (body as { id: string }).id : "";
|
|
101
|
+
return { id };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Mirrors eve's `formatConnectorErrorBody` — a bounded, safe-to-log rendering of a Connector error
|
|
105
|
+
* body for `updateTeamsActivityBackedOff`'s error message. */
|
|
106
|
+
function formatTeamsConnectorErrorBody(body: unknown): string {
|
|
107
|
+
const text = typeof body === "string" ? body : JSON.stringify(body);
|
|
108
|
+
if (text === undefined || text.length === 0) return "empty response body";
|
|
109
|
+
return text.length <= 1000 ? text : `${text.slice(0, 997)}...`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- HOST-SIDE EQUIVALENTS OF eve's sendTeamsActivity/replyToTeamsActivity/updateTeamsActivity ---
|
|
113
|
+
//
|
|
114
|
+
// Every write call site in this package (teams-post-arm.ts, teams-front.ts's fail-soft notice)
|
|
115
|
+
// dispatches through one of these instead of eve's own transport — see this file's header for why.
|
|
116
|
+
// The URL template, HTTP method and every thrown error string below match eve's own implementation
|
|
117
|
+
// exactly (verified against `eve/dist/src/public/channels/teams/api.js`), so this is a backoff
|
|
118
|
+
// added, not a behaviour changed for any call site that exists today. One divergence, currently
|
|
119
|
+
// inert: eve serializes a write body through `JSON.stringify(parseJsonObject(body))`, which THROWS
|
|
120
|
+
// for a non-plain-object/non-JSON-serializable body; `teamsConnectorWrite` (above) does a bare
|
|
121
|
+
// `JSON.stringify(body)`. Every call site in this repo passes a plain `{type, text, ...}` literal, so
|
|
122
|
+
// the two are equivalent today — but a future caller passing something `parseJsonObject` would
|
|
123
|
+
// reject (a class instance, a `Map`) would silently behave differently here than it would have under
|
|
124
|
+
// eve.
|
|
125
|
+
|
|
126
|
+
/** Mirrors eve's `sendTeamsActivity` — `POST /v3/conversations/{id}/activities`. */
|
|
127
|
+
export async function sendTeamsActivityBackedOff(
|
|
128
|
+
input: { readonly serviceUrl: string; readonly conversationId: string; readonly body: unknown },
|
|
129
|
+
deps: TeamsConnectorDeps = {},
|
|
130
|
+
): Promise<{ id: string }> {
|
|
131
|
+
const res = await teamsConnectorWrite(
|
|
132
|
+
{
|
|
133
|
+
operation: "conversations.activities.send",
|
|
134
|
+
serviceUrl: input.serviceUrl,
|
|
135
|
+
path: `/v3/conversations/${encodeURIComponent(input.conversationId)}/activities`,
|
|
136
|
+
body: input.body,
|
|
137
|
+
},
|
|
138
|
+
deps,
|
|
139
|
+
);
|
|
140
|
+
if (!res.ok) throw new Error(`Teams send activity failed with HTTP ${res.status}.`);
|
|
141
|
+
return toPostedTeamsActivity(res.body);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Mirrors eve's `replyToTeamsActivity` — `POST /v3/conversations/{id}/activities/{activityId}`. */
|
|
145
|
+
export async function replyToTeamsActivityBackedOff(
|
|
146
|
+
input: { readonly serviceUrl: string; readonly conversationId: string; readonly activityId: string; readonly body: unknown },
|
|
147
|
+
deps: TeamsConnectorDeps = {},
|
|
148
|
+
): Promise<{ id: string }> {
|
|
149
|
+
const res = await teamsConnectorWrite(
|
|
150
|
+
{
|
|
151
|
+
operation: "conversations.activities.reply",
|
|
152
|
+
serviceUrl: input.serviceUrl,
|
|
153
|
+
path: `/v3/conversations/${encodeURIComponent(input.conversationId)}/activities/${encodeURIComponent(input.activityId)}`,
|
|
154
|
+
body: input.body,
|
|
155
|
+
},
|
|
156
|
+
deps,
|
|
157
|
+
);
|
|
158
|
+
if (!res.ok) throw new Error(`Teams reply activity failed with HTTP ${res.status}.`);
|
|
159
|
+
return toPostedTeamsActivity(res.body);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Mirrors eve's `updateTeamsActivity` — `PUT /v3/conversations/{id}/activities/{activityId}`. */
|
|
163
|
+
export async function updateTeamsActivityBackedOff(
|
|
164
|
+
input: { readonly serviceUrl: string; readonly conversationId: string; readonly activityId: string; readonly body: unknown },
|
|
165
|
+
deps: TeamsConnectorDeps = {},
|
|
166
|
+
): Promise<{ id: string }> {
|
|
167
|
+
const res = await teamsConnectorWrite(
|
|
168
|
+
{
|
|
169
|
+
operation: "conversations.activities.update",
|
|
170
|
+
serviceUrl: input.serviceUrl,
|
|
171
|
+
path: `/v3/conversations/${encodeURIComponent(input.conversationId)}/activities/${encodeURIComponent(input.activityId)}`,
|
|
172
|
+
method: "PUT",
|
|
173
|
+
body: input.body,
|
|
174
|
+
},
|
|
175
|
+
deps,
|
|
176
|
+
);
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Teams update activity failed with HTTP ${res.status} for conversation "${input.conversationId}" activity "${input.activityId}": ${formatTeamsConnectorErrorBody(res.body)}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return toPostedTeamsActivity(res.body);
|
|
183
|
+
}
|