aibroker 0.13.1 → 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 +21 -13
- 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/package.json +1 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon/todoist-oauth.ts — the OAuth landing the redirect URL actually needs.
|
|
3
|
+
*
|
|
4
|
+
* Todoist does not deliver webhooks to the account that created the app. The
|
|
5
|
+
* app has to be authorised like any third party would authorise it, and the
|
|
6
|
+
* console's "install for myself" button does not do that — only a completed
|
|
7
|
+
* OAuth round trip does. Todoist's own guidance is to run the flow by hand and
|
|
8
|
+
* lift the `code` out of the address bar with developer tools, then exchange it
|
|
9
|
+
* from a separate HTTP client because the exchange has to be a POST.
|
|
10
|
+
*
|
|
11
|
+
* That instruction is a description of a missing endpoint. The redirect URL is
|
|
12
|
+
* ours; if it serves nothing, the browser lands on a 404 and the human has to
|
|
13
|
+
* play proxy for a machine-to-machine step. So we serve it: this module takes
|
|
14
|
+
* the `code` off the redirect, does the token exchange itself, stores the token
|
|
15
|
+
* and reports what happened — on the page and in the audit trail.
|
|
16
|
+
*
|
|
17
|
+
* The state parameter is not decoration. `aibroker todoist auth` mints one and
|
|
18
|
+
* records it; a callback that cannot present it is refused, because otherwise
|
|
19
|
+
* anyone who can reach the redirect can drive a code of their choosing into
|
|
20
|
+
* this exchange.
|
|
21
|
+
*/
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { log } from "../core/log.js";
|
|
27
|
+
import { audit } from "./audit.js";
|
|
28
|
+
const STATE_DIR = join(homedir(), ".aibroker");
|
|
29
|
+
const TOKEN_FILE = join(STATE_DIR, "todoist-oauth.json");
|
|
30
|
+
const PENDING_FILE = join(STATE_DIR, "todoist-oauth-pending.json");
|
|
31
|
+
/** A pending authorisation is only good for one attempt, and not for long. */
|
|
32
|
+
const PENDING_TTL_MS = 15 * 60 * 1000;
|
|
33
|
+
function writePrivate(file, data) {
|
|
34
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
35
|
+
writeFileSync(file, JSON.stringify(data, null, 2), "utf-8");
|
|
36
|
+
// The token is a bearer credential for the whole account. Group and world
|
|
37
|
+
// have no business reading it, and the default umask is not a guarantee.
|
|
38
|
+
chmodSync(file, 0o600);
|
|
39
|
+
}
|
|
40
|
+
function readJson(file) {
|
|
41
|
+
if (!existsSync(file))
|
|
42
|
+
return null;
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// A corrupt file is not a reason to erase it — see core/json-store.
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Mint and record the state for one authorisation attempt. */
|
|
52
|
+
export function beginAuth() {
|
|
53
|
+
const state = randomBytes(16).toString("hex");
|
|
54
|
+
writePrivate(PENDING_FILE, { state, created_at: new Date().toISOString() });
|
|
55
|
+
return state;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Check a callback's state against the recorded one and consume it.
|
|
59
|
+
*
|
|
60
|
+
* Returns a reason when it does not hold up, so the caller can say precisely
|
|
61
|
+
* what was wrong rather than "invalid request".
|
|
62
|
+
*/
|
|
63
|
+
export function consumeState(got) {
|
|
64
|
+
const pending = readJson(PENDING_FILE);
|
|
65
|
+
if (!pending)
|
|
66
|
+
return { ok: false, reason: "no authorisation is in progress — run `aibroker todoist auth` first" };
|
|
67
|
+
if (Date.now() - Date.parse(pending.created_at) > PENDING_TTL_MS) {
|
|
68
|
+
unlinkSync(PENDING_FILE);
|
|
69
|
+
return { ok: false, reason: "the authorisation attempt expired — run `aibroker todoist auth` again" };
|
|
70
|
+
}
|
|
71
|
+
if (!got || got !== pending.state)
|
|
72
|
+
return { ok: false, reason: "state did not match the pending authorisation" };
|
|
73
|
+
unlinkSync(PENDING_FILE);
|
|
74
|
+
return { ok: true };
|
|
75
|
+
}
|
|
76
|
+
export function loadToken() {
|
|
77
|
+
return readJson(TOKEN_FILE);
|
|
78
|
+
}
|
|
79
|
+
export function saveToken(t) {
|
|
80
|
+
writePrivate(TOKEN_FILE, t);
|
|
81
|
+
}
|
|
82
|
+
/** Build the URL a browser has to visit to authorise the app. */
|
|
83
|
+
export function authorizeUrl(clientId, scope, state) {
|
|
84
|
+
const q = new URLSearchParams({ client_id: clientId, scope, state });
|
|
85
|
+
return `https://todoist.com/oauth/authorize?${q.toString()}`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Trade the authorisation code for a token.
|
|
89
|
+
*
|
|
90
|
+
* Kept separate from the request handler so the exchange can be tested and so
|
|
91
|
+
* the secret is used in exactly one place.
|
|
92
|
+
*/
|
|
93
|
+
export async function exchangeCode(clientId, clientSecret, code, fetchImpl = fetch) {
|
|
94
|
+
const res = await fetchImpl("https://todoist.com/oauth/access_token", {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
97
|
+
body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code }).toString(),
|
|
98
|
+
});
|
|
99
|
+
const text = await res.text();
|
|
100
|
+
if (!res.ok)
|
|
101
|
+
throw new Error(`token exchange returned ${res.status}: ${text.slice(0, 200)}`);
|
|
102
|
+
let body;
|
|
103
|
+
try {
|
|
104
|
+
body = JSON.parse(text);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new Error(`token exchange returned unparseable body: ${text.slice(0, 200)}`);
|
|
108
|
+
}
|
|
109
|
+
if (body.error)
|
|
110
|
+
throw new Error(`token exchange refused: ${body.error}`);
|
|
111
|
+
if (!body.access_token)
|
|
112
|
+
throw new Error("token exchange returned no access_token");
|
|
113
|
+
return {
|
|
114
|
+
access_token: body.access_token,
|
|
115
|
+
token_type: body.token_type ?? "Bearer",
|
|
116
|
+
scope: body.scope,
|
|
117
|
+
obtained_at: new Date().toISOString(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function page(title, detail, ok) {
|
|
121
|
+
const accent = ok ? "#2d7a4b" : "#a33";
|
|
122
|
+
return `<!doctype html><meta charset="utf-8"><title>${title}</title>
|
|
123
|
+
<style>
|
|
124
|
+
body{font:16px/1.5 -apple-system,system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh;background:#faf9f7;color:#1a1a1a}
|
|
125
|
+
main{max-width:34rem;padding:2rem}
|
|
126
|
+
h1{font-size:1.3rem;margin:0 0 .6rem;color:${accent}}
|
|
127
|
+
p{margin:.4rem 0;color:#444}
|
|
128
|
+
code{background:#eee;padding:.1rem .3rem;border-radius:3px;font-size:.9em}
|
|
129
|
+
@media(prefers-color-scheme:dark){body{background:#141414;color:#eee}p{color:#bbb}code{background:#2a2a2a}}
|
|
130
|
+
</style>
|
|
131
|
+
<main><h1>${title}</h1><p>${detail}</p></main>`;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Handle the redirect Todoist sends the browser to.
|
|
135
|
+
*
|
|
136
|
+
* Always answers with a readable page — this is the one endpoint here a human
|
|
137
|
+
* looks at directly, and "404" taught the last person to debug it nothing.
|
|
138
|
+
*/
|
|
139
|
+
export async function handleOAuthCallback(url, deps) {
|
|
140
|
+
const fail = (status, title, detail, reason) => {
|
|
141
|
+
audit({ action: "todoist-oauth", actor: "todoist", target: "aibroker", outcome: "failed", reason });
|
|
142
|
+
log(`todoist-oauth: ${reason}`);
|
|
143
|
+
return { status, html: page(title, detail, false) };
|
|
144
|
+
};
|
|
145
|
+
const denied = url.searchParams.get("error");
|
|
146
|
+
if (denied) {
|
|
147
|
+
return fail(400, "Authorisation declined", `Todoist reported <code>${denied}</code>. Nothing was changed.`, `authorisation declined by Todoist: ${denied}`);
|
|
148
|
+
}
|
|
149
|
+
const code = url.searchParams.get("code");
|
|
150
|
+
if (!code) {
|
|
151
|
+
return fail(400, "Nothing to exchange", "This is the OAuth landing for the AIBroker Todoist bridge. It expects a <code>code</code> from Todoist; " +
|
|
152
|
+
"opening it directly does nothing. Start with <code>aibroker todoist auth</code>.", "callback carried no code");
|
|
153
|
+
}
|
|
154
|
+
if (!deps.clientId) {
|
|
155
|
+
return fail(500, "Not configured", "Set <code>TODOIST_CLIENT_ID</code> in <code>~/.aibroker/env</code> and restart the daemon — " +
|
|
156
|
+
"the exchange cannot be made without it.", "TODOIST_CLIENT_ID is not set");
|
|
157
|
+
}
|
|
158
|
+
const state = consumeState(url.searchParams.get("state"));
|
|
159
|
+
if (!state.ok) {
|
|
160
|
+
return fail(400, "Unexpected callback", state.reason, `state check failed: ${state.reason}`);
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const token = await exchangeCode(deps.clientId, deps.clientSecret, code, deps.fetchImpl);
|
|
164
|
+
saveToken(token);
|
|
165
|
+
audit({
|
|
166
|
+
action: "todoist-oauth", actor: "todoist", target: "aibroker", outcome: "authorized",
|
|
167
|
+
// The token itself is never recorded — only that one was obtained, and for what.
|
|
168
|
+
meta: { scope: token.scope },
|
|
169
|
+
});
|
|
170
|
+
log(`todoist-oauth: authorised, scope ${token.scope ?? "(unreported)"}`);
|
|
171
|
+
return {
|
|
172
|
+
status: 200,
|
|
173
|
+
html: page("Authorised", `AIBroker can now act on this Todoist account${token.scope ? ` with <code>${token.scope}</code>` : ""}. ` +
|
|
174
|
+
"Webhooks will be delivered from now on. You can close this tab.", true),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
179
|
+
return fail(502, "Exchange failed", reason, reason);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=todoist-oauth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"todoist-oauth.js","sourceRoot":"","sources":["../../src/daemon/todoist-oauth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACpG,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAC;AACrC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAEnC,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,WAAW,CAAC,CAAC;AAC/C,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,EAAE,oBAAoB,CAAC,CAAC;AACzD,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,EAAE,4BAA4B,CAAC,CAAC;AAenE,8EAA8E;AAC9E,MAAM,cAAc,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAEtC,SAAS,YAAY,CAAC,IAAY,EAAE,IAAa;IAC/C,SAAS,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC1C,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;IAC5D,0EAA0E;IAC1E,yEAAyE;IACzE,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AACzB,CAAC;AAED,SAAS,QAAQ,CAAI,IAAY;IAC/B,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACnC,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAM,CAAC;IACtD,CAAC;IAAC,MAAM,CAAC;QACP,oEAAoE;QACpE,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,SAAS;IACvB,MAAM,KAAK,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;IAC9C,YAAY,CAAC,YAAY,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,EAAoB,CAAC,CAAC;IAC9F,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,GAAkB;IAC7C,MAAM,OAAO,GAAG,QAAQ,CAAU,YAAY,CAAC,CAAC;IAChD,IAAI,CAAC,OAAO;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,qEAAqE,EAAE,CAAC;IAClH,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,GAAG,cAAc,EAAE,CAAC;QACjE,UAAU,CAAC,YAAY,CAAC,CAAC;QACzB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,uEAAuE,EAAE,CAAC;IACxG,CAAC;IACD,IAAI,CAAC,GAAG,IAAI,GAAG,KAAK,OAAO,CAAC,KAAK;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,+CAA+C,EAAE,CAAC;IACjH,UAAU,CAAC,YAAY,CAAC,CAAC;IACzB,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;AACtB,CAAC;AAED,MAAM,UAAU,SAAS;IACvB,OAAO,QAAQ,CAAc,UAAU,CAAC,CAAC;AAC3C,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,CAAc;IACtC,YAAY,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC;AAC9B,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,YAAY,CAAC,QAAgB,EAAE,KAAa,EAAE,KAAa;IACzE,MAAM,CAAC,GAAG,IAAI,eAAe,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IACrE,OAAO,uCAAuC,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC;AAC/D,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,QAAgB,EAChB,YAAoB,EACpB,IAAY,EACZ,YAA0B,KAAK;IAE/B,MAAM,GAAG,GAAG,MAAM,SAAS,CAAC,wCAAwC,EAAE;QACpE,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,mCAAmC,EAAE;QAChE,IAAI,EAAE,IAAI,eAAe,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,aAAa,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC,CAAC,QAAQ,EAAE;KACjG,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;IAC9B,IAAI,CAAC,GAAG,CAAC,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,GAAG,CAAC,MAAM,KAAK,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;IAE7F,IAAI,IAAoF,CAAC;IACzF,IAAI,CAAC;QACH,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAgB,CAAC;IACzC,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CAAC,6CAA6C,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;IACrF,CAAC;IACD,IAAI,IAAI,CAAC,KAAK;QAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;IACzE,IAAI,CAAC,IAAI,CAAC,YAAY;QAAE,MAAM,IAAI,KAAK,CAAC,yCAAyC,CAAC,CAAC;IAEnF,OAAO;QACL,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,UAAU,EAAE,IAAI,CAAC,UAAU,IAAI,QAAQ;QACvC,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,WAAW,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;KACtC,CAAC;AACJ,CAAC;AAED,SAAS,IAAI,CAAC,KAAa,EAAE,MAAc,EAAE,EAAW;IACtD,MAAM,MAAM,GAAG,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC;IACvC,OAAO,+CAA+C,KAAK;;;;8CAIf,MAAM;;;;;YAKxC,KAAK,WAAW,MAAM,aAAa,CAAC;AAChD,CAAC;AAQD;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,GAAQ,EACR,IAAe;IAEf,MAAM,IAAI,GAAG,CAAC,MAAc,EAAE,KAAa,EAAE,MAAc,EAAE,MAAc,EAAE,EAAE;QAC7E,KAAK,CAAC,EAAE,MAAM,EAAE,eAAe,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;QACpG,GAAG,CAAC,kBAAkB,MAAM,EAAE,CAAC,CAAC;QAChC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC;IACtD,CAAC,CAAC;IAEF,MAAM,MAAM,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAC7C,IAAI,MAAM,EAAE,CAAC;QACX,OAAO,IAAI,CAAC,GAAG,EAAE,wBAAwB,EAAE,0BAA0B,MAAM,+BAA+B,EACxG,sCAAsC,MAAM,EAAE,CAAC,CAAC;IACpD,CAAC;IAED,MAAM,IAAI,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAC1C,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,IAAI,CAAC,GAAG,EAAE,qBAAqB,EACpC,0GAA0G;YAC1G,kFAAkF,EAClF,0BAA0B,CAAC,CAAC;IAChC,CAAC;IAED,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACnB,OAAO,IAAI,CAAC,GAAG,EAAE,gBAAgB,EAC/B,8FAA8F;YAC9F,yCAAyC,EACzC,8BAA8B,CAAC,CAAC;IACpC,CAAC;IAED,MAAM,KAAK,GAAG,YAAY,CAAC,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;IAC1D,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC;QACd,OAAO,IAAI,CAAC,GAAG,EAAE,qBAAqB,EAAE,KAAK,CAAC,MAAM,EAAE,uBAAuB,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;IAC/F,CAAC;IAED,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,YAAY,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;QACzF,SAAS,CAAC,KAAK,CAAC,CAAC;QACjB,KAAK,CAAC;YACJ,MAAM,EAAE,eAAe,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,YAAY;YACpF,iFAAiF;YACjF,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE;SAC7B,CAAC,CAAC;QACH,GAAG,CAAC,oCAAoC,KAAK,CAAC,KAAK,IAAI,cAAc,EAAE,CAAC,CAAC;QACzE,OAAO;YACL,MAAM,EAAE,GAAG;YACX,IAAI,EAAE,IAAI,CAAC,YAAY,EACrB,+CAA+C,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,KAAK,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI;gBACzG,iEAAiE,EAAE,IAAI,CAAC;SAC3E,CAAC;IACJ,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,MAAM,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QAChE,OAAO,IAAI,CAAC,GAAG,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACtD,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon/todoist-owners.ts — which session took which task.
|
|
3
|
+
*
|
|
4
|
+
* A comment on a task is a correction to work already in flight: "actually, do
|
|
5
|
+
* it this way", "that's wrong, try again". It has to reach the session holding
|
|
6
|
+
* that work, and re-deriving the owner from the parent's project and labels is
|
|
7
|
+
* not the same thing. The original may have been addressed in its title — "pai
|
|
8
|
+
* send me a mail" — while the comment says only "make it next month". Re-derive
|
|
9
|
+
* and the correction lands on whatever the project or the default says, which
|
|
10
|
+
* is a different session than the one that did the work.
|
|
11
|
+
*
|
|
12
|
+
* So the owner is remembered at delivery and preferred at comment time.
|
|
13
|
+
*
|
|
14
|
+
* Bounded and on disk: bounded because this is a cache, not a ledger, and on
|
|
15
|
+
* disk because the daemon restarts more often than a conversation ends.
|
|
16
|
+
*/
|
|
17
|
+
/** Record who took a task, so a later comment on it reaches the same session. */
|
|
18
|
+
export declare function rememberOwner(taskId: string, owner: string): void;
|
|
19
|
+
/** Who took this task, if we still know. */
|
|
20
|
+
export declare function ownerOf(taskId: string): string | undefined;
|
|
21
|
+
/** Test seam: forget everything. */
|
|
22
|
+
export declare function forgetAllOwners(): void;
|
|
23
|
+
//# sourceMappingURL=todoist-owners.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"todoist-owners.d.ts","sourceRoot":"","sources":["../../src/daemon/todoist-owners.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AA8BH,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAUjE;AAED,4CAA4C;AAC5C,wBAAgB,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAG1D;AAED,oCAAoC;AACpC,wBAAgB,eAAe,IAAI,IAAI,CAEtC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon/todoist-owners.ts — which session took which task.
|
|
3
|
+
*
|
|
4
|
+
* A comment on a task is a correction to work already in flight: "actually, do
|
|
5
|
+
* it this way", "that's wrong, try again". It has to reach the session holding
|
|
6
|
+
* that work, and re-deriving the owner from the parent's project and labels is
|
|
7
|
+
* not the same thing. The original may have been addressed in its title — "pai
|
|
8
|
+
* send me a mail" — while the comment says only "make it next month". Re-derive
|
|
9
|
+
* and the correction lands on whatever the project or the default says, which
|
|
10
|
+
* is a different session than the one that did the work.
|
|
11
|
+
*
|
|
12
|
+
* So the owner is remembered at delivery and preferred at comment time.
|
|
13
|
+
*
|
|
14
|
+
* Bounded and on disk: bounded because this is a cache, not a ledger, and on
|
|
15
|
+
* disk because the daemon restarts more often than a conversation ends.
|
|
16
|
+
*/
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { loadJson, saveJson } from "../core/json-store.js";
|
|
20
|
+
const FILE = join(homedir(), ".aibroker", "todoist-owners.json");
|
|
21
|
+
/** Enough for any live conversation; old entries are not worth keeping. */
|
|
22
|
+
const MAX = 500;
|
|
23
|
+
function empty() {
|
|
24
|
+
return { order: [], owners: {} };
|
|
25
|
+
}
|
|
26
|
+
function read() {
|
|
27
|
+
const r = loadJson(FILE);
|
|
28
|
+
// An unreadable cache is not a reason to lose the daemon: start empty and
|
|
29
|
+
// let the next delivery repopulate it. Never erase the file on a parse error
|
|
30
|
+
// — that is the bug core/json-store exists to avoid.
|
|
31
|
+
if (r.status === "ok" && Array.isArray(r.data?.order) && r.data?.owners)
|
|
32
|
+
return r.data;
|
|
33
|
+
return empty();
|
|
34
|
+
}
|
|
35
|
+
/** Record who took a task, so a later comment on it reaches the same session. */
|
|
36
|
+
export function rememberOwner(taskId, owner) {
|
|
37
|
+
if (!taskId || !owner)
|
|
38
|
+
return;
|
|
39
|
+
const s = read();
|
|
40
|
+
if (!(taskId in s.owners))
|
|
41
|
+
s.order.push(taskId);
|
|
42
|
+
s.owners[taskId] = owner;
|
|
43
|
+
while (s.order.length > MAX) {
|
|
44
|
+
const drop = s.order.shift();
|
|
45
|
+
if (drop)
|
|
46
|
+
delete s.owners[drop];
|
|
47
|
+
}
|
|
48
|
+
saveJson(FILE, s);
|
|
49
|
+
}
|
|
50
|
+
/** Who took this task, if we still know. */
|
|
51
|
+
export function ownerOf(taskId) {
|
|
52
|
+
if (!taskId)
|
|
53
|
+
return undefined;
|
|
54
|
+
return read().owners[taskId];
|
|
55
|
+
}
|
|
56
|
+
/** Test seam: forget everything. */
|
|
57
|
+
export function forgetAllOwners() {
|
|
58
|
+
saveJson(FILE, empty());
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=todoist-owners.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"todoist-owners.js","sourceRoot":"","sources":["../../src/daemon/todoist-owners.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAE3D,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,WAAW,EAAE,qBAAqB,CAAC,CAAC;AAEjE,2EAA2E;AAC3E,MAAM,GAAG,GAAG,GAAG,CAAC;AAQhB,SAAS,KAAK;IACZ,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;AACnC,CAAC;AAED,SAAS,IAAI;IACX,MAAM,CAAC,GAAG,QAAQ,CAAQ,IAAI,CAAC,CAAC;IAChC,0EAA0E;IAC1E,6EAA6E;IAC7E,qDAAqD;IACrD,IAAI,CAAC,CAAC,MAAM,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,MAAM;QAAE,OAAO,CAAC,CAAC,IAAI,CAAC;IACvF,OAAO,KAAK,EAAE,CAAC;AACjB,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,aAAa,CAAC,MAAc,EAAE,KAAa;IACzD,IAAI,CAAC,MAAM,IAAI,CAAC,KAAK;QAAE,OAAO;IAC9B,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC;IACjB,IAAI,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,MAAM,CAAC;QAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAChD,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,KAAK,CAAC;IACzB,OAAO,CAAC,CAAC,KAAK,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC;QAC5B,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;QAC7B,IAAI,IAAI;YAAE,OAAO,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAClC,CAAC;IACD,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;AACpB,CAAC;AAED,4CAA4C;AAC5C,MAAM,UAAU,OAAO,CAAC,MAAc;IACpC,IAAI,CAAC,MAAM;QAAE,OAAO,SAAS,CAAC;IAC9B,OAAO,IAAI,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;AAC/B,CAAC;AAED,oCAAoC;AACpC,MAAM,UAAU,eAAe;IAC7B,QAAQ,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;AAC1B,CAAC"}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
export interface ReplyResult {
|
|
19
|
+
commentId: string;
|
|
20
|
+
taskId: string;
|
|
21
|
+
}
|
|
22
|
+
/** What a comment event needs from its parent task to be routable at all. */
|
|
23
|
+
export interface ParentTask {
|
|
24
|
+
content: string;
|
|
25
|
+
projectId: string;
|
|
26
|
+
labels: string[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Fetch the task a comment belongs to.
|
|
30
|
+
*
|
|
31
|
+
* A `note:added` payload carries `item_id` and the comment text and nothing
|
|
32
|
+
* else — no project, no labels. Without them the security boundary cannot be
|
|
33
|
+
* evaluated, so a comment is unroutable until its parent is resolved. This is
|
|
34
|
+
* the lookup that makes replying-by-comment possible at all.
|
|
35
|
+
*/
|
|
36
|
+
export declare function fetchParentTask(taskId: string, fetchImpl?: typeof fetch): Promise<ParentTask>;
|
|
37
|
+
/**
|
|
38
|
+
* Post a comment on a task as the agent.
|
|
39
|
+
*
|
|
40
|
+
* The mark is added here rather than trusted to callers: every path that can
|
|
41
|
+
* write to Todoist has to be echo-safe, and "remember the prefix" is not a
|
|
42
|
+
* safety mechanism.
|
|
43
|
+
*/
|
|
44
|
+
export declare function replyToTask(taskId: string, text: string, fetchImpl?: typeof fetch): Promise<ReplyResult>;
|
|
45
|
+
/**
|
|
46
|
+
* How many OPEN tasks in this project share a title.
|
|
47
|
+
*
|
|
48
|
+
* Two tasks called the same thing are indistinguishable in a list, so a reply
|
|
49
|
+
* posted on one looks — to whoever is watching the other — exactly like being
|
|
50
|
+
* ignored. That cost two full rounds today: comment routing was working
|
|
51
|
+
* perfectly and the answers were landing on a sibling nobody was looking at.
|
|
52
|
+
* Being ignored and being answered elsewhere must not look the same.
|
|
53
|
+
*
|
|
54
|
+
* Returns 1 in the ordinary case (this task alone) and 0 when it cannot tell —
|
|
55
|
+
* a lookup failure must not invent a duplicate warning.
|
|
56
|
+
*/
|
|
57
|
+
export declare function countTasksWithTitle(projectId: string, title: string, fetchImpl?: typeof fetch): Promise<number>;
|
|
58
|
+
//# sourceMappingURL=todoist-reply.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"todoist-reply.d.ts","sourceRoot":"","sources":["../../src/daemon/todoist-reply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAOH,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,6EAA6E;AAC7E,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC,UAAU,CAAC,CAqBrB;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC,WAAW,CAAC,CAiCtB;AAQD;;;;;;;;;;;GAWG;AACH,wBAAsB,mBAAmB,CACvC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC,MAAM,CAAC,CA4BjB"}
|
|
@@ -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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAA2D,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;AAMjG,qFAAqF;AACrF,eAAO,MAAM,UAAU,iBAAO,CAAC;AAE/B,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC3C,SAAS,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAChE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;;OASG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,mFAAmF;IACnF,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,yEAAyE;AACzE,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,CAAC;AAE7E,MAAM,MAAM,aAAa,GACrB;IAAE,GAAG,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAChG;IAAE,GAAG,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AA6CtD;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,GACjB;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAOjF;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAOhG;AAED,sEAAsE;AACtE,wBAAgB,QAAQ,CAAC,CAAC,EAAE,YAAY,GAAG,MAAM,CAGhD;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CACnB,CAAC,EAAE,YAAY,EACf,GAAG,EAAE,aAAa,EAClB,WAAW,GAAE,QAAQ,CAAC,MAAM,CAAM;AAClC;;;;;GAKG;AACH,MAAM,CAAC,EAAE,MAAM,GACd,aAAa,CA6Ef;AAmBD,MAAM,WAAW,WAAW;IAC1B;;;;;;OAMG;IACH,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,KACjE,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjE;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,MAAM,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAClD;AAgBD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,aAAa,EAAE,IAAI,EAAE,WAAW,GAAG,MAAM,CAsMjF;AAED,0EAA0E;AAC1E,wBAAgB,oBAAoB,IAAI,aAAa,GAAG,IAAI,CAyB3D;AAED,yDAAyD;AACzD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CA0BpE"}
|