@open-everywhere/provider-notion 0.1.0
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 +48 -0
- package/agent/channels/notion-acting-identity.ts +88 -0
- package/agent/channels/notion-ancestry-trust.ts +458 -0
- package/agent/channels/notion-api.ts +1290 -0
- package/agent/channels/notion-bot-user.ts +250 -0
- package/agent/channels/notion-catch-up.ts +339 -0
- package/agent/channels/notion-credentials.ts +417 -0
- package/agent/channels/notion-engagement.ts +372 -0
- package/agent/channels/notion-event-normalizer.ts +587 -0
- package/agent/channels/notion-identity.ts +271 -0
- package/agent/channels/notion-ingress-drain.ts +289 -0
- package/agent/channels/notion-ingress.ts +182 -0
- package/agent/channels/notion-message-ops.ts +140 -0
- package/agent/channels/notion-origin-codec.ts +38 -0
- package/agent/channels/notion-post-arm.ts +189 -0
- package/agent/channels/notion-question-return.ts +223 -0
- package/agent/channels/notion-read.ts +283 -0
- package/agent/channels/notion-scope.ts +560 -0
- package/agent/channels/notion-stop.ts +169 -0
- package/agent/channels/notion-supervisor.ts +856 -0
- package/agent/channels/notion-webhook-timing.ts +131 -0
- package/agent/channels/notion-webhook-verifier.ts +55 -0
- package/agent/channels/notion-webhook.ts +963 -0
- package/agent/channels/notion-work-search.ts +74 -0
- package/agent/channels/notion-work.ts +144 -0
- package/agent/channels/notion.ts +1242 -0
- package/agent/contributions.ts +39 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/memory/blocks.ts +295 -0
- package/agent/memory/memory.ts +622 -0
- package/agent/provider-events.ts +198 -0
- package/agent/register.ts +144 -0
- package/agent/schedules/notion-ingress.ts +20 -0
- package/agent/surface-parity.ts +105 -0
- package/agent/tools/notion_search.ts +110 -0
- package/agent/tools/read_notion_comments.ts +250 -0
- package/agent/tools/read_notion_page.ts +351 -0
- package/dist/agent/channels/notion-acting-identity.d.ts +23 -0
- package/dist/agent/channels/notion-acting-identity.d.ts.map +1 -0
- package/dist/agent/channels/notion-acting-identity.js +85 -0
- package/dist/agent/channels/notion-acting-identity.js.map +1 -0
- package/dist/agent/channels/notion-ancestry-trust.d.ts +208 -0
- package/dist/agent/channels/notion-ancestry-trust.d.ts.map +1 -0
- package/dist/agent/channels/notion-ancestry-trust.js +331 -0
- package/dist/agent/channels/notion-ancestry-trust.js.map +1 -0
- package/dist/agent/channels/notion-api.d.ts +655 -0
- package/dist/agent/channels/notion-api.d.ts.map +1 -0
- package/dist/agent/channels/notion-api.js +938 -0
- package/dist/agent/channels/notion-api.js.map +1 -0
- package/dist/agent/channels/notion-bot-user.d.ts +107 -0
- package/dist/agent/channels/notion-bot-user.d.ts.map +1 -0
- package/dist/agent/channels/notion-bot-user.js +192 -0
- package/dist/agent/channels/notion-bot-user.js.map +1 -0
- package/dist/agent/channels/notion-catch-up.d.ts +64 -0
- package/dist/agent/channels/notion-catch-up.d.ts.map +1 -0
- package/dist/agent/channels/notion-catch-up.js +289 -0
- package/dist/agent/channels/notion-catch-up.js.map +1 -0
- package/dist/agent/channels/notion-credentials.d.ts +210 -0
- package/dist/agent/channels/notion-credentials.d.ts.map +1 -0
- package/dist/agent/channels/notion-credentials.js +360 -0
- package/dist/agent/channels/notion-credentials.js.map +1 -0
- package/dist/agent/channels/notion-engagement.d.ts +219 -0
- package/dist/agent/channels/notion-engagement.d.ts.map +1 -0
- package/dist/agent/channels/notion-engagement.js +283 -0
- package/dist/agent/channels/notion-engagement.js.map +1 -0
- package/dist/agent/channels/notion-event-normalizer.d.ts +194 -0
- package/dist/agent/channels/notion-event-normalizer.d.ts.map +1 -0
- package/dist/agent/channels/notion-event-normalizer.js +542 -0
- package/dist/agent/channels/notion-event-normalizer.js.map +1 -0
- package/dist/agent/channels/notion-identity.d.ts +119 -0
- package/dist/agent/channels/notion-identity.d.ts.map +1 -0
- package/dist/agent/channels/notion-identity.js +205 -0
- package/dist/agent/channels/notion-identity.js.map +1 -0
- package/dist/agent/channels/notion-ingress-drain.d.ts +66 -0
- package/dist/agent/channels/notion-ingress-drain.d.ts.map +1 -0
- package/dist/agent/channels/notion-ingress-drain.js +185 -0
- package/dist/agent/channels/notion-ingress-drain.js.map +1 -0
- package/dist/agent/channels/notion-ingress.d.ts +64 -0
- package/dist/agent/channels/notion-ingress.d.ts.map +1 -0
- package/dist/agent/channels/notion-ingress.js +130 -0
- package/dist/agent/channels/notion-ingress.js.map +1 -0
- package/dist/agent/channels/notion-message-ops.d.ts +3 -0
- package/dist/agent/channels/notion-message-ops.d.ts.map +1 -0
- package/dist/agent/channels/notion-message-ops.js +126 -0
- package/dist/agent/channels/notion-message-ops.js.map +1 -0
- package/dist/agent/channels/notion-origin-codec.d.ts +7 -0
- package/dist/agent/channels/notion-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/notion-origin-codec.js +32 -0
- package/dist/agent/channels/notion-origin-codec.js.map +1 -0
- package/dist/agent/channels/notion-post-arm.d.ts +3 -0
- package/dist/agent/channels/notion-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/notion-post-arm.js +185 -0
- package/dist/agent/channels/notion-post-arm.js.map +1 -0
- package/dist/agent/channels/notion-question-return.d.ts +77 -0
- package/dist/agent/channels/notion-question-return.d.ts.map +1 -0
- package/dist/agent/channels/notion-question-return.js +183 -0
- package/dist/agent/channels/notion-question-return.js.map +1 -0
- package/dist/agent/channels/notion-read.d.ts +62 -0
- package/dist/agent/channels/notion-read.d.ts.map +1 -0
- package/dist/agent/channels/notion-read.js +257 -0
- package/dist/agent/channels/notion-read.js.map +1 -0
- package/dist/agent/channels/notion-scope.d.ts +188 -0
- package/dist/agent/channels/notion-scope.d.ts.map +1 -0
- package/dist/agent/channels/notion-scope.js +433 -0
- package/dist/agent/channels/notion-scope.js.map +1 -0
- package/dist/agent/channels/notion-stop.d.ts +48 -0
- package/dist/agent/channels/notion-stop.d.ts.map +1 -0
- package/dist/agent/channels/notion-stop.js +141 -0
- package/dist/agent/channels/notion-stop.js.map +1 -0
- package/dist/agent/channels/notion-supervisor.d.ts +462 -0
- package/dist/agent/channels/notion-supervisor.d.ts.map +1 -0
- package/dist/agent/channels/notion-supervisor.js +482 -0
- package/dist/agent/channels/notion-supervisor.js.map +1 -0
- package/dist/agent/channels/notion-webhook-timing.d.ts +35 -0
- package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook-timing.js +99 -0
- package/dist/agent/channels/notion-webhook-timing.js.map +1 -0
- package/dist/agent/channels/notion-webhook-verifier.d.ts +5 -0
- package/dist/agent/channels/notion-webhook-verifier.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook-verifier.js +53 -0
- package/dist/agent/channels/notion-webhook-verifier.js.map +1 -0
- package/dist/agent/channels/notion-webhook.d.ts +242 -0
- package/dist/agent/channels/notion-webhook.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook.js +675 -0
- package/dist/agent/channels/notion-webhook.js.map +1 -0
- package/dist/agent/channels/notion-work-search.d.ts +9 -0
- package/dist/agent/channels/notion-work-search.d.ts.map +1 -0
- package/dist/agent/channels/notion-work-search.js +69 -0
- package/dist/agent/channels/notion-work-search.js.map +1 -0
- package/dist/agent/channels/notion-work.d.ts +68 -0
- package/dist/agent/channels/notion-work.d.ts.map +1 -0
- package/dist/agent/channels/notion-work.js +126 -0
- package/dist/agent/channels/notion-work.js.map +1 -0
- package/dist/agent/channels/notion.d.ts +95 -0
- package/dist/agent/channels/notion.d.ts.map +1 -0
- package/dist/agent/channels/notion.js +1081 -0
- package/dist/agent/channels/notion.js.map +1 -0
- package/dist/agent/contributions.d.ts +32 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +37 -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/memory/blocks.d.ts +58 -0
- package/dist/agent/memory/blocks.d.ts.map +1 -0
- package/dist/agent/memory/blocks.js +264 -0
- package/dist/agent/memory/blocks.js.map +1 -0
- package/dist/agent/memory/memory.d.ts +157 -0
- package/dist/agent/memory/memory.d.ts.map +1 -0
- package/dist/agent/memory/memory.js +471 -0
- package/dist/agent/memory/memory.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 +196 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +5 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +128 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/schedules/notion-ingress.d.ts +6 -0
- package/dist/agent/schedules/notion-ingress.d.ts.map +1 -0
- package/dist/agent/schedules/notion-ingress.js +16 -0
- package/dist/agent/schedules/notion-ingress.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 +99 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/dist/agent/tools/notion_search.d.ts +56 -0
- package/dist/agent/tools/notion_search.d.ts.map +1 -0
- package/dist/agent/tools/notion_search.js +100 -0
- package/dist/agent/tools/notion_search.js.map +1 -0
- package/dist/agent/tools/read_notion_comments.d.ts +57 -0
- package/dist/agent/tools/read_notion_comments.d.ts.map +1 -0
- package/dist/agent/tools/read_notion_comments.js +236 -0
- package/dist/agent/tools/read_notion_comments.js.map +1 -0
- package/dist/agent/tools/read_notion_page.d.ts +53 -0
- package/dist/agent/tools/read_notion_page.d.ts.map +1 -0
- package/dist/agent/tools/read_notion_page.js +327 -0
- package/dist/agent/tools/read_notion_page.js.map +1 -0
- package/package.json +231 -0
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// notion-bot-user โ THE deployment's own Notion identity, per integration: the bot user id every
|
|
2
|
+
// road that has to tell "our own words" from someone else's compares against.
|
|
3
|
+
//
|
|
4
|
+
// ๐ด IT LIVES IN ITS OWN MODULE BECAUSE TWO ROADS NEED IT (OPE-1857). The ingress road has always
|
|
5
|
+
// held it โ `classifyNotionComment` drops a self-authored comment, and `notionCommentIsStopDirective`
|
|
6
|
+
// refuses one โ and it sat as a module-private helper inside `notion.ts`. The SEAT CATCH-UP needs the
|
|
7
|
+
// same answer: it reads the page's own discussion since the seat last woke, and the seat's previous
|
|
8
|
+
// reply is IN that window. Without this the catch-up cannot recognise our own comment, and Notion's
|
|
9
|
+
// comment payload gives it nothing else to go on โ `created_by` is a partial user (an id and an
|
|
10
|
+
// object, no `type`), so our own reply is indistinguishable from a person's by shape alone. A second
|
|
11
|
+
// copy of this resolver would be a second spelling of one identity, which is the class OPE-1857 is.
|
|
12
|
+
//
|
|
13
|
+
// ๐ด CACHED PER CREDENTIAL (OPE-467), not per deployment. EVERY Notion integration has its OWN bot
|
|
14
|
+
// user id, so a deployment running a second integration for a bound domain has two of them. One
|
|
15
|
+
// shared cache would compare a reply against the WRONG identity and break the loop guard in both
|
|
16
|
+
// directions: the agent could either loop on its own replies (its id never matches) or drop real
|
|
17
|
+
// human comments. The key is the credential id, which is exactly one integration.
|
|
18
|
+
//
|
|
19
|
+
// FAIL-SOFT, AND THE TWO ROADS READ AN UNRESOLVED ID DIFFERENTLY. A failed resolve answers `{}` and
|
|
20
|
+
// is CACHED as such for the process lifetime.
|
|
21
|
+
// โข THE INGRESS ROAD FAILS CLOSED: `classifyNotionComment` drops on "unresolved bot user id", so
|
|
22
|
+
// nothing dispatches rather than risk looping on our own replies.
|
|
23
|
+
// โข THE CATCH-UP ROAD DOES NOT. With no id, our own comment reads as a PERSON and enters the
|
|
24
|
+
// seat's context at the `unauth` floor. That is deliberate and `notion-catch-up.ts` states and
|
|
25
|
+
// defends it where it reads this resolver: blinding a context read on a transient
|
|
26
|
+
// `GET /v1/users/me` failure would re-create the OPE-1857 symptom โ a seat that keeps nothing.
|
|
27
|
+
// The ingress road's fail-closed arm covers the PRIMARY id only: a deployment that cannot
|
|
28
|
+
// resolve THAT dispatches no Notion wake and therefore takes no catch-up. An unresolved SIBLING
|
|
29
|
+
// still wakes on a person-marked comment (classifyNotionComment step 5), so its own replies can
|
|
30
|
+
// reach a seat's context as a person's โ `notion-catch-up.ts` states that residual.
|
|
31
|
+
|
|
32
|
+
import { retrieveBotUser, NOTION_INGRESS_RETRY, type ResolvedNotionCredential } from "./notion-api.js";
|
|
33
|
+
// The ORG-DEFAULT credential id, imported rather than spelled: the override scope below is a
|
|
34
|
+
// security decision, and a second spelling of that id would silently widen it.
|
|
35
|
+
import { NOTION_CHANNEL_CREDENTIAL } from "./notion-credentials.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* EVERY bot user id this deployment posts under, and whether the set is COMPLETE (OPE-1921).
|
|
39
|
+
*
|
|
40
|
+
* ๐ด ONE ID IS NOT THE DEPLOYMENT'S IDENTITY WHEN IT RUNS TWO INTEGRATIONS. Each Notion integration
|
|
41
|
+
* has its own bot user id. The loop guard used to compare a comment against the id of the ONE
|
|
42
|
+
* integration whose secret verified that delivery, so a reply posted by integration A and delivered
|
|
43
|
+
* on integration B's subscription carried a real id that matched nothing. It read as a person, woke a
|
|
44
|
+
* turn, and A's subscription then saw B's answer โ a loop the OPE-1906 once-guard cannot bound,
|
|
45
|
+
* because every reply is a new comment id.
|
|
46
|
+
*
|
|
47
|
+
* `all` is what a self-check compares against; `primary` is the VERIFYING integration's own id, kept
|
|
48
|
+
* separate because the addressed path is still that integration's (a structured @mention names one
|
|
49
|
+
* bot user, not a deployment).
|
|
50
|
+
*
|
|
51
|
+
* ๐ด `complete` IS THE FAIL-CLOSED INPUT, AND IT IS FALSE WHENEVER ONE CONFIGURED INTEGRATION'S ID
|
|
52
|
+
* DID NOT RESOLVE. Notion serves a comment's `created_by` as a PARTIAL user with no `type`
|
|
53
|
+
* (OPE-1857), so an unresolved sibling leaves a real authored comment that NOTHING can attribute:
|
|
54
|
+
* it is either a person or our own second integration, and the payload cannot say which. The guard
|
|
55
|
+
* therefore drops it โ see {@link NotionOwnBotIds.complete} for what that costs.
|
|
56
|
+
*/
|
|
57
|
+
export interface NotionOwnBotIds {
|
|
58
|
+
/** The bot user id of the integration this delivery VERIFIED under, or undefined when it did not
|
|
59
|
+
* resolve. An absent primary is the pre-existing fail-closed arm and still drops on its own. */
|
|
60
|
+
readonly primary: string | undefined;
|
|
61
|
+
/** Every bot user id this deployment could resolve, `primary` included. A comment whose author id
|
|
62
|
+
* is in here is OUR OWN WORDS, whichever integration posted it. */
|
|
63
|
+
readonly all: ReadonlySet<string>;
|
|
64
|
+
/**
|
|
65
|
+
* True when EVERY configured integration's bot user id resolved. False โ the self-check cannot
|
|
66
|
+
* answer "is this ours" for an author it does not recognise, and the loop guard fails CLOSED.
|
|
67
|
+
*
|
|
68
|
+
* ๐ด THE COST, STATED PLAINLY: an integration that CAN reach Notion but whose `GET /v1/users/me`
|
|
69
|
+
* fails makes the whole deployment deaf to every Notion comment Notion does not mark
|
|
70
|
+
* `person`-authored โ on every page, including pages the healthy integration serves. That is NOT
|
|
71
|
+
* only ambient chatter: `classifyNotionComment` step 5 sits ABOVE the addressed arms, so an
|
|
72
|
+
* addressed name-drop and an `oe link <code>` identity claim drop with it. And
|
|
73
|
+
* `resolveNotionBotUser` caches a failure for the PROCESS
|
|
74
|
+
* LIFETIME, so a single rate-limit episode during the first resolve silences that instance until
|
|
75
|
+
* it restarts. That is the direction this repository takes everywhere else on this guard (an
|
|
76
|
+
* identity we cannot establish never wakes the agent), and the alternative is worse: a fail-open
|
|
77
|
+
* sibling is an unbounded reply loop between two integrations of one deployment, on a launch
|
|
78
|
+
* surface, with no once-guard able to stop it.
|
|
79
|
+
* The cost is bounded to integrations that could actually have posted โ an integration with no
|
|
80
|
+
* usable key never sets this false. See `collectNotionOwnBotIds`'s `canPost`.
|
|
81
|
+
*/
|
|
82
|
+
readonly complete: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the deployment's own bot user ids: the already-resolved `primary`, plus one per entry of
|
|
87
|
+
* `siblingCredentialIds` โ every CONFIGURED INTEGRATION EXCEPT the one this delivery verified under.
|
|
88
|
+
* The resolver is the SAME per-credential one and cache the loop guard already reads, so no second
|
|
89
|
+
* spelling of one identity can exist.
|
|
90
|
+
*
|
|
91
|
+
* ๐ด THE ADDED INGRESS COST, EXACTLY: N-1 extra `GET /v1/users/me` calls for N configured
|
|
92
|
+
* integrations, on the FIRST delivery of a process, and ZERO after it โ
|
|
93
|
+
* {@link resolveNotionBotUser} memoises on the credential id. The verifying integration is excluded
|
|
94
|
+
* here precisely so the number is N-1 and not N: its id is already in hand, and re-resolving it
|
|
95
|
+
* would charge a live resolver for an answer the caller just took. A SINGLE-INTEGRATION deployment โ
|
|
96
|
+
* every deployment before OPE-1921 โ therefore pays NOTHING at all, and `notion-test.ts`'s
|
|
97
|
+
* ack-attribution fixture measures that: its `botTriggerPage` bucket is unchanged.
|
|
98
|
+
*
|
|
99
|
+
* A resolver that THROWS counts as unresolved rather than failing the delivery: this runs on the
|
|
100
|
+
* awaited ingress path, and a thrown identity lookup must not turn into a 500 Notion will retry.
|
|
101
|
+
*
|
|
102
|
+
* ๐ด AN INTEGRATION THAT CANNOT POST IS NOT A LOOP RISK, AND MARKING THE SET INCOMPLETE FOR ONE IS
|
|
103
|
+
* PURE AVAILABILITY LOSS. `resolve` answers `undefined` for two different failures: a working key
|
|
104
|
+
* whose `GET /v1/users/me` failed โ that integration CAN post, so we must fail closed โ and an
|
|
105
|
+
* integration THIS DEPLOYMENT holds no usable API key for, so it cannot post FROM HERE. `canPost`
|
|
106
|
+
* separates them, and it is consulted ONLY when `resolve` came back empty, so the ordinary path
|
|
107
|
+
* pays nothing for it. Without the split, a domain whose credential is declared but whose secret is
|
|
108
|
+
* unset or revoked would silence Notion on EVERY page of the deployment, including pages
|
|
109
|
+
* the healthy org-default integration serves โ a blast radius nothing buys. Absent โ every
|
|
110
|
+
* unresolved sibling is treated as able to post, which is the conservative reading.
|
|
111
|
+
*
|
|
112
|
+
* ๐ด THE CLAIM IS "NOT A LOOP RISK FROM HERE", NEVER "CANNOT HAVE AUTHORED ANY COMMENT", and the
|
|
113
|
+
* difference is real. `canPost` establishes only that THIS deployment cannot resolve a key for that
|
|
114
|
+
* credential right now. A token an operator deleted here, or one another environment still holds,
|
|
115
|
+
* can keep authoring comments on the shared page โ and this deployment then reads those replies as a
|
|
116
|
+
* person's, because that id is absent from `all` while `complete` stays true. The residual is
|
|
117
|
+
* accepted: a loop needs two of OUR OWN integrations answering each other, and an integration we
|
|
118
|
+
* cannot post under is not one of the two.
|
|
119
|
+
*/
|
|
120
|
+
export async function collectNotionOwnBotIds(
|
|
121
|
+
primary: string | undefined,
|
|
122
|
+
siblingCredentialIds: readonly string[],
|
|
123
|
+
resolve: (credentialId: string) => Promise<string | undefined>,
|
|
124
|
+
canPost?: (credentialId: string) => Promise<boolean>,
|
|
125
|
+
): Promise<NotionOwnBotIds> {
|
|
126
|
+
const all = new Set<string>();
|
|
127
|
+
if (primary) all.add(primary);
|
|
128
|
+
// An unresolved PRIMARY is already an incomplete set. Step 1 of the classifier drops on it before
|
|
129
|
+
// step 5 is reached, so this line changes no verdict โ it keeps the flag's meaning honest for any
|
|
130
|
+
// later reader of the structure.
|
|
131
|
+
let complete = primary !== undefined;
|
|
132
|
+
for (const credentialId of new Set(siblingCredentialIds)) {
|
|
133
|
+
let id: string | undefined;
|
|
134
|
+
try {
|
|
135
|
+
id = await resolve(credentialId);
|
|
136
|
+
} catch {
|
|
137
|
+
id = undefined;
|
|
138
|
+
}
|
|
139
|
+
if (id) {
|
|
140
|
+
all.add(id);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// Unresolved. Only an integration that can REACH Notion could have authored the comment in
|
|
144
|
+
// hand, so only that one makes the set incomplete.
|
|
145
|
+
// ๐ด AN UNKNOWN ANSWER COUNTS AS ABLE TO POST. `resolveNotionCredential` signals the real
|
|
146
|
+
// no-usable-key cases by RETURNING `undefined` โ an unregistered credential, one with no
|
|
147
|
+
// `secretRef`, and a broker `CredentialError` all take that path โ so it re-throws only an
|
|
148
|
+
// UNEXPECTED fault (a store timeout, a decrypt error). A throw therefore says the state is
|
|
149
|
+
// unknown, never "this integration has no key", and the unknown side of this question has to
|
|
150
|
+
// fall the way the absent-`canPost` default falls: treat the sibling as able to post and let
|
|
151
|
+
// the set go incomplete. `reachableOnce` holds that direction.
|
|
152
|
+
let reachable = true;
|
|
153
|
+
if (canPost) reachable = await reachableOnce(credentialId, canPost);
|
|
154
|
+
if (reachable) complete = false;
|
|
155
|
+
}
|
|
156
|
+
return { primary, all, complete };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Can this credential reach Notion at all โ memoised for the PROCESS LIFETIME, the same lifetime
|
|
161
|
+
* {@link botUserCache} gives a resolved bot user.
|
|
162
|
+
*
|
|
163
|
+
* ๐ด WITHOUT THE MEMO THIS QUESTION IS ASKED ON EVERY DELIVERY, FOR EVER. The answer is consulted
|
|
164
|
+
* only for a sibling whose bot user id did not resolve โ and `botUserCache` holds THAT failure for
|
|
165
|
+
* the process lifetime, so the arm runs on every subsequent delivery of the process. The webhook
|
|
166
|
+
* core's own credential memo is per-DELIVERY (`notion.ts` builds a fresh `Map` per delivery), so
|
|
167
|
+
* each of those deliveries paid a fresh operational-store read on the AWAITED ingress path. Worse,
|
|
168
|
+
* on the default `db` broker a ref with no stored row writes one `CREDENTIAL_UNRESOLVED` audit row
|
|
169
|
+
* per attempt by design, so a declared-but-unstored sibling credential โ a configuration
|
|
170
|
+
* `docs/deploy/surfaces/notion.md` calls supported โ wrote one row per ingress delivery indefinitely
|
|
171
|
+
* and drowned the repeated-refusal RATE that row exists to show. `notion-catch-up.ts` refuses that
|
|
172
|
+
* same cost on the catch-up road; this is the ingress road's answer to it.
|
|
173
|
+
*
|
|
174
|
+
* The cached value is the VERDICT, not the credential, so no key is held here. Both answers last
|
|
175
|
+
* until restart, which matches `botUserCache`: a sibling whose id will not resolve stays unresolved
|
|
176
|
+
* until then anyway, so a fresher reachability answer could not change any verdict.
|
|
177
|
+
*/
|
|
178
|
+
const reachableCache = new Map<string, Promise<boolean>>();
|
|
179
|
+
|
|
180
|
+
function reachableOnce(credentialId: string, canPost: (credentialId: string) => Promise<boolean>): Promise<boolean> {
|
|
181
|
+
const hit = reachableCache.get(credentialId);
|
|
182
|
+
if (hit) return hit;
|
|
183
|
+
// `.catch(() => true)` is the fail-CLOSED direction, and it is the whole point: an unknown answer
|
|
184
|
+
// leaves the sibling counted as able to post, which marks the set incomplete. See the call site.
|
|
185
|
+
const verdict = canPost(credentialId).catch(() => true);
|
|
186
|
+
reachableCache.set(credentialId, verdict);
|
|
187
|
+
return verdict;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const botUserCache = new Map<string, Promise<{ id?: string; name?: string }>>();
|
|
191
|
+
|
|
192
|
+
/** Where this module's own diagnostics go โ the same prefix every Notion channel module logs under. */
|
|
193
|
+
function log(msg: string): void {
|
|
194
|
+
console.log(`[open-everywhere:notion] ${msg}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The bot user (id and display name) for ONE integration, memoised on `credentialId`.
|
|
199
|
+
* `credential` is a THUNK so an operator override short-circuits the credential resolution entirely.
|
|
200
|
+
*
|
|
201
|
+
* ๐ด `NOTION_BOT_USER_ID` PINS THE ORG DEFAULT ONLY, AND SCOPING IT IS LOAD-BEARING (OPE-1921). It
|
|
202
|
+
* is ONE id, and two Notion integrations NEVER share a bot user โ so an override applied to every
|
|
203
|
+
* credential would answer every integration with the org default's id. That is worse than no
|
|
204
|
+
* override: the cross-integration loop guard would hold a one-element set AND report it COMPLETE,
|
|
205
|
+
* so a sibling's reply would match nothing, the fail-closed arm would not fire, and the loop the
|
|
206
|
+
* set exists to close would reopen silently on a deployment that looks configured. Scoped here, a
|
|
207
|
+
* sibling still resolves live and an unresolvable one correctly marks the set incomplete.
|
|
208
|
+
* `NOTION_BOT_NAME` is NOT scoped: it is a display name the judge reads, never an identity anything
|
|
209
|
+
* is compared against, and a deployment that renames its agent means the new name everywhere.
|
|
210
|
+
*/
|
|
211
|
+
export function resolveNotionBotUser(
|
|
212
|
+
credentialId: string,
|
|
213
|
+
credential: () => Promise<ResolvedNotionCredential | undefined>,
|
|
214
|
+
): () => Promise<{ id?: string; name?: string }> {
|
|
215
|
+
return () => {
|
|
216
|
+
const cached = botUserCache.get(credentialId);
|
|
217
|
+
if (cached) return cached;
|
|
218
|
+
const overrideId = credentialId === NOTION_CHANNEL_CREDENTIAL ? process.env.NOTION_BOT_USER_ID : undefined;
|
|
219
|
+
const overrideName = process.env.NOTION_BOT_NAME;
|
|
220
|
+
const named = (u: { id?: string; name?: string }) => ({ id: u.id, name: overrideName && overrideName.length > 0 ? overrideName : u.name });
|
|
221
|
+
const resolved =
|
|
222
|
+
overrideId && overrideId.length > 0
|
|
223
|
+
? Promise.resolve(named({ id: overrideId }))
|
|
224
|
+
: credential()
|
|
225
|
+
// ๐ด THE INGRESS POLICY, BECAUSE THIS RESOLVE IS ON THE AWAITED INGRESS PATH (OPE-1254).
|
|
226
|
+
// `handleNotionDelivery` awaits `deps.resolveBotUserId` BEFORE `retrieveComment`, and
|
|
227
|
+
// this is the only thing behind it. On the shared defaults it could spend 60 s of backoff
|
|
228
|
+
// on its own, ahead of every call the webhook core's own `ingressCallOpts` bounds โ so
|
|
229
|
+
// the delivery's worst case was ~90 s against a 300 s ceiling, not the ~60 s the seam
|
|
230
|
+
// claims. Worse, a failure here is CACHED for the process lifetime as `{}` below, and an
|
|
231
|
+
// unresolved bot id makes the classifier fail closed: one rate-limit episode during the
|
|
232
|
+
// first resolve silences every later delivery on that instance. Bounding the wait shrinks
|
|
233
|
+
// that window.
|
|
234
|
+
.then((cred) => (cred ? retrieveBotUser({ ...cred, retry: NOTION_INGRESS_RETRY }).then(named) : {}))
|
|
235
|
+
.catch((err) => {
|
|
236
|
+
log(`could not resolve bot user for credential "${credentialId}" (GET /v1/users/me failed): ${String(err)}`);
|
|
237
|
+
return {};
|
|
238
|
+
});
|
|
239
|
+
botUserCache.set(credentialId, resolved);
|
|
240
|
+
return resolved;
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Reset the cached bot users AND the cached reachability verdicts (tests only). Both are keyed by
|
|
245
|
+
* credential id and both live for the process lifetime, so a test that resets one and not the other
|
|
246
|
+
* would carry a stale answer into the next case. */
|
|
247
|
+
export function _resetNotionBotUserCache(): void {
|
|
248
|
+
botUserCache.clear();
|
|
249
|
+
reachableCache.clear();
|
|
250
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// notion-catch-up โ Notion's registered SEAT CATCH-UP FETCH (OPE-1726): the seat's own page
|
|
2
|
+
// discussion since its last wake, normalized for core's shared catch-up path
|
|
3
|
+
// (channels/seat-catch-up.ts).
|
|
4
|
+
//
|
|
5
|
+
// WHAT "THE SEAT'S CONTAINER" IS HERE. A Notion seat is bound to a PAGE and every wake arrives scoped
|
|
6
|
+
// to one DISCUSSION on it. The read is the PAGE's comment sweep, narrowed to that discussion here.
|
|
7
|
+
// ๐ด THERE IS NO PER-DISCUSSION ENDPOINT. Notion lists comments by their PAGE/BLOCK anchor, while a
|
|
8
|
+
// discussion id is only the reply/filter identity. For an INLINE trigger, ingress carries the
|
|
9
|
+
// fetched comment's authenticated parent block as `threadAnchor`; this fetch drills into that exact
|
|
10
|
+
// block, then filters by `threadRoot`. A PAGE-level trigger has no block anchor and preserves the
|
|
11
|
+
// full page sweep. Guessing the discussion id as a block id would 404 on every wake.
|
|
12
|
+
//
|
|
13
|
+
// WHY A STANDALONE CALLER, not `notionReadAdapter.readChannel`: that method takes a live
|
|
14
|
+
// {@link BindableContext} and runs the turn-time domain-scope fence over it. A catch-up fires from a
|
|
15
|
+
// webhook route with no turn, and its container is the one the wake itself already admitted. The
|
|
16
|
+
// CREDENTIAL is resolved the SAME way the read adapter resolves it โ from the DOMAIN that binds this
|
|
17
|
+
// surface (`notionChannelCredentialForSurface`), never the org default alone โ so a deployment whose
|
|
18
|
+
// Notion domain binds its own integration reads the page over that integration, and this opens no
|
|
19
|
+
// second transport: the same endpoint fence and the same 429 backoff.
|
|
20
|
+
//
|
|
21
|
+
// ๐ด THE VENDOR CANNOT FILTER BY TIME: Notion's comments endpoint takes no `since`, so the bound is
|
|
22
|
+
// applied on this side and again by the shared path.
|
|
23
|
+
//
|
|
24
|
+
// ๐ด โฆAND IT SERVES OLDEST-FIRST, WHICH IS WHAT RED `J-ENGAGED-notion` ON SHIP #208 (OPE-1831).
|
|
25
|
+
// THE CAUSE IS THE COMMENT CAP, CHARGED FROM THE OLD END. `readNotionComments` sweeps the PAGE's own
|
|
26
|
+
// comments first and only THEN walks the page's blocks. `maxComments` was charged over the whole
|
|
27
|
+
// history, so on the pinned journey page โ which had collected every prior ship's page-level comments
|
|
28
|
+
// โ the oldest hundred filled the cap and `take` returned STOP. The sweep ended with `blocksScanned`
|
|
29
|
+
// at ZERO: the block sweep never ran at all. The journey's own comment lives in a BLOCK-anchored
|
|
30
|
+
// discussion on a block the run appends itself (`scripts/e2e/scenarios.ts`: `appendParagraphBlock`
|
|
31
|
+
// then `createBlockDiscussion`), and a page-id comment query never returns a child block's comments
|
|
32
|
+
// (`scripts/e2e/notion-driver.ts` records that, live-confirmed). So the read returned `[]` with no
|
|
33
|
+
// error at all, on a discussion that HAD carried the message.
|
|
34
|
+
// THE FIX IS `sinceMs`, handed to the sweep so the cap counts comments IN the seat's window. The
|
|
35
|
+
// ancient page-level comments are then skipped without charging it, `take` does not stop, and the
|
|
36
|
+
// block sweep runs โ which is where the seat's discussion actually is.
|
|
37
|
+
// ๐ด `maxCommentPages` IS A SECOND, SEPARATE BOUND, and it is NOT what fixed ship #208. One call
|
|
38
|
+
// returns an anchor's oldest hundred comments and nothing follows `next_cursor`, so a PAGE-ANCHORED
|
|
39
|
+
// discussion on a busy page is unreachable for the same oldest-first reason. Real, and worth closing
|
|
40
|
+
// while the file is open โ but a page follow could never have reached a BLOCK-anchored comment, so do
|
|
41
|
+
// not read it as the ship #208 cause.
|
|
42
|
+
// ๐ด THE 80-BLOCK PAGE-SWEEP BOUND STILL EXISTS FOR PAGE-LEVEL CATCH-UP, AND IT IS NOW SPENT ON THE
|
|
43
|
+
// PAGE'S NEWEST BLOCKS (OPE-1843). It never hid the triggering INLINE discussion, because that path
|
|
44
|
+
// uses the authenticated block drill-in. A PAGE-level seat has no narrower anchor, so it keeps the
|
|
45
|
+
// bounded sweep โ but in document order the whole budget went to the oldest material of a page that
|
|
46
|
+
// had outgrown the cap, and Notion pages grow by APPENDING. A discussion on a recently appended
|
|
47
|
+
// block was therefore never scanned, and the read returned zero comments with no error: byte for
|
|
48
|
+
// byte the answer a quiet page gives. `blockScanOrder: "newest-first"` spends the same 80 scans on
|
|
49
|
+
// the last blocks instead, which is where a live conversation on an append-only page is.
|
|
50
|
+
// ๐ด IT MOVES THE BLIND SPOT RATHER THAN CLOSING IT: a comment posted today on an ANCIENT block of
|
|
51
|
+
// an over-cap page is now the unread case. Notion can neither query "blocks with recent comments"
|
|
52
|
+
// nor stamp a block when one is commented on, so no scan order is complete at any cap below the
|
|
53
|
+
// page's own length. `notion-api.ts` `blockScanOrder` carries the full reasoning.
|
|
54
|
+
//
|
|
55
|
+
// An unresolvable credential yields `[]` because no transport was attempted. An attempted read
|
|
56
|
+
// failure propagates to core's shared fail-soft boundary and records `outcome=failed` (OPE-1842).
|
|
57
|
+
// ๐ด THE ROW CANNOT CARRY THE RESIDUAL COVERAGE MISS, AND THAT IS A CONTRACT LIMIT, NOT A CHOICE.
|
|
58
|
+
// `readNotionComments` returns a `truncated` bit, but {@link SeatCatchUpFetch} answers with
|
|
59
|
+
// `NormalizedMessage[]` and nothing else, so a surface has no slot to report partial coverage in
|
|
60
|
+
// and `seat.catch_up` writes `outcome=empty raw=0 kept=0 reason=fetch-returned-none` for an
|
|
61
|
+
// over-cap miss exactly as for a quiet page. Closing that needs the shared fetch contract to carry
|
|
62
|
+
// a coverage bit โ core's decision, not this surface's.
|
|
63
|
+
|
|
64
|
+
import { readNotionComments, resolveNotionCredential, type NotionCommentSummary, type ResolvedNotionCredential } from "./notion-api.js";
|
|
65
|
+
import { notionChannelCredentialForSurface, notionIntegrations, NOTION_CHANNEL_CREDENTIAL } from "./notion-credentials.js";
|
|
66
|
+
import { resolveNotionBotUser } from "./notion-bot-user.js";
|
|
67
|
+
import { catchUpSender, getConfig, type Domain, type NormalizedMessage, type SeatCatchUpFetch } from "@open-everywhere/core/agent/adapter-api.js";
|
|
68
|
+
|
|
69
|
+
/** The loaded domain roster, or none. FAIL-SOFT: a config that will not load must not throw out of a
|
|
70
|
+
* read contracted never to throw โ it degrades to the org-default credential, which is what a
|
|
71
|
+
* surface bound to no domain resolves anyway. */
|
|
72
|
+
function safeDomains(): readonly Domain[] {
|
|
73
|
+
try {
|
|
74
|
+
return getConfig().loadDomains();
|
|
75
|
+
} catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One sweep. The catch-up window is "since the seat last woke", and a page that collected more than
|
|
81
|
+
* this in that window is one core's token cap would trim regardless. */
|
|
82
|
+
const CATCH_UP_MAX_COMMENTS = 100;
|
|
83
|
+
|
|
84
|
+
/** Blocks the page sweep scans. Matches the read adapter's own bound, so a catch-up never costs more
|
|
85
|
+
* API calls than a `read_channel` on the same page. */
|
|
86
|
+
const CATCH_UP_MAX_BLOCK_SCANS = 80;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pages of comments the sweep follows PER ANCHOR (`notion-api.ts listBlockComments`).
|
|
90
|
+
* ๐ด ONE PAGE IS THE OLDEST HUNDRED, WHICH IS WHY THIS IS NOT 1 (OPE-1831). Notion serves comments
|
|
91
|
+
* oldest-first, so an anchor that has collected more than a page puts today's comment on the LAST
|
|
92
|
+
* page and the default single call can never reach it. A PAGE-ANCHORED discussion on any long-lived
|
|
93
|
+
* doc then reads as permanently quiet to its seat, with no error to show for it.
|
|
94
|
+
* IT COSTS NOTHING ON A NORMAL ANCHOR: the follow stops the moment Notion reports no more, so an
|
|
95
|
+
* anchor under a hundred comments is still exactly one call. Only an overflowing anchor pays, and
|
|
96
|
+
* five pages is five hundred comments โ far past any window a seat's last wake can span.
|
|
97
|
+
*
|
|
98
|
+
* ๐ด THE PER-WAKE CALL BUDGET, STATED BECAUSE THIS CHANGE GREW IT (OPE-1831). One catch-up now costs
|
|
99
|
+
* up to `1 + CATCH_UP_MAX_BLOCK_SCANS` list calls (the page anchor, then one per block), each up to
|
|
100
|
+
* `CATCH_UP_MAX_COMMENT_PAGES` pages โ so 81 calls typically and 405 at the theoretical ceiling,
|
|
101
|
+
* against the shared 60 s-per-call 429 budget (`channels/rate-limit-retry.ts`, OPE-1254).
|
|
102
|
+
* ๐ด AND THE OLD CHEAP PATH WAS THE BUG. Before `sinceMs`, a page whose own comments filled
|
|
103
|
+
* `maxComments` returned after ONE call โ cheap, and WRONG: that early return is exactly what stopped
|
|
104
|
+
* the block sweep and shipped #208. Paying the sweep is the correction, not a regression to undo.
|
|
105
|
+
* The 405 ceiling needs every one of 81 anchors to hold over 100 comments, which no real page does;
|
|
106
|
+
* the realistic cost is one extra call or two on the page anchor alone.
|
|
107
|
+
*/
|
|
108
|
+
const CATCH_UP_MAX_COMMENT_PAGES = 5;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Normalize ONE Notion comment, applying the VISIBILITY GATE every fetch adapter owes core: a comment
|
|
112
|
+
* with no author, no text or no readable time is dropped, because an unattributable body must never
|
|
113
|
+
* reach a seat. The deployment's OWN integration id resolves `self`.
|
|
114
|
+
*
|
|
115
|
+
* ๐ด `authorKind` IS ABSENT ON ALMOST EVERY COMMENT, AND READING THAT AS "NOT A PERSON" IS OPE-1857.
|
|
116
|
+
* It is Notion's own actor type, and Notion serves a comment's `created_by` as a PARTIAL user โ
|
|
117
|
+
* `id` and `object`, no `type` (notion-api.ts, whose interface says so). This line used to read
|
|
118
|
+
* `authorKind === "person" ? "human" : "bot"`, so EVERY comment this surface read arrived tagged
|
|
119
|
+
* `bot` โ and the default `wide` admission policy refuses every bot, on every wake, for ever. Ship
|
|
120
|
+
* #212 measured it: `seat.catch_up outcome=empty raw=4 kept=0 reason=admission-dropped-all` on a
|
|
121
|
+
* read the bounds had kept in full. A real page member's comment was refused by that arithmetic
|
|
122
|
+
* exactly as the e2e driver's was, so the seat read a page it was being spoken on as silent.
|
|
123
|
+
* ๐ด THE RULE IS CORE'S NOW ({@link catchUpSender}), and this surface contributes only the SIGNAL:
|
|
124
|
+
* a kind Notion actually stated, or `undefined` when it stated none. The repository's own trigger
|
|
125
|
+
* road already read it this way โ `classifyNotionComment` (notion-supervisor.ts) drops on
|
|
126
|
+
* `author?.type && author.type !== "person"`, so an absent type is a person there too. The two roads
|
|
127
|
+
* disagreeing about one comment is the whole defect.
|
|
128
|
+
*/
|
|
129
|
+
function normalizeComment(c: NotionCommentSummary, selfActorIds: ReadonlySet<string>): NormalizedMessage | undefined {
|
|
130
|
+
const text = c.text.trim();
|
|
131
|
+
if (text.length === 0 || !c.authorId || !c.discussionId) return undefined;
|
|
132
|
+
const ms = Date.parse(c.createdTime ?? "");
|
|
133
|
+
if (!Number.isFinite(ms)) return undefined;
|
|
134
|
+
// OUR OWN VOICE IS THE DEPLOYMENT'S, NOT ONE INTEGRATION'S (OPE-1921) โ see collectCatchUpSelfIds.
|
|
135
|
+
const isSelf = selfActorIds.has(c.authorId);
|
|
136
|
+
return {
|
|
137
|
+
// ๐ด THE COMMENT'S OWN NOTION ID, AND IT HAS TO BE (OPE-1847). The wake route passes the trigger's
|
|
138
|
+
// comment id as `excludeId`, and core removes the wake's own message BY ID alone โ time cannot do
|
|
139
|
+
// it here, because `created_time` floors to the MINUTE and the trigger therefore shares its
|
|
140
|
+
// instant with every comment the seat is reading for. This slot used to hold a render label
|
|
141
|
+
// (`<discussion>:<created_time>`) that could never equal that id, so the trigger had to be bounded
|
|
142
|
+
// out by `untilMs` โ which took the seat's whole catch-up with it and shipped #211 red.
|
|
143
|
+
// The label survives only as the fallback for a comment Notion served with no id, where nothing
|
|
144
|
+
// better exists; it is NOT unique (a coarse stamp lets two comments in one discussion collide).
|
|
145
|
+
id: c.id ?? `${c.discussionId}:${c.createdTime ?? ms}`,
|
|
146
|
+
ms,
|
|
147
|
+
// The bot's OWN posts carry no author id, matching every other surface's normalized shape.
|
|
148
|
+
...(isSelf ? {} : { authorId: c.authorId }),
|
|
149
|
+
sender: catchUpSender({ self: isSelf, ...(c.authorKind === undefined ? {} : { bot: c.authorKind !== "person" }) }),
|
|
150
|
+
// A Notion wake is thread-first: every comment belongs to a discussion, which IS the thread.
|
|
151
|
+
type: "thread",
|
|
152
|
+
threadId: c.discussionId,
|
|
153
|
+
text,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Injected seam so the fetch drives offline in tests; defaults to the live brokered credential. */
|
|
158
|
+
export interface NotionCatchUpDeps {
|
|
159
|
+
readonly readComments: typeof readNotionComments;
|
|
160
|
+
readonly resolveCredential: typeof resolveNotionCredential;
|
|
161
|
+
/** The domain roster the per-surface credential is resolved against. Injected so the fetch drives
|
|
162
|
+
* with no config registered; the live default is the loaded roster. */
|
|
163
|
+
readonly domains?: readonly Domain[];
|
|
164
|
+
/** ONE own Notion bot id, pinning the whole self set โ a TEST override, so a fetch drives with a
|
|
165
|
+
* fixed identity and resolves nothing. Present โ {@link selfActorIdFor} is never called and no
|
|
166
|
+
* sibling integration is resolved. The live read builds the set through `collectCatchUpSelfIds`. */
|
|
167
|
+
readonly selfActorId?: string;
|
|
168
|
+
/**
|
|
169
|
+
* Resolve the deployment's own bot user id for ONE integration. The live default is the SAME
|
|
170
|
+
* per-credential resolver and cache the ingress road reads (`notion-bot-user.ts`), so the catch-up
|
|
171
|
+
* and the loop guard can never hold two answers for one integration. `collectCatchUpSelfIds` calls
|
|
172
|
+
* it once per CONFIGURED integration, so the seat recognises its own words whichever one posted
|
|
173
|
+
* them (OPE-1921).
|
|
174
|
+
*
|
|
175
|
+
* ๐ด IT IS LOAD-BEARING SINCE OPE-1857, NOT A LABEL. Notion's comment payload carries a partial
|
|
176
|
+
* `created_by` โ an id and an object, no `type` โ so this read cannot tell our own reply from a
|
|
177
|
+
* person's by SHAPE. Before OPE-1857 an absent `type` made every author a `bot`, which refused our
|
|
178
|
+
* own comment as a side effect of refusing everyone. Now that an unsignalled author is a person,
|
|
179
|
+
* the id comparison is the ONLY thing keeping the seat's own previous reply โ which lands in the
|
|
180
|
+
* very discussion this read sweeps โ out of its next wake as another person's words.
|
|
181
|
+
* ๐ด THE READING CREDENTIAL AND THE POSTING ONE CAN DIFFER, AND THAT NO LONGER LOSES THE REPLY.
|
|
182
|
+
* This read's credential comes from the page's DOMAIN BINDING, while the reply sitting in the
|
|
183
|
+
* discussion was posted under the DELIVERY's carried credential, which wins where the two differ
|
|
184
|
+
* (OPE-499, `notion-credentials.ts`). On a page served by two integrations they disagree, and the
|
|
185
|
+
* seat's own reply used to read as a person's. `collectCatchUpSelfIds` resolves BOTH, so the
|
|
186
|
+
* comparison no longer depends on which credential posted โ and the delivery's credential id,
|
|
187
|
+
* which this seam once needed on `SeatCatchUpRequest` to close this, is not required after all.
|
|
188
|
+
* ๐ด AN UNRESOLVED ID DOES NOT BLIND THIS READ, and it does not have to: the INGRESS road fails
|
|
189
|
+
* CLOSED on the same id from the same cache (`classifyNotionComment` drops on "unresolved bot user
|
|
190
|
+
* id"), so a deployment that cannot resolve it dispatches no Notion wake at all and takes no
|
|
191
|
+
* catch-up. Blinding the read here as well would re-create the OPE-1857 symptom โ a seat that
|
|
192
|
+
* keeps nothing โ out of a transient `GET /v1/users/me` failure.
|
|
193
|
+
*/
|
|
194
|
+
readonly selfActorIdFor?: (credentialId: string, credential: () => Promise<ResolvedNotionCredential | undefined>) => Promise<string | undefined>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const liveDeps: NotionCatchUpDeps = {
|
|
198
|
+
readComments: readNotionComments,
|
|
199
|
+
resolveCredential: resolveNotionCredential,
|
|
200
|
+
selfActorIdFor: async (credentialId, credential) => (await resolveNotionBotUser(credentialId, credential)()).id,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* EVERY configured integration's bot user id, for the same reason the ingress road takes a SET
|
|
205
|
+
* (OPE-1921): this deployment posts under one bot user id PER INTEGRATION, and a reply a SIBLING
|
|
206
|
+
* integration posted lands in the very discussion this read sweeps. Against the reading
|
|
207
|
+
* integration's id alone it matched nothing, so the seat was handed its own words as another
|
|
208
|
+
* person's โ at the `unauth` floor, in the context it then answers from.
|
|
209
|
+
*
|
|
210
|
+
* ๐ด THE BLOCKER THIS FILE USED TO NAME IS GONE. It said closing this needed the DELIVERY's
|
|
211
|
+
* credential id on the shared `SeatCatchUpRequest`, which carries none. That was true only while the
|
|
212
|
+
* answer had to be ONE id: the set does not care which integration posted, so no new request field
|
|
213
|
+
* is needed. What remains is the ordinary residual โ an integration whose id will not resolve.
|
|
214
|
+
*
|
|
215
|
+
* FAIL-SOFT PER INTEGRATION, and DELIBERATELY NOT fail-closed like the ingress road. An id this
|
|
216
|
+
* cannot resolve just leaves that integration's comments reading as a person's, which is where this
|
|
217
|
+
* read already stood; blinding the catch-up on a transient `GET /v1/users/me` failure would
|
|
218
|
+
* re-create the OPE-1857 symptom โ a seat that keeps nothing.
|
|
219
|
+
* ๐ด AND THE INGRESS ARM DOES NOT COVER THIS ONE, SO DO NOT READ IT AS A BACKSTOP. An unresolved
|
|
220
|
+
* PRIMARY id blocks every wake, so that case really does take no catch-up. An unresolved SIBLING
|
|
221
|
+
* does not: `classifyNotionComment` step 5 drops only authors Notion does not mark `person`, so a
|
|
222
|
+
* person-marked comment still wakes a turn, still takes a catch-up, and that integration's own
|
|
223
|
+
* prior replies then enter the seat's context as a person's words at the `unauth` floor. That
|
|
224
|
+
* residual is the price of the fail-soft posture here, and it is stated rather than covered.
|
|
225
|
+
*
|
|
226
|
+
* Cost: one `GET /v1/users/me` per SIBLING integration, memoised for the process lifetime by
|
|
227
|
+
* `resolveNotionBotUser`'s per-credential cache โ against the up-to-81 list calls the sweep itself
|
|
228
|
+
* makes. The sibling's CREDENTIAL is passed as a thunk, so on a cache hit it is never resolved at
|
|
229
|
+
* all; `resolveNotionCredential` has no memo of its own, and an eager resolve here would charge
|
|
230
|
+
* every wake a store read and a decrypt. A single-integration deployment resolves exactly what it
|
|
231
|
+
* did before.
|
|
232
|
+
*/
|
|
233
|
+
async function collectCatchUpSelfIds(
|
|
234
|
+
readingCredentialId: string,
|
|
235
|
+
readingCredential: ResolvedNotionCredential,
|
|
236
|
+
domains: readonly Domain[],
|
|
237
|
+
deps: NotionCatchUpDeps,
|
|
238
|
+
): Promise<ReadonlySet<string>> {
|
|
239
|
+
const ids = new Set<string>();
|
|
240
|
+
const add = async (credentialId: string, credential: () => Promise<ResolvedNotionCredential | undefined>): Promise<void> => {
|
|
241
|
+
try {
|
|
242
|
+
const id = await deps.selfActorIdFor?.(credentialId, credential);
|
|
243
|
+
if (id) ids.add(id);
|
|
244
|
+
} catch {
|
|
245
|
+
/* an id we cannot resolve leaves that integration's comments reading as a person's */
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
await add(readingCredentialId, async () => readingCredential);
|
|
249
|
+
for (const integration of notionIntegrations(domains)) {
|
|
250
|
+
if (integration.credentialId === readingCredentialId) continue;
|
|
251
|
+
// ๐ด A THUNK, NOT A RESOLVED CREDENTIAL. `resolveNotionBotUser` memoises the bot user per
|
|
252
|
+
// credential and NEVER CALLS the thunk on a cache hit, while `resolveNotionCredential` has no
|
|
253
|
+
// memo of its own โ so resolving eagerly here would charge every wake an operational-store read
|
|
254
|
+
// and a decrypt for an answer the cache already holds, and would write one
|
|
255
|
+
// credential-unresolved audit row per wake, for ever, on a sibling with no stored secret.
|
|
256
|
+
await add(integration.credentialId, () => deps.resolveCredential(integration.credentialId));
|
|
257
|
+
}
|
|
258
|
+
return ids;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The fetch, with its seams exposed so the contract test drives it with no network. */
|
|
262
|
+
export async function fetchNotionCatchUpWindow(
|
|
263
|
+
req: { container: string; threadRoot?: string; threadAnchor?: string; sinceMs: number; channelKey?: string },
|
|
264
|
+
deps: NotionCatchUpDeps = liveDeps,
|
|
265
|
+
): Promise<readonly NormalizedMessage[]> {
|
|
266
|
+
// The DOMAIN's own integration where this surface binds one, else the org default โ the same
|
|
267
|
+
// resolution `notionReadAdapter` makes, so the catch-up reads the page over the credential the
|
|
268
|
+
// operator selected for it rather than a broader one.
|
|
269
|
+
const domains = deps.domains ?? safeDomains();
|
|
270
|
+
const credentialId = req.channelKey ? notionChannelCredentialForSurface(req.channelKey, domains) : undefined;
|
|
271
|
+
const cred = await deps.resolveCredential(credentialId);
|
|
272
|
+
if (!cred) return []; // unconfigured โ nothing to read, never a guessed key
|
|
273
|
+
// OUR OWN IDENTITY, under the SAME key the ingress road caches it by: an absent `credentialId`
|
|
274
|
+
// means the ORG DEFAULT, which is stored as `NOTION_CHANNEL_CREDENTIAL` โ so both roads memoise
|
|
275
|
+
// one integration under one key rather than resolving it twice. EVERY configured integration's id,
|
|
276
|
+
// for the reason the ingress road takes the set (OPE-1921) โ see `selfActorIds` below.
|
|
277
|
+
const readingCredentialId = credentialId ?? NOTION_CHANNEL_CREDENTIAL;
|
|
278
|
+
const selfActorIds = deps.selfActorId !== undefined
|
|
279
|
+
? new Set([deps.selfActorId])
|
|
280
|
+
: await collectCatchUpSelfIds(readingCredentialId, cred, domains, deps);
|
|
281
|
+
const res = await deps.readComments(req.container, {
|
|
282
|
+
// ๐ด THE ENDPOINT FENCE TRAVELS WITH THE KEY, unconditionally and ON ONE LINE: a bare `apiKey`
|
|
283
|
+
// silently disables it, and `notion-credential-fence-test.ts` ยง2 reads the pair line by line โ
|
|
284
|
+
// so splitting them reads as a bare key to the rule that exists to stop one. An absent
|
|
285
|
+
// `endpoints` is already the unnarrowed reading, so the pair is always passed together.
|
|
286
|
+
apiKey: cred.apiKey, endpoints: cred.endpoints,
|
|
287
|
+
maxComments: CATCH_UP_MAX_COMMENTS,
|
|
288
|
+
maxBlockScans: CATCH_UP_MAX_BLOCK_SCANS,
|
|
289
|
+
// ๐ด THE WINDOW GOES TO THE SWEEP, not only to the loop below (OPE-1831). `maxComments` is
|
|
290
|
+
// charged in the order Notion serves โ OLDEST FIRST โ so a cap applied over the whole history
|
|
291
|
+
// fills with a page's most ancient comments and truncates before it reaches the seat's own
|
|
292
|
+
// window. Handing the bound down makes the cap count comments IN the window instead.
|
|
293
|
+
sinceMs: req.sinceMs,
|
|
294
|
+
maxCommentPages: CATCH_UP_MAX_COMMENT_PAGES,
|
|
295
|
+
// ๐ด THE BLOCK BUDGET IS SPENT ON THE PAGE'S NEWEST BLOCKS (OPE-1843). Document order spends
|
|
296
|
+
// all 80 scans on the oldest material of a page that has outgrown the cap, so a discussion on a
|
|
297
|
+
// recently appended block is never scanned and the seat reads a quiet room. A catch-up asks
|
|
298
|
+
// "what happened since I last woke", and the growth shape that crosses the cap is append-only.
|
|
299
|
+
// No effect on a page under the cap, and none on the `block` drill-in above, which scans one
|
|
300
|
+
// named block. The residual โ a new comment on an ANCIENT block of an over-cap page โ is
|
|
301
|
+
// `readNotionComments`'s own to state, and it does: see `blockScanOrder`.
|
|
302
|
+
blockScanOrder: "newest-first",
|
|
303
|
+
// An inline comment's authenticated parent block addresses the exact discussion anchor. The
|
|
304
|
+
// discussion id is not a block id and remains only the filter below.
|
|
305
|
+
...(req.threadAnchor ? { block: req.threadAnchor } : {}),
|
|
306
|
+
});
|
|
307
|
+
const out: NormalizedMessage[] = [];
|
|
308
|
+
for (const c of res.comments) {
|
|
309
|
+
// The seat's own discussion, filtered HERE โ see the header for why there is no endpoint that
|
|
310
|
+
// could do it. A seat bound to no discussion reads the whole page.
|
|
311
|
+
if (req.threadRoot !== undefined && c.discussionId !== req.threadRoot) continue;
|
|
312
|
+
const n = normalizeComment(c, selfActorIds);
|
|
313
|
+
if (n && n.ms >= req.sinceMs) out.push(n);
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* ๐ด NOTION FLOORS `created_time` TO THE MINUTE, and this is the number the shared catch-up road
|
|
320
|
+
* widens both of its bounds by (OPE-1847, `core/agent/channels/seat-catch-up.ts`
|
|
321
|
+
* {@link SeatCatchUpDeclaration.timestampPrecisionMs}).
|
|
322
|
+
* It is the VENDOR'S UNIT, not a margin: every comment posted inside one wall-clock minute comes back
|
|
323
|
+
* carrying that minute's opening instant. Declare it too small and a comment in the trigger's own
|
|
324
|
+
* minute is bounded out of the window it belongs to โ the ship #211 red, where the seat read two
|
|
325
|
+
* comments and kept none. Declare it too large and the seat is handed comments from before its window.
|
|
326
|
+
* Measured on the live workspace (OPE-1711) and the repository's own e2e driver floors to the same
|
|
327
|
+
* unit (`core/scripts/e2e/notion-driver.ts` `notionMinuteFloor`).
|
|
328
|
+
*/
|
|
329
|
+
export const NOTION_TIMESTAMP_PRECISION_MS = 60_000;
|
|
330
|
+
|
|
331
|
+
/** Notion's registered {@link SeatCatchUpFetch} โ the one thing this surface contributes to the shared path. */
|
|
332
|
+
export const notionSeatCatchUpFetch: SeatCatchUpFetch = (req) =>
|
|
333
|
+
fetchNotionCatchUpWindow({
|
|
334
|
+
container: req.container,
|
|
335
|
+
...(req.threadRoot ? { threadRoot: req.threadRoot } : {}),
|
|
336
|
+
...(req.threadAnchor ? { threadAnchor: req.threadAnchor } : {}),
|
|
337
|
+
sinceMs: req.sinceMs,
|
|
338
|
+
...(req.channelKey ? { channelKey: req.channelKey } : {}),
|
|
339
|
+
});
|