aibroker 0.13.0 → 0.14.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/dist/adapters/iterm/core.d.ts +11 -0
- package/dist/adapters/iterm/core.d.ts.map +1 -1
- package/dist/adapters/iterm/core.js +38 -16
- package/dist/adapters/iterm/core.js.map +1 -1
- package/dist/core/env.d.ts +14 -0
- package/dist/core/env.d.ts.map +1 -0
- package/dist/core/env.js +48 -0
- package/dist/core/env.js.map +1 -0
- package/dist/core/state.d.ts +27 -1
- package/dist/core/state.d.ts.map +1 -1
- package/dist/core/state.js +20 -1
- package/dist/core/state.js.map +1 -1
- package/dist/daemon/cli.js +6 -0
- package/dist/daemon/cli.js.map +1 -1
- package/dist/daemon/commands.d.ts.map +1 -1
- package/dist/daemon/commands.js +31 -10
- package/dist/daemon/commands.js.map +1 -1
- package/dist/daemon/core-handlers.d.ts.map +1 -1
- package/dist/daemon/core-handlers.js +120 -12
- package/dist/daemon/core-handlers.js.map +1 -1
- package/dist/daemon/dispatch.d.ts +8 -0
- package/dist/daemon/dispatch.d.ts.map +1 -1
- package/dist/daemon/dispatch.js +20 -3
- package/dist/daemon/dispatch.js.map +1 -1
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +32 -34
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mailbox-watch.d.ts +26 -0
- package/dist/daemon/mailbox-watch.d.ts.map +1 -0
- package/dist/daemon/mailbox-watch.js +69 -0
- package/dist/daemon/mailbox-watch.js.map +1 -0
- package/dist/daemon/ota.d.ts.map +1 -1
- package/dist/daemon/ota.js +9 -3
- package/dist/daemon/ota.js.map +1 -1
- package/dist/daemon/todoist-cli.d.ts +10 -0
- package/dist/daemon/todoist-cli.d.ts.map +1 -0
- package/dist/daemon/todoist-cli.js +108 -0
- package/dist/daemon/todoist-cli.js.map +1 -0
- package/dist/daemon/todoist-ingress.d.ts +47 -0
- package/dist/daemon/todoist-ingress.d.ts.map +1 -0
- package/dist/daemon/todoist-ingress.js +101 -0
- package/dist/daemon/todoist-ingress.js.map +1 -0
- package/dist/daemon/todoist-oauth.d.ts +69 -0
- package/dist/daemon/todoist-oauth.d.ts.map +1 -0
- package/dist/daemon/todoist-oauth.js +182 -0
- package/dist/daemon/todoist-oauth.js.map +1 -0
- package/dist/daemon/todoist-owners.d.ts +23 -0
- package/dist/daemon/todoist-owners.d.ts.map +1 -0
- package/dist/daemon/todoist-owners.js +60 -0
- package/dist/daemon/todoist-owners.js.map +1 -0
- package/dist/daemon/todoist-reply.d.ts +58 -0
- package/dist/daemon/todoist-reply.d.ts.map +1 -0
- package/dist/daemon/todoist-reply.js +135 -0
- package/dist/daemon/todoist-reply.js.map +1 -0
- package/dist/daemon/todoist-webhook.d.ts +66 -3
- package/dist/daemon/todoist-webhook.d.ts.map +1 -1
- package/dist/daemon/todoist-webhook.js +244 -17
- package/dist/daemon/todoist-webhook.js.map +1 -1
- package/dist/mcp/index.js +30 -2
- package/dist/mcp/index.js.map +1 -1
- package/dist/transport/sync-facade.d.ts.map +1 -1
- package/dist/transport/sync-facade.js +12 -0
- package/dist/transport/sync-facade.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon/todoist-reply.ts — answering on the task you were asked from.
|
|
3
|
+
*
|
|
4
|
+
* A task arriving here is often a question, not a work order, and until now the
|
|
5
|
+
* answer went to a terminal the asker was not looking at. Filing from a watch
|
|
6
|
+
* and reading the reply at a desk is not a channel; it is two half channels.
|
|
7
|
+
*
|
|
8
|
+
* So a session answers on the task itself. The comment carries the agent mark,
|
|
9
|
+
* which `route()` already drops before anything else, so our own writes cannot
|
|
10
|
+
* come back as instructions.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately does NOT complete the task. Completion is the human saying done
|
|
13
|
+
* — the same rule the receiver enforces on the way in — and a question whose
|
|
14
|
+
* answer you have not read yet is not done. Left open, the task keeps its place
|
|
15
|
+
* in the list with a comment indicator; completed, it vanishes from view and
|
|
16
|
+
* the answer with it.
|
|
17
|
+
*/
|
|
18
|
+
import { AGENT_MARK } from "./todoist-webhook.js";
|
|
19
|
+
import { loadToken } from "./todoist-oauth.js";
|
|
20
|
+
import { log } from "../core/log.js";
|
|
21
|
+
import { audit } from "./audit.js";
|
|
22
|
+
/**
|
|
23
|
+
* Fetch the task a comment belongs to.
|
|
24
|
+
*
|
|
25
|
+
* A `note:added` payload carries `item_id` and the comment text and nothing
|
|
26
|
+
* else — no project, no labels. Without them the security boundary cannot be
|
|
27
|
+
* evaluated, so a comment is unroutable until its parent is resolved. This is
|
|
28
|
+
* the lookup that makes replying-by-comment possible at all.
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchParentTask(taskId, fetchImpl = fetch) {
|
|
31
|
+
const token = loadToken();
|
|
32
|
+
if (!token)
|
|
33
|
+
throw new Error("no Todoist authorisation on file — run `aibroker todoist auth`");
|
|
34
|
+
const res = await fetchImpl(`https://api.todoist.com/api/v1/tasks/${encodeURIComponent(taskId)}`, {
|
|
35
|
+
headers: { authorization: `${token.token_type} ${token.access_token}` },
|
|
36
|
+
});
|
|
37
|
+
const raw = await res.text();
|
|
38
|
+
if (!res.ok)
|
|
39
|
+
throw new Error(`task lookup failed with ${res.status}: ${raw.slice(0, 200)}`);
|
|
40
|
+
let t;
|
|
41
|
+
try {
|
|
42
|
+
t = JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error(`task lookup returned unparseable body: ${raw.slice(0, 200)}`);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
content: t.content ?? "",
|
|
49
|
+
projectId: t.project_id ?? "",
|
|
50
|
+
labels: Array.isArray(t.labels) ? t.labels.map(String) : [],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Post a comment on a task as the agent.
|
|
55
|
+
*
|
|
56
|
+
* The mark is added here rather than trusted to callers: every path that can
|
|
57
|
+
* write to Todoist has to be echo-safe, and "remember the prefix" is not a
|
|
58
|
+
* safety mechanism.
|
|
59
|
+
*/
|
|
60
|
+
export async function replyToTask(taskId, text, fetchImpl = fetch) {
|
|
61
|
+
const token = loadToken();
|
|
62
|
+
if (!token) {
|
|
63
|
+
throw new Error("no Todoist authorisation on file — run `aibroker todoist auth`");
|
|
64
|
+
}
|
|
65
|
+
const body = text.trimStart().startsWith(AGENT_MARK) ? text : `${AGENT_MARK} ${text}`;
|
|
66
|
+
const res = await fetchImpl("https://api.todoist.com/api/v1/comments", {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: {
|
|
69
|
+
"content-type": "application/json",
|
|
70
|
+
authorization: `${token.token_type} ${token.access_token}`,
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({ task_id: taskId, content: body }),
|
|
73
|
+
});
|
|
74
|
+
const raw = await res.text();
|
|
75
|
+
if (!res.ok)
|
|
76
|
+
throw new Error(`comment failed with ${res.status}: ${raw.slice(0, 200)}`);
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new Error(`comment returned unparseable body: ${raw.slice(0, 200)}`);
|
|
83
|
+
}
|
|
84
|
+
const result = { commentId: parsed.id ?? "?", taskId };
|
|
85
|
+
audit({
|
|
86
|
+
action: "todoist-reply", actor: "aibroker", target: `todoist:task:${taskId}`,
|
|
87
|
+
outcome: "posted", body,
|
|
88
|
+
});
|
|
89
|
+
log(`todoist-reply: commented on ${taskId}`);
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
/** Normalise a title the way a human comparing two list rows would. */
|
|
93
|
+
function sameTitle(a, b) {
|
|
94
|
+
const n = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
|
|
95
|
+
return n(a) === n(b);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* How many OPEN tasks in this project share a title.
|
|
99
|
+
*
|
|
100
|
+
* Two tasks called the same thing are indistinguishable in a list, so a reply
|
|
101
|
+
* posted on one looks — to whoever is watching the other — exactly like being
|
|
102
|
+
* ignored. That cost two full rounds today: comment routing was working
|
|
103
|
+
* perfectly and the answers were landing on a sibling nobody was looking at.
|
|
104
|
+
* Being ignored and being answered elsewhere must not look the same.
|
|
105
|
+
*
|
|
106
|
+
* Returns 1 in the ordinary case (this task alone) and 0 when it cannot tell —
|
|
107
|
+
* a lookup failure must not invent a duplicate warning.
|
|
108
|
+
*/
|
|
109
|
+
export async function countTasksWithTitle(projectId, title, fetchImpl = fetch) {
|
|
110
|
+
const token = loadToken();
|
|
111
|
+
if (!token || !projectId || !title.trim())
|
|
112
|
+
return 0;
|
|
113
|
+
const res = await fetchImpl(`https://api.todoist.com/api/v1/tasks?project_id=${encodeURIComponent(projectId)}`, { headers: { authorization: `${token.token_type} ${token.access_token}` } });
|
|
114
|
+
if (!res.ok)
|
|
115
|
+
return 0;
|
|
116
|
+
let body;
|
|
117
|
+
try {
|
|
118
|
+
body = JSON.parse(await res.text());
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
// This endpoint has returned both a bare array and a {results:[…]} envelope
|
|
124
|
+
// across versions; accept either rather than silently counting zero.
|
|
125
|
+
const list = Array.isArray(body)
|
|
126
|
+
? body
|
|
127
|
+
: Array.isArray(body?.results)
|
|
128
|
+
? body.results
|
|
129
|
+
: [];
|
|
130
|
+
return list.filter((t) => {
|
|
131
|
+
const c = t?.content;
|
|
132
|
+
return typeof c === "string" && sameTitle(c, title);
|
|
133
|
+
}).length;
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=todoist-reply.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"todoist-reply.js","sourceRoot":"","sources":["../../src/daemon/todoist-reply.ts"],"names":[],"mappings":"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"}
|
|
@@ -65,6 +65,21 @@ export interface WebhookConfig {
|
|
|
65
65
|
bind: string;
|
|
66
66
|
/** Path the receiver answers on. Anything else 404s. */
|
|
67
67
|
path: string;
|
|
68
|
+
/**
|
|
69
|
+
* Client id from the App Management Console.
|
|
70
|
+
*
|
|
71
|
+
* Only needed for the OAuth landing below — the webhook path itself is
|
|
72
|
+
* verified by HMAC and never uses it.
|
|
73
|
+
*/
|
|
74
|
+
clientId?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Path the OAuth redirect lands on.
|
|
77
|
+
*
|
|
78
|
+
* Todoist rejects a redirect URL carrying a port, so this one cannot be
|
|
79
|
+
* reached over the same Funnel as the webhook. Put a tailnet-only Serve on
|
|
80
|
+
* 443 in front of it — see docs/todoist.md.
|
|
81
|
+
*/
|
|
82
|
+
oauthPath: string;
|
|
68
83
|
/**
|
|
69
84
|
* Project ids allowed to reach a session. An explicit allowlist, not a
|
|
70
85
|
* subtree resolved at runtime, and deliberately so: a project added later
|
|
@@ -80,14 +95,35 @@ export interface WebhookConfig {
|
|
|
80
95
|
*/
|
|
81
96
|
defaultOwner?: string;
|
|
82
97
|
}
|
|
98
|
+
/** Which rule chose the owner. Recorded, so a wrong guess is visible. */
|
|
99
|
+
export type OwnerRule = "label" | "address" | "held" | "project" | "default";
|
|
83
100
|
export type RouteDecision = {
|
|
84
101
|
act: true;
|
|
85
102
|
project: string;
|
|
86
103
|
body: string;
|
|
87
104
|
taskId: string;
|
|
105
|
+
rule: OwnerRule;
|
|
106
|
+
nearMiss?: string;
|
|
88
107
|
} | {
|
|
89
108
|
act: false;
|
|
90
109
|
reason: string;
|
|
110
|
+
nearMiss?: string;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Pull a leading session name off the content.
|
|
114
|
+
*
|
|
115
|
+
* Typing a label is several taps on a phone and worse on a watch, so the
|
|
116
|
+
* cheapest possible address is the first word: "pai send a whatsapp message".
|
|
117
|
+
* Only names we already know are accepted — otherwise "home improvements"
|
|
118
|
+
* becomes a work order for the Home session — and an explicit `name:` or
|
|
119
|
+
* `name,` counts as addressing even when the name is unknown, so that a typo
|
|
120
|
+
* can be reported instead of silently ignored.
|
|
121
|
+
*/
|
|
122
|
+
export declare function parseAddress(content: string, known: Set<string>): {
|
|
123
|
+
owner?: string;
|
|
124
|
+
rest: string;
|
|
125
|
+
firstToken: string | null;
|
|
126
|
+
addressed: boolean;
|
|
91
127
|
};
|
|
92
128
|
/**
|
|
93
129
|
* Verify Todoist's HMAC over the RAW body.
|
|
@@ -104,14 +140,41 @@ export declare function eventKey(e: TodoistEvent): string;
|
|
|
104
140
|
* Pure, so the routing rules are testable without a socket: this function is
|
|
105
141
|
* the entire security boundary of the ingress.
|
|
106
142
|
*/
|
|
107
|
-
export declare function route(e: TodoistEvent, cfg: WebhookConfig
|
|
143
|
+
export declare function route(e: TodoistEvent, cfg: WebhookConfig, knownOwners?: Iterable<string>,
|
|
144
|
+
/**
|
|
145
|
+
* The session already holding this task, when we know it.
|
|
146
|
+
*
|
|
147
|
+
* Only meaningful for a comment: a follow-up belongs to whoever did the work,
|
|
148
|
+
* not to whatever the project mapping would pick today.
|
|
149
|
+
*/
|
|
150
|
+
heldBy?: string): RouteDecision;
|
|
108
151
|
export interface WebhookDeps {
|
|
109
|
-
/**
|
|
110
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Deliver a work order. Returns a short outcome for the audit record.
|
|
154
|
+
*
|
|
155
|
+
* `prefix` distinguishes a correction from a new work order: `[Task]` reads
|
|
156
|
+
* as "start this", and a session that receives a comment under that heading
|
|
157
|
+
* will begin again rather than adjust.
|
|
158
|
+
*/
|
|
159
|
+
deliver: (project: string, body: string, opts?: {
|
|
160
|
+
prefix?: string;
|
|
161
|
+
}) => Promise<{
|
|
111
162
|
outcome: string;
|
|
112
163
|
session: string;
|
|
113
164
|
reason?: string;
|
|
114
165
|
}>;
|
|
166
|
+
/**
|
|
167
|
+
* Names that may be used as an address in a task's first word.
|
|
168
|
+
*
|
|
169
|
+
* Must be the names DISPATCH can resolve, not merely the ones a human would
|
|
170
|
+
* recognise. Addressing a live session that dispatch has no alias for reads
|
|
171
|
+
* as success and delivers nothing — which is exactly how "paicloud send me a
|
|
172
|
+
* mail" was quietly answered by the wrong session.
|
|
173
|
+
*
|
|
174
|
+
* A function rather than a list: aliases and sessions both change while the
|
|
175
|
+
* daemon runs, and a name that was valid at boot need not be valid now.
|
|
176
|
+
*/
|
|
177
|
+
knownOwners?: () => string[] | Promise<string[]>;
|
|
115
178
|
}
|
|
116
179
|
export declare function createWebhookServer(cfg: WebhookConfig, deps: WebhookDeps): Server;
|
|
117
180
|
/** Read config from the environment. Returns null when not configured. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"todoist-webhook.d.ts","sourceRoot":"","sources":["../../src/daemon/todoist-webhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAA2D,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"todoist-webhook.d.ts","sourceRoot":"","sources":["../../src/daemon/todoist-webhook.ts"],"names":[],"mappings":"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"}
|
|
@@ -36,8 +36,74 @@ import { createServer } from "node:http";
|
|
|
36
36
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
37
37
|
import { log } from "../core/log.js";
|
|
38
38
|
import { audit } from "./audit.js";
|
|
39
|
+
import { handleOAuthCallback } from "./todoist-oauth.js";
|
|
39
40
|
/** Prefix on anything an agent writes back to Todoist, so we ignore our own echo. */
|
|
40
41
|
export const AGENT_MARK = "🤖";
|
|
42
|
+
/**
|
|
43
|
+
* Something that looked like an address but was not one.
|
|
44
|
+
*
|
|
45
|
+
* A task saying `PAI do the thing` or labelled `pai` reads, to a human, as an
|
|
46
|
+
* instruction about where it should go. Neither parses, and the silent outcome
|
|
47
|
+
* is that it lands on the default owner as though nothing had been asked for.
|
|
48
|
+
* That is the failure this project keeps having to fix: a suppression nobody
|
|
49
|
+
* counts is indistinguishable from an event that never happened. So we say it.
|
|
50
|
+
*/
|
|
51
|
+
function detectNearMiss(labels, firstToken, addressed, known) {
|
|
52
|
+
const labelAttempt = labels.find((l) => {
|
|
53
|
+
const low = l.toLowerCase();
|
|
54
|
+
// A label that already routes is not a near miss, whichever form it took.
|
|
55
|
+
if (low.startsWith("pai:") || known.has(low))
|
|
56
|
+
return false;
|
|
57
|
+
return low.startsWith("pai");
|
|
58
|
+
});
|
|
59
|
+
if (labelAttempt)
|
|
60
|
+
return `label "${labelAttempt}" names nobody — use a session name, or pai:<name>`;
|
|
61
|
+
if (addressed && firstToken && !known.has(firstToken.toLowerCase())) {
|
|
62
|
+
return `"${firstToken}" reads as an address but no session by that name is known`;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Owner named by a label, in either accepted form.
|
|
68
|
+
*
|
|
69
|
+
* `pai:<name>` is explicit and works even for a name we have never heard of.
|
|
70
|
+
* A bare `<name>` label is the cheap form — one tap in Todoist's picker — and
|
|
71
|
+
* is accepted only when it matches something dispatch can actually resolve,
|
|
72
|
+
* so an ordinary label like `urgent` stays an ordinary label.
|
|
73
|
+
*/
|
|
74
|
+
function ownerFromLabels(labels, known) {
|
|
75
|
+
for (const raw of labels.map((l) => l.trim())) {
|
|
76
|
+
const low = raw.toLowerCase();
|
|
77
|
+
if (low.startsWith("pai:")) {
|
|
78
|
+
const name = raw.slice(4).trim();
|
|
79
|
+
if (name)
|
|
80
|
+
return name;
|
|
81
|
+
}
|
|
82
|
+
if (known.has(low))
|
|
83
|
+
return low;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Pull a leading session name off the content.
|
|
89
|
+
*
|
|
90
|
+
* Typing a label is several taps on a phone and worse on a watch, so the
|
|
91
|
+
* cheapest possible address is the first word: "pai send a whatsapp message".
|
|
92
|
+
* Only names we already know are accepted — otherwise "home improvements"
|
|
93
|
+
* becomes a work order for the Home session — and an explicit `name:` or
|
|
94
|
+
* `name,` counts as addressing even when the name is unknown, so that a typo
|
|
95
|
+
* can be reported instead of silently ignored.
|
|
96
|
+
*/
|
|
97
|
+
export function parseAddress(content, known) {
|
|
98
|
+
const m = /^([\p{L}\p{N}_-]+)([:,])?\s+([\s\S]+)$/u.exec(content.trim());
|
|
99
|
+
if (!m)
|
|
100
|
+
return { rest: content, firstToken: null, addressed: false };
|
|
101
|
+
const [, token, punct, rest] = m;
|
|
102
|
+
const addressed = Boolean(punct);
|
|
103
|
+
if (known.has(token.toLowerCase()))
|
|
104
|
+
return { owner: token.toLowerCase(), rest, firstToken: token, addressed: true };
|
|
105
|
+
return { rest: content, firstToken: token, addressed };
|
|
106
|
+
}
|
|
41
107
|
/**
|
|
42
108
|
* Verify Todoist's HMAC over the RAW body.
|
|
43
109
|
*
|
|
@@ -70,7 +136,26 @@ export function eventKey(e) {
|
|
|
70
136
|
* Pure, so the routing rules are testable without a socket: this function is
|
|
71
137
|
* the entire security boundary of the ingress.
|
|
72
138
|
*/
|
|
73
|
-
export function route(e, cfg
|
|
139
|
+
export function route(e, cfg, knownOwners = [],
|
|
140
|
+
/**
|
|
141
|
+
* The session already holding this task, when we know it.
|
|
142
|
+
*
|
|
143
|
+
* Only meaningful for a comment: a follow-up belongs to whoever did the work,
|
|
144
|
+
* not to whatever the project mapping would pick today.
|
|
145
|
+
*/
|
|
146
|
+
heldBy) {
|
|
147
|
+
// Names we will accept as an address: every session the hub can see, plus
|
|
148
|
+
// every owner named in config. Config counts even when the session is not
|
|
149
|
+
// running, so "clickr do x" is understood and reported as undeliverable
|
|
150
|
+
// rather than being read as the first word of a shopping list.
|
|
151
|
+
const known = new Set();
|
|
152
|
+
for (const n of knownOwners)
|
|
153
|
+
if (n)
|
|
154
|
+
known.add(n.toLowerCase());
|
|
155
|
+
for (const o of cfg.projectOwners.values())
|
|
156
|
+
known.add(o.toLowerCase());
|
|
157
|
+
if (cfg.defaultOwner)
|
|
158
|
+
known.add(cfg.defaultOwner.toLowerCase());
|
|
74
159
|
const data = e.event_data ?? {};
|
|
75
160
|
const content = typeof data.content === "string" ? data.content : "";
|
|
76
161
|
const description = typeof data.description === "string" ? data.description : "";
|
|
@@ -80,7 +165,7 @@ export function route(e, cfg) {
|
|
|
80
165
|
if (content.startsWith(AGENT_MARK) || description.startsWith(AGENT_MARK)) {
|
|
81
166
|
return { act: false, reason: "agent-authored content, ignored to avoid an echo loop" };
|
|
82
167
|
}
|
|
83
|
-
const ACTIONABLE = new Set(["item:added", "item:completed", "reminder:fired"]);
|
|
168
|
+
const ACTIONABLE = new Set(["item:added", "item:completed", "reminder:fired", "note:added"]);
|
|
84
169
|
if (!ACTIONABLE.has(e.event_name)) {
|
|
85
170
|
return { act: false, reason: `event ${e.event_name} is not actionable` };
|
|
86
171
|
}
|
|
@@ -98,21 +183,52 @@ export function route(e, cfg) {
|
|
|
98
183
|
return { act: false, reason: "task has no content" };
|
|
99
184
|
}
|
|
100
185
|
// Owner, most explicit first:
|
|
101
|
-
// 1. a `pai:<name>` label
|
|
102
|
-
// 2.
|
|
103
|
-
// 3. the
|
|
186
|
+
// 1. a `pai:<name>` label — say exactly where it goes
|
|
187
|
+
// 2. a leading name in the text — "pai send a whatsapp message"
|
|
188
|
+
// 3. the session already holding it — a comment follows its work
|
|
189
|
+
// 4. the project it was filed in — "put it in the Whazaa list"
|
|
190
|
+
// 5. the configured default — the watch case: Inbox, no label, no text address
|
|
191
|
+
//
|
|
192
|
+
// Text beats project: what you wrote is more deliberate than where it landed,
|
|
193
|
+
// and on a watch the project is whatever the quick-capture button chose.
|
|
104
194
|
const labels = Array.isArray(data.labels) ? data.labels.map(String) : [];
|
|
105
|
-
const labelled = labels
|
|
106
|
-
|
|
107
|
-
const
|
|
195
|
+
const labelled = ownerFromLabels(labels, known);
|
|
196
|
+
const addr = parseAddress(content, known);
|
|
197
|
+
const nearMiss = detectNearMiss(labels, addr.firstToken, addr.addressed, known);
|
|
198
|
+
let owner;
|
|
199
|
+
let rule;
|
|
200
|
+
if (labelled) {
|
|
201
|
+
owner = labelled;
|
|
202
|
+
rule = "label";
|
|
203
|
+
}
|
|
204
|
+
else if (addr.owner) {
|
|
205
|
+
owner = addr.owner;
|
|
206
|
+
rule = "address";
|
|
207
|
+
}
|
|
208
|
+
else if (heldBy) {
|
|
209
|
+
owner = heldBy;
|
|
210
|
+
rule = "held";
|
|
211
|
+
}
|
|
212
|
+
else if (cfg.projectOwners.get(projectId)) {
|
|
213
|
+
owner = cfg.projectOwners.get(projectId);
|
|
214
|
+
rule = "project";
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
owner = cfg.defaultOwner;
|
|
218
|
+
rule = "default";
|
|
219
|
+
}
|
|
108
220
|
if (!owner) {
|
|
109
221
|
return {
|
|
110
222
|
act: false,
|
|
111
|
-
reason: "no pai:<name> label, no owner for this project, and no default owner configured",
|
|
223
|
+
reason: "no pai:<name> label, no name in the text, no owner for this project, and no default owner configured",
|
|
224
|
+
nearMiss,
|
|
112
225
|
};
|
|
113
226
|
}
|
|
114
|
-
|
|
115
|
-
|
|
227
|
+
// Only the address rule consumed part of the title; every other rule leaves
|
|
228
|
+
// the text exactly as written.
|
|
229
|
+
const title = rule === "address" ? addr.rest : content;
|
|
230
|
+
const body = description.trim() ? `${title}\n\n${description}` : title;
|
|
231
|
+
return { act: true, project: owner, body, taskId, rule, nearMiss };
|
|
116
232
|
}
|
|
117
233
|
/** Bounded replay guard: Todoist retries, and a retry must not run work twice. */
|
|
118
234
|
class SeenSet {
|
|
@@ -156,12 +272,22 @@ export function createWebhookServer(cfg, deps) {
|
|
|
156
272
|
const seen = new SeenSet();
|
|
157
273
|
return createServer((req, res) => {
|
|
158
274
|
void (async () => {
|
|
275
|
+
// Ignore query strings on the webhook path; a proxy may append them. The
|
|
276
|
+
// OAuth landing is the one place here where the query IS the payload.
|
|
277
|
+
const reqPath = (req.url ?? "/").split("?")[0];
|
|
278
|
+
if (req.method === "GET" && reqPath === cfg.oauthPath) {
|
|
279
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
280
|
+
const { status, html } = await handleOAuthCallback(url, {
|
|
281
|
+
clientId: cfg.clientId,
|
|
282
|
+
clientSecret: cfg.secret,
|
|
283
|
+
});
|
|
284
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(html);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
159
287
|
if (req.method !== "POST") {
|
|
160
288
|
res.writeHead(405).end();
|
|
161
289
|
return;
|
|
162
290
|
}
|
|
163
|
-
// Ignore query strings; a proxy may append them.
|
|
164
|
-
const reqPath = (req.url ?? "/").split("?")[0];
|
|
165
291
|
if (reqPath !== cfg.path) {
|
|
166
292
|
res.writeHead(404).end();
|
|
167
293
|
return;
|
|
@@ -202,22 +328,119 @@ export function createWebhookServer(cfg, deps) {
|
|
|
202
328
|
return;
|
|
203
329
|
}
|
|
204
330
|
seen.add(key);
|
|
205
|
-
|
|
331
|
+
// Deleting the project revokes its ingress. A grant outliving the thing
|
|
332
|
+
// it points at is dead weight in a security boundary: it reads as though
|
|
333
|
+
// access is still open, and if the id is ever reused it silently is. The
|
|
334
|
+
// boundary should only ever shrink on its own.
|
|
335
|
+
if (event.event_name === "project:deleted") {
|
|
336
|
+
const gone = String(event.event_data?.id ?? "");
|
|
337
|
+
if (gone) {
|
|
338
|
+
const { revokeIngress } = await import("./todoist-ingress.js");
|
|
339
|
+
if (revokeIngress(gone)) {
|
|
340
|
+
log(`todoist-webhook: project ${gone} was deleted — its ingress grant is revoked`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Still not actionable as work; fall through to be recorded as ignored.
|
|
344
|
+
}
|
|
345
|
+
// A comment carries item_id and its own text and nothing else — no
|
|
346
|
+
// project, no labels — so the boundary cannot be evaluated until the
|
|
347
|
+
// parent task is resolved. Do it before routing, and let a failed lookup
|
|
348
|
+
// drop the event rather than route it against a missing project, which
|
|
349
|
+
// the allowlist would refuse anyway but for the wrong reason.
|
|
350
|
+
if (event.event_name === "note:added" && !event.event_data?.project_id) {
|
|
351
|
+
const parentId = String(event.event_data?.item_id ?? "");
|
|
352
|
+
if (!parentId) {
|
|
353
|
+
audit({
|
|
354
|
+
action: "webhook", actor: "todoist", target: "aibroker", outcome: "ignored",
|
|
355
|
+
reason: "comment carries no item_id", meta: { event: event.event_name },
|
|
356
|
+
});
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const { fetchParentTask } = await import("./todoist-reply.js");
|
|
361
|
+
const parent = await fetchParentTask(parentId);
|
|
362
|
+
event.event_data = {
|
|
363
|
+
...event.event_data,
|
|
364
|
+
id: parentId,
|
|
365
|
+
project_id: parent.projectId,
|
|
366
|
+
labels: parent.labels,
|
|
367
|
+
// Answer on the task, about the task: the comment is the
|
|
368
|
+
// instruction, the title is the context it makes sense in.
|
|
369
|
+
description: parent.content ? `(comment on "${parent.content}")` : "",
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
const reason = `could not resolve the task a comment belongs to — ${err instanceof Error ? err.message : String(err)}`;
|
|
374
|
+
audit({
|
|
375
|
+
action: "webhook", actor: "todoist", target: "aibroker",
|
|
376
|
+
outcome: "ignored", reason, meta: { event: event.event_name, taskId: parentId },
|
|
377
|
+
});
|
|
378
|
+
log(`todoist-webhook: ${reason}`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const isComment = event.event_name === "note:added";
|
|
383
|
+
const parentId = String(event.event_data?.id ?? "");
|
|
384
|
+
const { ownerOf, rememberOwner } = await import("./todoist-owners.js");
|
|
385
|
+
// Grants made since the daemon started take effect now, not at the next
|
|
386
|
+
// restart. A project created and granted while you are using the system
|
|
387
|
+
// has to work immediately, or the grant is indistinguishable from a
|
|
388
|
+
// project that routes nowhere.
|
|
389
|
+
const { applyGrants } = await import("./todoist-ingress.js");
|
|
390
|
+
const live = applyGrants(cfg);
|
|
391
|
+
const decision = route(event, live, await (deps.knownOwners?.() ?? []), isComment ? ownerOf(parentId) : undefined);
|
|
206
392
|
const who = event.initiator?.email ?? event.initiator?.full_name ?? "todoist";
|
|
393
|
+
if (decision.nearMiss) {
|
|
394
|
+
// Recorded whether or not the task was delivered: the point is that
|
|
395
|
+
// something was asked for and not honoured.
|
|
396
|
+
log(`todoist-webhook: near miss — ${decision.nearMiss}`);
|
|
397
|
+
}
|
|
207
398
|
if (!decision.act) {
|
|
208
399
|
audit({
|
|
209
400
|
action: "webhook", actor: `todoist:${who}`, target: "aibroker",
|
|
210
401
|
outcome: "ignored", reason: decision.reason,
|
|
211
|
-
meta: {
|
|
402
|
+
meta: {
|
|
403
|
+
event: event.event_name,
|
|
404
|
+
task: event.event_data?.content ?? undefined,
|
|
405
|
+
nearMiss: decision.nearMiss,
|
|
406
|
+
},
|
|
212
407
|
});
|
|
213
408
|
return;
|
|
214
409
|
}
|
|
215
410
|
try {
|
|
216
|
-
|
|
411
|
+
// The id rides along so the session can answer on the task it came
|
|
412
|
+
// from. Without it the reply has nowhere to go but a terminal the
|
|
413
|
+
// asker is not looking at.
|
|
414
|
+
// Warn when the title is not unique in its project. Two tasks with the
|
|
415
|
+
// same name are indistinguishable in a list, so an answer posted on one
|
|
416
|
+
// looks — to whoever is watching the other — exactly like being ignored.
|
|
417
|
+
// The session is told, so it can say which id it answered on.
|
|
418
|
+
let twins = 0;
|
|
419
|
+
if (!isComment) {
|
|
420
|
+
try {
|
|
421
|
+
const { countTasksWithTitle } = await import("./todoist-reply.js");
|
|
422
|
+
twins = await countTasksWithTitle(String(event.event_data?.project_id ?? ""), String(event.event_data?.content ?? ""));
|
|
423
|
+
}
|
|
424
|
+
catch { /* a lookup failure must not block a delivery */ }
|
|
425
|
+
}
|
|
426
|
+
const twinWarning = twins > 1
|
|
427
|
+
? `\n\n[note: ${twins} open tasks in this project share this title — say which id you answered on]`
|
|
428
|
+
: "";
|
|
429
|
+
const delivered = `${decision.body}${twinWarning}\n\n[todoist:${decision.taskId}]`;
|
|
430
|
+
const r = await deps.deliver(decision.project, delivered, isComment ? { prefix: "[Task:comment]" } : undefined);
|
|
431
|
+
// Remember who took it, so a later comment reaches the same session.
|
|
432
|
+
// Recorded on the way out and only on a real delivery: a task nobody
|
|
433
|
+
// accepted has no owner to inherit.
|
|
434
|
+
if (!isComment && r.outcome === "delivered")
|
|
435
|
+
rememberOwner(decision.taskId, decision.project);
|
|
217
436
|
audit({
|
|
218
437
|
action: "webhook", actor: `todoist:${who}`, target: r.session || decision.project,
|
|
219
438
|
outcome: r.outcome, body: decision.body, reason: r.reason,
|
|
220
|
-
meta: {
|
|
439
|
+
meta: {
|
|
440
|
+
event: event.event_name, taskId: decision.taskId,
|
|
441
|
+
rule: decision.rule, nearMiss: decision.nearMiss,
|
|
442
|
+
duplicateTitles: twins > 1 ? twins : undefined,
|
|
443
|
+
},
|
|
221
444
|
});
|
|
222
445
|
}
|
|
223
446
|
catch (err) {
|
|
@@ -254,6 +477,8 @@ export function webhookConfigFromEnv() {
|
|
|
254
477
|
port: Number(process.env.TODOIST_WEBHOOK_PORT) || 8766,
|
|
255
478
|
bind: process.env.TODOIST_WEBHOOK_BIND ?? "127.0.0.1",
|
|
256
479
|
path: process.env.TODOIST_WEBHOOK_PATH ?? "/todoist",
|
|
480
|
+
clientId: process.env.TODOIST_CLIENT_ID,
|
|
481
|
+
oauthPath: process.env.TODOIST_OAUTH_PATH ?? "/oauth",
|
|
257
482
|
ingressProjectIds: ids,
|
|
258
483
|
projectOwners: owners,
|
|
259
484
|
defaultOwner: process.env.TODOIST_DEFAULT_OWNER,
|
|
@@ -280,6 +505,8 @@ export function startTodoistWebhook(deps) {
|
|
|
280
505
|
server.listen(cfg.port, cfg.bind, () => {
|
|
281
506
|
log(`todoist-webhook: listening on ${cfg.bind}:${cfg.port}${cfg.path}, ` +
|
|
282
507
|
`${cfg.ingressProjectIds.size} ingress project(s), default owner ${cfg.defaultOwner ?? "(none)"}`);
|
|
508
|
+
log(`todoist-webhook: OAuth landing on ${cfg.oauthPath}` +
|
|
509
|
+
(cfg.clientId ? "" : " — inert until TODOIST_CLIENT_ID is set"));
|
|
283
510
|
});
|
|
284
511
|
server.on("error", (err) => log(`todoist-webhook: server error — ${err.message}`));
|
|
285
512
|
return server;
|