@indigoai-us/hq-cli 5.345.177 → 5.345.179
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/CHANGELOG.md +50 -0
- package/dist/command-catalog.generated.d.ts +79 -1
- package/dist/command-catalog.generated.js +96 -1
- package/dist/commands/bot-jobs.d.ts +11 -9
- package/dist/commands/bot-jobs.js +56 -21
- package/dist/commands/bot.d.ts +13 -0
- package/dist/commands/bot.js +230 -21
- package/dist/commands/cloud.d.ts +49 -0
- package/dist/commands/cloud.js +155 -15
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +15 -4
- package/dist/commands/dm-attach.js +15 -3
- package/dist/commands/integrations-core.d.ts +2 -0
- package/dist/commands/integrations-core.js +1 -0
- package/dist/commands/library-add.d.ts +9 -0
- package/dist/commands/library-add.js +19 -2
- package/dist/commands/run.js +9 -1
- package/dist/commands/sources.d.ts +89 -0
- package/dist/commands/sources.js +363 -1
- package/dist/lib/bot/config.d.ts +2 -0
- package/dist/lib/bot/fleet-default-flag.d.ts +31 -0
- package/dist/lib/bot/fleet-default-flag.js +127 -0
- package/dist/lib/bot/fleet-local.d.ts +30 -1
- package/dist/lib/bot/fleet-local.js +86 -11
- package/dist/lib/bot/index.d.ts +1 -0
- package/dist/lib/bot/index.js +1 -0
- package/dist/lib/bot/jobs/hot-signal-sync.d.ts +109 -0
- package/dist/lib/bot/jobs/hot-signal-sync.js +177 -0
- package/dist/lib/bot/local-config.js +1 -1
- package/dist/lib/bot/local-jobs.d.ts +40 -4
- package/dist/lib/bot/local-jobs.js +72 -5
- package/dist/lib/bot/run.d.ts +15 -0
- package/dist/lib/bot/run.js +61 -0
- package/dist/lib/daemon/catalog.d.ts +2 -0
- package/dist/lib/daemon/catalog.js +7 -2
- package/dist/lib/daemon/config.d.ts +2 -1
- package/dist/lib/daemon/config.js +4 -3
- package/dist/lib/daemon/handoff.d.ts +4 -0
- package/dist/lib/daemon/handoff.js +6 -2
- package/dist/lib/daemon/install.d.ts +70 -2
- package/dist/lib/daemon/install.js +185 -27
- package/dist/lib/daemon/legacy.d.ts +9 -3
- package/dist/lib/daemon/legacy.js +11 -4
- package/dist/lib/daemon/migrate.js +1 -1
- package/dist/lib/daemon/run.d.ts +3 -2
- package/dist/lib/daemon/run.js +10 -6
- package/dist/lib/doctor/checks/anywhere.d.ts +6 -0
- package/dist/lib/doctor/checks/anywhere.js +35 -6
- package/dist/lib/hot-channels/adapters/__fixtures__/dummy.d.ts +33 -0
- package/dist/lib/hot-channels/adapters/__fixtures__/dummy.js +53 -0
- package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.d.ts +38 -0
- package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.js +64 -0
- package/dist/lib/hot-channels/adapters/attio.d.ts +67 -0
- package/dist/lib/hot-channels/adapters/attio.js +374 -0
- package/dist/lib/hot-channels/adapters/gmail.d.ts +111 -0
- package/dist/lib/hot-channels/adapters/gmail.js +413 -0
- package/dist/lib/hot-channels/adapters/index.d.ts +23 -0
- package/dist/lib/hot-channels/adapters/index.js +39 -0
- package/dist/lib/hot-channels/adapters/linear.d.ts +79 -0
- package/dist/lib/hot-channels/adapters/linear.js +321 -0
- package/dist/lib/hot-channels/adapters/meeting-providers.d.ts +86 -0
- package/dist/lib/hot-channels/adapters/meeting-providers.js +253 -0
- package/dist/lib/hot-channels/adapters/meetings.d.ts +81 -0
- package/dist/lib/hot-channels/adapters/meetings.js +523 -0
- package/dist/lib/hot-channels/adapters/slack.d.ts +70 -0
- package/dist/lib/hot-channels/adapters/slack.js +277 -0
- package/dist/lib/hot-channels/alert.d.ts +78 -0
- package/dist/lib/hot-channels/alert.js +179 -0
- package/dist/lib/hot-channels/audience.d.ts +15 -0
- package/dist/lib/hot-channels/audience.js +25 -0
- package/dist/lib/hot-channels/contract.d.ts +140 -0
- package/dist/lib/hot-channels/contract.js +20 -0
- package/dist/lib/hot-channels/cursors.d.ts +41 -0
- package/dist/lib/hot-channels/cursors.js +84 -0
- package/dist/lib/hot-channels/index.d.ts +10 -0
- package/dist/lib/hot-channels/index.js +10 -0
- package/dist/lib/hot-channels/landing.d.ts +83 -0
- package/dist/lib/hot-channels/landing.js +175 -0
- package/dist/lib/hot-channels/ledger.d.ts +101 -0
- package/dist/lib/hot-channels/ledger.js +186 -0
- package/dist/lib/hot-channels/loop.d.ts +74 -0
- package/dist/lib/hot-channels/loop.js +219 -0
- package/dist/lib/hot-channels/prompts/signals-pass.md +22 -0
- package/dist/lib/hot-channels/registry.d.ts +28 -0
- package/dist/lib/hot-channels/registry.js +121 -0
- package/dist/lib/hot-channels/run.d.ts +137 -0
- package/dist/lib/hot-channels/run.js +324 -0
- package/dist/lib/hot-channels/signals-pass.d.ts +150 -0
- package/dist/lib/hot-channels/signals-pass.js +581 -0
- package/dist/lib/hot-channels/sync-lock.d.ts +51 -0
- package/dist/lib/hot-channels/sync-lock.js +224 -0
- package/dist/lib/install/hook-target.d.ts +6 -2
- package/dist/lib/install/hook-target.js +9 -4
- package/dist/lib/lanes/loop.d.ts +10 -0
- package/dist/lib/lanes/loop.js +18 -1
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +26 -6
- package/dist/run/hq-plugin.d.ts +3 -0
- package/dist/run/hq-plugin.js +18 -0
- package/package.json +7 -7
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attio hot channel (hot-signal-sync US-010).
|
|
3
|
+
*
|
|
4
|
+
* The Attio webhook receiver in hq-pro-agents
|
|
5
|
+
* (`src/sources/inbound/attio-events.ts`) is the push half: it lands one
|
|
6
|
+
* document per delivery in the company vault under `sources/attio/`, with
|
|
7
|
+
* `event_key: attio:{Idempotency-Key}` in the frontmatter and the webhook
|
|
8
|
+
* payload in the `.raw.json` sidecar. This adapter is the drain half, a poll
|
|
9
|
+
* adapter over that folder, the same shape as Slack (US-006). The cursor is
|
|
10
|
+
* the newest `ingested_at` it has read.
|
|
11
|
+
*
|
|
12
|
+
* The hq-pro catalog entry for attio still says `transport: push`. run.ts
|
|
13
|
+
* plans by the registered adapter's transport, so this adapter runs once per
|
|
14
|
+
* usable Attio connection either way; changing the catalog entry to `poll` is
|
|
15
|
+
* an hq-pro follow-up.
|
|
16
|
+
*
|
|
17
|
+
* Attio webhooks are workspace-level, so the folder holds every event in the
|
|
18
|
+
* workspace. Per-person scoping happens here (US-009 rule): a delivery is kept
|
|
19
|
+
* only when the person's Attio workspace-member id matches
|
|
20
|
+
* 1. the actor of an event in the delivery,
|
|
21
|
+
* 2. the owner attribute of the referenced record, or
|
|
22
|
+
* 3. an assignee or mention on the referenced comment or note.
|
|
23
|
+
* The member id comes from the person's user-level Attio connection: the
|
|
24
|
+
* gateway's `list-workspace-members` result, matched by email against the
|
|
25
|
+
* person's identities. Record owners come from the payload when present and
|
|
26
|
+
* from the gateway's `get-records-by-ids` otherwise. Assignees and mentions
|
|
27
|
+
* are read from the payload. With no member id the adapter emits nothing and
|
|
28
|
+
* leaves the cursor where it was.
|
|
29
|
+
*
|
|
30
|
+
* Each landed document is a pointer. Its body is a short header and
|
|
31
|
+
* `payload_ref` names the landed source document. No payload is copied: the
|
|
32
|
+
* `.raw.json` sidecar holds the source frontmatter and why the delivery
|
|
33
|
+
* matched.
|
|
34
|
+
*/
|
|
35
|
+
import * as fs from "node:fs/promises";
|
|
36
|
+
import * as path from "node:path";
|
|
37
|
+
import * as yaml from "js-yaml";
|
|
38
|
+
import { MAX_CATCH_UP_WINDOW_MS } from "../contract.js";
|
|
39
|
+
import { HOT_SIGNAL_ORIGIN } from "../landing.js";
|
|
40
|
+
export const ATTIO_ADAPTER_ID = "attio";
|
|
41
|
+
export const ATTIO_CHANNEL = "attio-events";
|
|
42
|
+
export const ATTIO_PROVIDER = "attio";
|
|
43
|
+
export const ATTIO_SOURCE_DIR = "sources/attio";
|
|
44
|
+
export const ATTIO_RECORD_EVENT = "attio.record";
|
|
45
|
+
export const ATTIO_COMMENT_EVENT = "attio.comment";
|
|
46
|
+
export const ATTIO_NOTE_EVENT = "attio.note";
|
|
47
|
+
export const ATTIO_LIST_MEMBERS_TOOL = "list-workspace-members";
|
|
48
|
+
export const ATTIO_GET_RECORDS_TOOL = "get-records-by-ids";
|
|
49
|
+
/**
|
|
50
|
+
* Suggestion rule data: Attio is suggested when the person owns a record with
|
|
51
|
+
* activity in the last 14 days. The contract's `owned-by-me` rule has no
|
|
52
|
+
* window field, so the window is declared here for the evaluator (US-014).
|
|
53
|
+
*/
|
|
54
|
+
export const ATTIO_SUGGESTION_ACTIVITY_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
|
|
55
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
56
|
+
/** Source frontmatter copied to the raw sidecar. */
|
|
57
|
+
const RAW_FIELDS = ["source_id", "event_key", "event_type", "event_count", "ingested_at", "company_id"];
|
|
58
|
+
function isRecord(value) {
|
|
59
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
function nonEmptyString(value) {
|
|
62
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
|
63
|
+
}
|
|
64
|
+
function parseTime(value) {
|
|
65
|
+
const stamp = nonEmptyString(value);
|
|
66
|
+
return stamp === undefined ? NaN : Date.parse(stamp);
|
|
67
|
+
}
|
|
68
|
+
/** Split `---\n{yaml}\n---\n{body}` and return the frontmatter. Unparseable or non-object is null. */
|
|
69
|
+
function readFrontmatter(text) {
|
|
70
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
|
|
71
|
+
if (!match)
|
|
72
|
+
return null;
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = yaml.load(match[1], { schema: yaml.CORE_SCHEMA });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return isRecord(parsed) ? parsed : null;
|
|
81
|
+
}
|
|
82
|
+
function toDelivery(file, fm) {
|
|
83
|
+
if (fm.origin === HOT_SIGNAL_ORIGIN)
|
|
84
|
+
return null;
|
|
85
|
+
const eventKey = nonEmptyString(fm.event_key);
|
|
86
|
+
const eventType = nonEmptyString(fm.event_type);
|
|
87
|
+
const tsMs = parseTime(fm.ingested_at);
|
|
88
|
+
if (!eventKey?.startsWith("attio:") || !eventType || Number.isNaN(tsMs))
|
|
89
|
+
return null;
|
|
90
|
+
const idempotencyKey = eventKey.slice("attio:".length);
|
|
91
|
+
if (idempotencyKey === "")
|
|
92
|
+
return null;
|
|
93
|
+
return {
|
|
94
|
+
file,
|
|
95
|
+
sourceId: nonEmptyString(fm.source_id) ?? file.replace(/\.md$/, ""),
|
|
96
|
+
tsMs,
|
|
97
|
+
idempotencyKey,
|
|
98
|
+
eventType,
|
|
99
|
+
frontmatter: fm,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** The webhook payload's events: `{ events: [...] }`, or the payload itself. */
|
|
103
|
+
function payloadEvents(payload) {
|
|
104
|
+
if (!isRecord(payload))
|
|
105
|
+
return [];
|
|
106
|
+
const events = Array.isArray(payload.events) ? payload.events : [payload];
|
|
107
|
+
return events.filter(isRecord);
|
|
108
|
+
}
|
|
109
|
+
/** A workspace-member id from an actor-reference shape, or a bare id string. */
|
|
110
|
+
function actorRefId(value) {
|
|
111
|
+
if (typeof value === "string")
|
|
112
|
+
return nonEmptyString(value);
|
|
113
|
+
if (!isRecord(value))
|
|
114
|
+
return undefined;
|
|
115
|
+
if (value.type !== undefined && value.type !== "workspace-member")
|
|
116
|
+
return undefined;
|
|
117
|
+
if (value.referenced_actor_type !== undefined && value.referenced_actor_type !== "workspace-member")
|
|
118
|
+
return undefined;
|
|
119
|
+
return (nonEmptyString(value.referenced_actor_id) ??
|
|
120
|
+
nonEmptyString(value.workspace_member_id) ??
|
|
121
|
+
(isRecord(value.id) ? nonEmptyString(value.id.workspace_member_id) : nonEmptyString(value.id)));
|
|
122
|
+
}
|
|
123
|
+
function actorRefIds(value) {
|
|
124
|
+
const list = Array.isArray(value) ? value : value === undefined ? [] : [value];
|
|
125
|
+
return list.map(actorRefId).filter((id) => id !== undefined);
|
|
126
|
+
}
|
|
127
|
+
/** Owner ids on a record: `values.owner` (Attio record shape) or a top-level `owner`. */
|
|
128
|
+
function recordOwnerIds(record) {
|
|
129
|
+
const values = isRecord(record.values) ? record.values : {};
|
|
130
|
+
return [...actorRefIds(values.owner), ...actorRefIds(record.owner)];
|
|
131
|
+
}
|
|
132
|
+
/** Assignee and mention ids on one event's comment or note, wherever the payload carries them. */
|
|
133
|
+
function assigneeAndMentionIds(evt) {
|
|
134
|
+
const holders = [evt, evt.comment, evt.note, evt.data].filter(isRecord);
|
|
135
|
+
const assignees = [];
|
|
136
|
+
const mentions = [];
|
|
137
|
+
for (const h of holders) {
|
|
138
|
+
assignees.push(...actorRefIds(h.assignees), ...actorRefIds(h.assignee));
|
|
139
|
+
mentions.push(...actorRefIds(h.mentions), ...actorRefIds(h.mentioned_workspace_members));
|
|
140
|
+
}
|
|
141
|
+
return { assignees, mentions };
|
|
142
|
+
}
|
|
143
|
+
/** The record an event refers to: `id.{object_id,record_id}`, or a comment/note parent. */
|
|
144
|
+
function recordRef(evt) {
|
|
145
|
+
const id = isRecord(evt.id) ? evt.id : {};
|
|
146
|
+
const objectId = nonEmptyString(id.object_id) ?? nonEmptyString(evt.object_id) ?? nonEmptyString(evt.parent_object_id);
|
|
147
|
+
const recordId = nonEmptyString(id.record_id) ?? nonEmptyString(evt.record_id) ?? nonEmptyString(evt.parent_record_id);
|
|
148
|
+
return objectId && recordId ? { objectId, recordId } : null;
|
|
149
|
+
}
|
|
150
|
+
/** Unwrap a gateway result: a JSON string, an MCP `content[].text` block, or an object. */
|
|
151
|
+
function unwrapResult(result) {
|
|
152
|
+
if (typeof result === "string") {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(result);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (isRecord(result) && Array.isArray(result.content)) {
|
|
161
|
+
const text = result.content
|
|
162
|
+
.map((block) => (isRecord(block) ? nonEmptyString(block.text) : undefined))
|
|
163
|
+
.find((value) => value !== undefined);
|
|
164
|
+
if (text !== undefined)
|
|
165
|
+
return unwrapResult(text);
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
function listOf(body) {
|
|
170
|
+
if (Array.isArray(body))
|
|
171
|
+
return body;
|
|
172
|
+
if (!isRecord(body))
|
|
173
|
+
return [];
|
|
174
|
+
for (const key of ["data", "members", "workspace_members", "records"]) {
|
|
175
|
+
if (Array.isArray(body[key]))
|
|
176
|
+
return body[key];
|
|
177
|
+
}
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
async function lookupMemberIds(gateway, emails) {
|
|
181
|
+
if (emails.size === 0)
|
|
182
|
+
return [];
|
|
183
|
+
const members = listOf(unwrapResult(await gateway.call(ATTIO_LIST_MEMBERS_TOOL, {})));
|
|
184
|
+
const ids = [];
|
|
185
|
+
for (const m of members) {
|
|
186
|
+
if (!isRecord(m))
|
|
187
|
+
continue;
|
|
188
|
+
const email = nonEmptyString(m.email_address) ?? nonEmptyString(m.email);
|
|
189
|
+
const id = actorRefId(m);
|
|
190
|
+
if (email && id && emails.has(email.toLowerCase()))
|
|
191
|
+
ids.push(id);
|
|
192
|
+
}
|
|
193
|
+
return ids;
|
|
194
|
+
}
|
|
195
|
+
async function lookupOwnerIds(gateway, ref) {
|
|
196
|
+
const records = listOf(unwrapResult(await gateway.call(ATTIO_GET_RECORDS_TOOL, { object: ref.objectId, record_ids: [ref.recordId] })));
|
|
197
|
+
return records.filter(isRecord).flatMap(recordOwnerIds);
|
|
198
|
+
}
|
|
199
|
+
function eventWhat(eventType) {
|
|
200
|
+
if (eventType.startsWith("comment."))
|
|
201
|
+
return ATTIO_COMMENT_EVENT;
|
|
202
|
+
if (eventType.startsWith("note"))
|
|
203
|
+
return ATTIO_NOTE_EVENT;
|
|
204
|
+
return ATTIO_RECORD_EVENT;
|
|
205
|
+
}
|
|
206
|
+
function byTimeThenName(a, b) {
|
|
207
|
+
return a.tsMs - b.tsMs || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
|
|
208
|
+
}
|
|
209
|
+
export function createAttioAdapter(options) {
|
|
210
|
+
const dir = path.join(options.vaultRoot, ATTIO_SOURCE_DIR);
|
|
211
|
+
const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
212
|
+
if (!Number.isInteger(pageSize) || pageSize < 1)
|
|
213
|
+
throw new Error("attio pageSize must be a positive integer");
|
|
214
|
+
async function readPayload(d) {
|
|
215
|
+
try {
|
|
216
|
+
return JSON.parse(await fs.readFile(path.join(dir, `${d.file.replace(/\.md$/, "")}.raw.json`), "utf8"));
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function fetch(cursor, ctx) {
|
|
223
|
+
let names;
|
|
224
|
+
try {
|
|
225
|
+
names = await fs.readdir(dir);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
if (err.code === "ENOENT")
|
|
229
|
+
return { events: [], cursor, hasMore: false };
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
const identities = [ctx.personUid, ...(options.identities ?? [])].filter((id) => id !== "");
|
|
233
|
+
const emails = new Set(identities.filter((id) => id.includes("@")).map((id) => id.toLowerCase()));
|
|
234
|
+
const memberIds = new Set((options.memberIds && options.memberIds.length > 0
|
|
235
|
+
? [...options.memberIds]
|
|
236
|
+
: ctx.gateway
|
|
237
|
+
? await lookupMemberIds(ctx.gateway, emails)
|
|
238
|
+
: []).map((id) => id.toLowerCase()));
|
|
239
|
+
// Without the person's member id nothing can be scoped to them: emit
|
|
240
|
+
// nothing and keep the cursor, so the deliveries are read once it resolves.
|
|
241
|
+
if (memberIds.size === 0)
|
|
242
|
+
return { events: [], cursor, hasMore: false };
|
|
243
|
+
const isMe = (id) => memberIds.has(id.toLowerCase());
|
|
244
|
+
const sinceMs = Date.parse(ctx.since);
|
|
245
|
+
const cursorMs = cursor === null ? NaN : Date.parse(cursor);
|
|
246
|
+
const candidates = [];
|
|
247
|
+
for (const file of names.filter((n) => n.endsWith(".md")).sort()) {
|
|
248
|
+
let text;
|
|
249
|
+
try {
|
|
250
|
+
text = await fs.readFile(path.join(dir, file), "utf8");
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const fm = readFrontmatter(text);
|
|
256
|
+
const d = fm ? toDelivery(file, fm) : null;
|
|
257
|
+
if (!d || d.tsMs < sinceMs)
|
|
258
|
+
continue;
|
|
259
|
+
if (!Number.isNaN(cursorMs) && d.tsMs < cursorMs)
|
|
260
|
+
continue;
|
|
261
|
+
candidates.push(d);
|
|
262
|
+
}
|
|
263
|
+
candidates.sort(byTimeThenName);
|
|
264
|
+
// Deliveries at the cursor time are re-read every page so one landing later
|
|
265
|
+
// with the same ingest time is not lost; landing drops the duplicates. A
|
|
266
|
+
// page never splits one timestamp. The cursor moves past every delivery on
|
|
267
|
+
// the page, kept or not.
|
|
268
|
+
const atCursor = candidates.filter((d) => d.tsMs === cursorMs);
|
|
269
|
+
const newer = Number.isNaN(cursorMs) ? candidates : candidates.filter((d) => d.tsMs > cursorMs);
|
|
270
|
+
let end = Math.min(pageSize, newer.length);
|
|
271
|
+
while (end > 0 && end < newer.length && newer[end].tsMs === newer[end - 1].tsMs)
|
|
272
|
+
end += 1;
|
|
273
|
+
const page = newer.slice(0, end);
|
|
274
|
+
const ownerCache = new Map();
|
|
275
|
+
const ownersOf = (ref) => {
|
|
276
|
+
const key = `${ref.objectId}/${ref.recordId}`;
|
|
277
|
+
let hit = ownerCache.get(key);
|
|
278
|
+
if (!hit) {
|
|
279
|
+
hit = ctx.gateway ? lookupOwnerIds(ctx.gateway, ref) : Promise.resolve([]);
|
|
280
|
+
ownerCache.set(key, hit);
|
|
281
|
+
}
|
|
282
|
+
return hit;
|
|
283
|
+
};
|
|
284
|
+
const events = [];
|
|
285
|
+
for (const d of [...atCursor, ...page]) {
|
|
286
|
+
const evts = payloadEvents(await readPayload(d));
|
|
287
|
+
const reasons = new Set();
|
|
288
|
+
let actor;
|
|
289
|
+
let ref = null;
|
|
290
|
+
for (const evt of evts) {
|
|
291
|
+
const actorId = actorRefId(evt.actor);
|
|
292
|
+
actor ??= actorId;
|
|
293
|
+
if (actorId && isMe(actorId))
|
|
294
|
+
reasons.add("actor");
|
|
295
|
+
const { assignees, mentions } = assigneeAndMentionIds(evt);
|
|
296
|
+
if (assignees.some(isMe))
|
|
297
|
+
reasons.add("assignee");
|
|
298
|
+
if (mentions.some(isMe))
|
|
299
|
+
reasons.add("mention");
|
|
300
|
+
ref ??= recordRef(evt);
|
|
301
|
+
const inline = [evt.record, evt.data].filter(isRecord).flatMap(recordOwnerIds);
|
|
302
|
+
if (inline.some(isMe))
|
|
303
|
+
reasons.add("owner");
|
|
304
|
+
}
|
|
305
|
+
if (reasons.size === 0 && ref && (await ownersOf(ref)).some(isMe))
|
|
306
|
+
reasons.add("owner");
|
|
307
|
+
if (reasons.size === 0)
|
|
308
|
+
continue;
|
|
309
|
+
events.push(toEvent(d, [...reasons], actor, ref, ctx.personUid, options.identities));
|
|
310
|
+
}
|
|
311
|
+
const last = page[page.length - 1];
|
|
312
|
+
return {
|
|
313
|
+
events,
|
|
314
|
+
cursor: last ? new Date(last.tsMs).toISOString() : cursor,
|
|
315
|
+
hasMore: end < newer.length,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
id: ATTIO_ADAPTER_ID,
|
|
320
|
+
channel: ATTIO_CHANNEL,
|
|
321
|
+
transport: "poll",
|
|
322
|
+
hotEventTypes: [ATTIO_RECORD_EVENT, ATTIO_COMMENT_EVENT, ATTIO_NOTE_EVENT],
|
|
323
|
+
catchUpWindow: { maxLookbackMs: MAX_CATCH_UP_WINDOW_MS },
|
|
324
|
+
tokenScope: { provider: ATTIO_PROVIDER, scopes: [] },
|
|
325
|
+
// Records the person owns with activity in the last
|
|
326
|
+
// ATTIO_SUGGESTION_ACTIVITY_WINDOW_MS (14 days).
|
|
327
|
+
suggestionRule: { kind: "owned-by-me" },
|
|
328
|
+
fetch,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function toEvent(d, reasons, actor, ref, personUid, identities) {
|
|
332
|
+
const fm = d.frontmatter;
|
|
333
|
+
const when = new Date(d.tsMs).toISOString();
|
|
334
|
+
const what = eventWhat(d.eventType);
|
|
335
|
+
const where = ref ? `attio:${ref.objectId}/${ref.recordId}` : "attio";
|
|
336
|
+
const person = identities?.find((id) => id.startsWith("prs_")) ?? personUid;
|
|
337
|
+
const payloadRef = `${ATTIO_SOURCE_DIR}/${d.file}`;
|
|
338
|
+
const title = `Attio ${d.eventType}`;
|
|
339
|
+
const body = [
|
|
340
|
+
`# ${title}`,
|
|
341
|
+
"",
|
|
342
|
+
`- When: ${when}`,
|
|
343
|
+
`- Who: ${actor ?? "unknown"}`,
|
|
344
|
+
`- Matched: ${reasons.join(", ")}`,
|
|
345
|
+
`- Where: ${where}`,
|
|
346
|
+
`- Source: ${payloadRef}`,
|
|
347
|
+
"",
|
|
348
|
+
].join("\n");
|
|
349
|
+
const raw = {};
|
|
350
|
+
for (const field of RAW_FIELDS)
|
|
351
|
+
if (fm[field] !== undefined)
|
|
352
|
+
raw[field] = fm[field];
|
|
353
|
+
return {
|
|
354
|
+
providerId: d.idempotencyKey,
|
|
355
|
+
// The receiver dedupes by Idempotency-Key, so one delivery has one version.
|
|
356
|
+
version: d.eventType,
|
|
357
|
+
who: actor ?? "unknown",
|
|
358
|
+
what,
|
|
359
|
+
where,
|
|
360
|
+
when,
|
|
361
|
+
audience: [person],
|
|
362
|
+
title,
|
|
363
|
+
body,
|
|
364
|
+
payloadRef,
|
|
365
|
+
frontmatter: {
|
|
366
|
+
audience_source: "person_uid",
|
|
367
|
+
attio_event_type: d.eventType,
|
|
368
|
+
attio_match: reasons.join(","),
|
|
369
|
+
...(ref ? { attio_object_id: ref.objectId, attio_record_id: ref.recordId } : {}),
|
|
370
|
+
},
|
|
371
|
+
raw: { source: payloadRef, ...raw, matched: reasons, actor: actor ?? null, record: ref },
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
//# sourceMappingURL=attio.js.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gmail hot channel (hot-signal-sync US-013), flag-gated.
|
|
3
|
+
*
|
|
4
|
+
* A poll adapter over the person's Google connection, routed through the HQ
|
|
5
|
+
* gateway. It watches INBOX with `users.watch`, renewed daily, and reads new
|
|
6
|
+
* mail with `history.list` from the stored `historyId`. The token stays in the
|
|
7
|
+
* gateway; this adapter never sees it.
|
|
8
|
+
*
|
|
9
|
+
* The adapter is always registered. It declares `gate.flag`, and the catalog
|
|
10
|
+
* lists gmail only when that flag is on for the company. With the flag off,
|
|
11
|
+
* `hq sources sync` reports gmail as `gated` and never calls this adapter.
|
|
12
|
+
*
|
|
13
|
+
* Gateway tools: the HQ google provider does not expose these four tools yet.
|
|
14
|
+
* They are named here as constants, and a follow-up hq-pro story adds them
|
|
15
|
+
* (all within `gmail.readonly`) before the flag turns on. The argument shapes
|
|
16
|
+
* are recorded in the project design note US-013-gmail-gateway-tools.md.
|
|
17
|
+
*
|
|
18
|
+
* The cursor is a JSON object `{ historyId, watchExpiresAt, watchedAt, pageToken }`:
|
|
19
|
+
* - `historyId`: Gmail history at or before it was already read.
|
|
20
|
+
* - `watchExpiresAt` / `watchedAt`: when the INBOX watch expires and when it
|
|
21
|
+
* was last renewed. The watch is renewed when it was never set up, when it
|
|
22
|
+
* is 24 hours old or older, or when it expires in under 24 hours.
|
|
23
|
+
* - `pageToken`: the `history.list` page token while a pass is in progress.
|
|
24
|
+
* The pass keeps the old `historyId` until its last page, then advances to
|
|
25
|
+
* the response's top-level `historyId`.
|
|
26
|
+
*
|
|
27
|
+
* A first run seeds `historyId` from the watch response (or `users.getProfile`)
|
|
28
|
+
* and lands nothing: Gmail history only exists after a known `historyId`.
|
|
29
|
+
* When Google answers 404 because the stored `historyId` is too old (about a
|
|
30
|
+
* week), the adapter re-seeds the same way and lands nothing.
|
|
31
|
+
*
|
|
32
|
+
* Each landed document is a pointer: subject, from, to, date and thread id
|
|
33
|
+
* from a metadata-only `messages.get`. The adapter never requests or copies a
|
|
34
|
+
* message body or snippet. The event key is `gmail:{messageId}:{historyId}`,
|
|
35
|
+
* with the history record id as the version.
|
|
36
|
+
*
|
|
37
|
+
* A revoked or expired token surfaces from the gateway call; run.ts turns it
|
|
38
|
+
* into a reconnect prompt in the run ledger and the cursor does not move.
|
|
39
|
+
*/
|
|
40
|
+
import { type PollHotChannelAdapter } from "../contract.js";
|
|
41
|
+
export declare const GMAIL_ADAPTER_ID = "gmail";
|
|
42
|
+
export declare const GMAIL_CHANNEL = "gmail";
|
|
43
|
+
export declare const GMAIL_PROVIDER = "google";
|
|
44
|
+
export declare const GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly";
|
|
45
|
+
/** The flag registered in hq-pro `src/flags/keys.ts`. */
|
|
46
|
+
export declare const GMAIL_FLAG = "hq.hot-channel-gmail";
|
|
47
|
+
export declare const GMAIL_MESSAGE_ADDED_EVENT = "gmail.message.added";
|
|
48
|
+
export declare const GMAIL_LABEL_ADDED_EVENT = "gmail.label.added";
|
|
49
|
+
export declare const GMAIL_WATCH_TOOL = "google.gmail.users.watch";
|
|
50
|
+
export declare const GMAIL_HISTORY_LIST_TOOL = "google.gmail.history.list";
|
|
51
|
+
export declare const GMAIL_GET_PROFILE_TOOL = "google.gmail.users.getProfile";
|
|
52
|
+
export declare const GMAIL_GET_MESSAGE_TOOL = "google.gmail.messages.get";
|
|
53
|
+
export declare const GMAIL_INBOX_LABEL = "INBOX";
|
|
54
|
+
export declare const GMAIL_METADATA_HEADERS: readonly ["Subject", "From", "To", "Date"];
|
|
55
|
+
export declare const GMAIL_CATCH_UP_WINDOW_MS: number;
|
|
56
|
+
/** Renew the watch at least this often, and when it expires sooner than this. */
|
|
57
|
+
export declare const GMAIL_WATCH_RENEW_MS: number;
|
|
58
|
+
export interface GmailAdapterOptions {
|
|
59
|
+
/** History records per `history.list` call. Clamped to 1..500. Default 100. */
|
|
60
|
+
pageSize?: number;
|
|
61
|
+
/** Clock for watch renewal and event times without a Date header. */
|
|
62
|
+
now?: () => Date;
|
|
63
|
+
}
|
|
64
|
+
/** The adapter's cursor: see the file comment. */
|
|
65
|
+
export interface GmailCursor {
|
|
66
|
+
historyId: string;
|
|
67
|
+
watchExpiresAt: string | null;
|
|
68
|
+
watchedAt: string | null;
|
|
69
|
+
pageToken: string | null;
|
|
70
|
+
}
|
|
71
|
+
/** The headers a landed pointer carries. Never a body or snippet. */
|
|
72
|
+
export interface GmailMessageHeaders {
|
|
73
|
+
subject?: string;
|
|
74
|
+
from?: string;
|
|
75
|
+
to?: string;
|
|
76
|
+
date?: string;
|
|
77
|
+
}
|
|
78
|
+
/** The `.raw.json` sidecar of a landed Gmail event. */
|
|
79
|
+
export interface GmailPointer {
|
|
80
|
+
messageId: string;
|
|
81
|
+
threadId?: string;
|
|
82
|
+
historyId: string;
|
|
83
|
+
labelIds: string[];
|
|
84
|
+
headers: GmailMessageHeaders;
|
|
85
|
+
}
|
|
86
|
+
interface HistoryChange {
|
|
87
|
+
kind: typeof GMAIL_MESSAGE_ADDED_EVENT | typeof GMAIL_LABEL_ADDED_EVENT;
|
|
88
|
+
historyId: string;
|
|
89
|
+
messageId: string;
|
|
90
|
+
threadId?: string;
|
|
91
|
+
labelIds: string[];
|
|
92
|
+
}
|
|
93
|
+
interface HistoryPage {
|
|
94
|
+
changes: HistoryChange[];
|
|
95
|
+
nextPageToken: string | null;
|
|
96
|
+
historyId: string | null;
|
|
97
|
+
}
|
|
98
|
+
export declare function parseGmailCursor(cursor: string | null): GmailCursor | null;
|
|
99
|
+
/** True when the INBOX watch must be set up or renewed now. */
|
|
100
|
+
export declare function gmailWatchDue(cursor: GmailCursor | null, now: Date): boolean;
|
|
101
|
+
/** Google's answer when `startHistoryId` is older than the history it keeps. */
|
|
102
|
+
export declare function isExpiredHistoryError(err: unknown): boolean;
|
|
103
|
+
/** Parse one `history.list` page into INBOX message and label changes. */
|
|
104
|
+
export declare function parseGmailHistoryPage(result: unknown): HistoryPage;
|
|
105
|
+
/** Headers from a metadata-format `messages.get`. Body, snippet and parts are ignored. */
|
|
106
|
+
export declare function parseGmailMessageHeaders(result: unknown): GmailMessageHeaders & {
|
|
107
|
+
internalDate?: string;
|
|
108
|
+
};
|
|
109
|
+
export declare function createGmailAdapter(options?: GmailAdapterOptions): PollHotChannelAdapter;
|
|
110
|
+
export {};
|
|
111
|
+
//# sourceMappingURL=gmail.d.ts.map
|