@pellux/goodvibes-tui 0.24.1 → 0.26.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/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/handlers/calendar/caldav-client.ts +661 -0
- package/src/daemon/handlers/calendar/ics.ts +556 -0
- package/src/daemon/handlers/calendar/index.ts +316 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +77 -0
- package/src/daemon/handlers/credentials.ts +129 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/email/config.ts +164 -0
- package/src/daemon/handlers/email/imap-connector.ts +441 -0
- package/src/daemon/handlers/email/imap-parsing.ts +499 -0
- package/src/daemon/handlers/email/index.ts +43 -0
- package/src/daemon/handlers/email/read-handlers.ts +80 -0
- package/src/daemon/handlers/email/runtime.ts +140 -0
- package/src/daemon/handlers/email/smtp-connector.ts +557 -0
- package/src/daemon/handlers/email/validation.ts +147 -0
- package/src/daemon/handlers/email/write-handlers.ts +133 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +290 -0
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +155 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
- package/src/daemon/handlers/inbox/providers/email.ts +151 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +125 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +124 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/handlers/triage/pipeline.ts +273 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/runtime/services.ts +48 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Daemon-internal email auto-tag / spam triage SCORER.
|
|
3
|
+
//
|
|
4
|
+
// Pure, dependency-free heuristic + naive-Bayes-style spam/priority scoring
|
|
5
|
+
// over an InboundChannelItem's textual surface (subject + snippet). No I/O,
|
|
6
|
+
// no credentials, no network — safe to run on every polled item.
|
|
7
|
+
//
|
|
8
|
+
// The score is a normalized 0..1 confidence in the assigned label. The label
|
|
9
|
+
// is one of 'spam' | 'priority' | 'normal'. Scoring is deterministic so the
|
|
10
|
+
// pipeline can persist stable triageScore/triageTags values across polls.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
import type { InboundChannelItem, TriageLabel } from './types.ts';
|
|
14
|
+
|
|
15
|
+
export type { TriageLabel } from './types.ts';
|
|
16
|
+
|
|
17
|
+
export interface TriageScore {
|
|
18
|
+
/** Confidence in the assigned label, 0..1 (2-decimal rounded). */
|
|
19
|
+
score: number;
|
|
20
|
+
label: TriageLabel;
|
|
21
|
+
/** Raw component probabilities (for diagnostics / tests). */
|
|
22
|
+
signals: {
|
|
23
|
+
spam: number; // 0..1 P(spam)
|
|
24
|
+
priority: number; // 0..1 P(priority)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TriageScorerOptions {
|
|
29
|
+
/** Minimum P(spam) to label an item 'spam'. Default 0.65. */
|
|
30
|
+
spamThreshold?: number;
|
|
31
|
+
/** Minimum P(priority) to label an item 'priority'. Default 0.6. */
|
|
32
|
+
priorityThreshold?: number;
|
|
33
|
+
/** Extra spam lexicon terms (lowercased) merged with the defaults. */
|
|
34
|
+
extraSpamTerms?: readonly string[];
|
|
35
|
+
/** Extra priority lexicon terms (lowercased) merged with the defaults. */
|
|
36
|
+
extraPriorityTerms?: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_SPAM_THRESHOLD = 0.65;
|
|
40
|
+
const DEFAULT_PRIORITY_THRESHOLD = 0.6;
|
|
41
|
+
|
|
42
|
+
// Tokens that, when present, push toward spam. Weighted log-likelihoods.
|
|
43
|
+
const SPAM_TERMS: ReadonlyMap<string, number> = new Map([
|
|
44
|
+
['viagra', 3.2],
|
|
45
|
+
['lottery', 2.8],
|
|
46
|
+
['winner', 2.0],
|
|
47
|
+
['congratulations', 1.4],
|
|
48
|
+
['prize', 2.2],
|
|
49
|
+
['free', 1.1],
|
|
50
|
+
['cash', 1.6],
|
|
51
|
+
['bitcoin', 1.5],
|
|
52
|
+
['crypto', 1.2],
|
|
53
|
+
['investment', 1.0],
|
|
54
|
+
['guarantee', 1.3],
|
|
55
|
+
['guaranteed', 1.5],
|
|
56
|
+
['risk-free', 2.0],
|
|
57
|
+
['click', 1.2],
|
|
58
|
+
['unsubscribe', 0.9],
|
|
59
|
+
['limited', 0.9],
|
|
60
|
+
['offer', 1.0],
|
|
61
|
+
['act now', 2.2],
|
|
62
|
+
['urgent', 0.7],
|
|
63
|
+
['wire transfer', 2.6],
|
|
64
|
+
['nigerian', 2.6],
|
|
65
|
+
['inheritance', 2.4],
|
|
66
|
+
['million', 1.4],
|
|
67
|
+
['pharmacy', 2.2],
|
|
68
|
+
['pills', 2.0],
|
|
69
|
+
['loan', 1.4],
|
|
70
|
+
['debt', 1.2],
|
|
71
|
+
['refinance', 1.6],
|
|
72
|
+
['casino', 2.4],
|
|
73
|
+
['earn money', 2.2],
|
|
74
|
+
['work from home', 1.8],
|
|
75
|
+
['100% free', 2.4],
|
|
76
|
+
['no cost', 1.6],
|
|
77
|
+
['cheap', 1.2],
|
|
78
|
+
['discount', 0.8],
|
|
79
|
+
['weight loss', 2.0],
|
|
80
|
+
['verify your account', 2.6],
|
|
81
|
+
['suspended', 1.4],
|
|
82
|
+
['password', 1.0],
|
|
83
|
+
['confirm your identity', 2.4],
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
// Tokens that push toward priority/important.
|
|
87
|
+
const PRIORITY_TERMS: ReadonlyMap<string, number> = new Map([
|
|
88
|
+
['urgent', 2.4],
|
|
89
|
+
['asap', 2.4],
|
|
90
|
+
['immediately', 2.0],
|
|
91
|
+
['deadline', 2.0],
|
|
92
|
+
['today', 1.2],
|
|
93
|
+
['eod', 1.6],
|
|
94
|
+
['important', 1.6],
|
|
95
|
+
['action required', 2.2],
|
|
96
|
+
['action needed', 2.2],
|
|
97
|
+
['please respond', 1.8],
|
|
98
|
+
['please reply', 1.8],
|
|
99
|
+
['waiting', 1.0],
|
|
100
|
+
['follow up', 1.2],
|
|
101
|
+
['follow-up', 1.2],
|
|
102
|
+
['reminder', 1.0],
|
|
103
|
+
['overdue', 2.2],
|
|
104
|
+
['blocker', 2.0],
|
|
105
|
+
['blocked', 1.6],
|
|
106
|
+
['escalation', 2.2],
|
|
107
|
+
['escalate', 2.0],
|
|
108
|
+
['critical', 2.4],
|
|
109
|
+
['emergency', 2.6],
|
|
110
|
+
['payment due', 2.0],
|
|
111
|
+
['invoice', 1.2],
|
|
112
|
+
['signature', 1.0],
|
|
113
|
+
['approval', 1.4],
|
|
114
|
+
['approve', 1.4],
|
|
115
|
+
['meeting', 0.8],
|
|
116
|
+
['call me', 1.6],
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
const URL_PATTERN = /https?:\/\/[^\s]+/gi;
|
|
120
|
+
const MONEY_PATTERN = /(?:[$£€]\s?\d|(?:\d[\d,]*)\s?(?:usd|eur|gbp|dollars|euros))/i;
|
|
121
|
+
const EXCESSIVE_PUNCT = /[!?]{2,}/;
|
|
122
|
+
|
|
123
|
+
function normalizeText(item: InboundChannelItem): string {
|
|
124
|
+
const subject = typeof item.subject === 'string' ? item.subject : '';
|
|
125
|
+
const snippet = typeof item.snippet === 'string' ? item.snippet : '';
|
|
126
|
+
// Subject is weighted more heavily by duplicating it once.
|
|
127
|
+
return `${subject} ${subject} ${snippet}`.trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function escapeRegExp(value: string): string {
|
|
131
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function countLexicon(text: string, lexicon: ReadonlyMap<string, number>): number {
|
|
135
|
+
let total = 0;
|
|
136
|
+
for (const [term, weight] of lexicon) {
|
|
137
|
+
if (term.includes(' ')) {
|
|
138
|
+
// Phrase match: count non-overlapping occurrences.
|
|
139
|
+
let from = 0;
|
|
140
|
+
let idx = text.indexOf(term, from);
|
|
141
|
+
while (idx !== -1) {
|
|
142
|
+
total += weight;
|
|
143
|
+
from = idx + term.length;
|
|
144
|
+
idx = text.indexOf(term, from);
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
// Word-boundary match for single tokens.
|
|
148
|
+
const re = new RegExp(`\\b${escapeRegExp(term)}\\b`, 'g');
|
|
149
|
+
const matches = text.match(re);
|
|
150
|
+
if (matches) total += weight * matches.length;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return total;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function upperCaseRatio(text: string): number {
|
|
157
|
+
const letters = text.replace(/[^a-zA-Z]/g, '');
|
|
158
|
+
if (letters.length < 8) return 0;
|
|
159
|
+
let upper = 0;
|
|
160
|
+
for (const ch of letters) {
|
|
161
|
+
if (ch >= 'A' && ch <= 'Z') upper += 1;
|
|
162
|
+
}
|
|
163
|
+
return upper / letters.length;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function urlDensity(text: string): number {
|
|
167
|
+
const urls = text.match(URL_PATTERN);
|
|
168
|
+
const urlCount = urls ? urls.length : 0;
|
|
169
|
+
const words = text.split(/\s+/).filter((w) => w.length > 0).length || 1;
|
|
170
|
+
return urlCount / words;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Logistic squash of an accumulated log-likelihood into 0..1. */
|
|
174
|
+
function sigmoid(x: number): number {
|
|
175
|
+
return 1 / (1 + Math.exp(-x));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function round2(value: number): number {
|
|
179
|
+
return Math.round(value * 100) / 100;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function clamp01(value: number): number {
|
|
183
|
+
if (Number.isNaN(value)) return 0;
|
|
184
|
+
if (value < 0) return 0;
|
|
185
|
+
if (value > 1) return 1;
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function mergeLexicon(
|
|
190
|
+
base: ReadonlyMap<string, number>,
|
|
191
|
+
extra: readonly string[] | undefined,
|
|
192
|
+
extraWeight: number,
|
|
193
|
+
): ReadonlyMap<string, number> {
|
|
194
|
+
if (!extra || extra.length === 0) return base;
|
|
195
|
+
const merged = new Map(base);
|
|
196
|
+
for (const term of extra) {
|
|
197
|
+
const key = term.trim().toLowerCase();
|
|
198
|
+
if (key.length === 0) continue;
|
|
199
|
+
merged.set(key, Math.max(merged.get(key) ?? 0, extraWeight));
|
|
200
|
+
}
|
|
201
|
+
return merged;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Score a single inbound item for spam likelihood and priority likelihood,
|
|
206
|
+
* then resolve a single label. Deterministic and side-effect free.
|
|
207
|
+
*/
|
|
208
|
+
export function scoreInboundItem(
|
|
209
|
+
item: InboundChannelItem,
|
|
210
|
+
options: TriageScorerOptions = {},
|
|
211
|
+
): TriageScore {
|
|
212
|
+
const spamThreshold = options.spamThreshold ?? DEFAULT_SPAM_THRESHOLD;
|
|
213
|
+
const priorityThreshold = options.priorityThreshold ?? DEFAULT_PRIORITY_THRESHOLD;
|
|
214
|
+
|
|
215
|
+
const spamLexicon = mergeLexicon(SPAM_TERMS, options.extraSpamTerms, 1.5);
|
|
216
|
+
const priorityLexicon = mergeLexicon(PRIORITY_TERMS, options.extraPriorityTerms, 1.5);
|
|
217
|
+
|
|
218
|
+
const raw = normalizeText(item);
|
|
219
|
+
const text = raw.toLowerCase();
|
|
220
|
+
|
|
221
|
+
// ---- Spam evidence -----------------------------------------------------
|
|
222
|
+
// Bias term tuned so a clean message lands well below the threshold.
|
|
223
|
+
let spamLL = -2.4;
|
|
224
|
+
spamLL += countLexicon(text, spamLexicon);
|
|
225
|
+
|
|
226
|
+
const caps = upperCaseRatio(raw);
|
|
227
|
+
if (caps > 0.6) spamLL += 1.8;
|
|
228
|
+
else if (caps > 0.4) spamLL += 0.9;
|
|
229
|
+
|
|
230
|
+
const density = urlDensity(text);
|
|
231
|
+
if (density > 0.25) spamLL += 1.6;
|
|
232
|
+
else if (density > 0.1) spamLL += 0.8;
|
|
233
|
+
|
|
234
|
+
if (MONEY_PATTERN.test(raw)) spamLL += 0.8;
|
|
235
|
+
if (EXCESSIVE_PUNCT.test(raw)) spamLL += 0.6;
|
|
236
|
+
if (raw.length === 0) spamLL -= 1.0; // empty/no-text items are not spam
|
|
237
|
+
|
|
238
|
+
const spam = clamp01(sigmoid(spamLL));
|
|
239
|
+
|
|
240
|
+
// ---- Priority evidence -------------------------------------------------
|
|
241
|
+
let priorityLL = -2.2;
|
|
242
|
+
priorityLL += countLexicon(text, priorityLexicon);
|
|
243
|
+
|
|
244
|
+
// Direct conversations (1:1 DMs) are inherently more likely to be priority.
|
|
245
|
+
if (item.conversationKind === 'direct') priorityLL += 0.8;
|
|
246
|
+
if (item.unread === true) priorityLL += 0.3;
|
|
247
|
+
// A trailing question mark suggests an awaited answer.
|
|
248
|
+
if (/\?\s*$/.test(raw)) priorityLL += 0.5;
|
|
249
|
+
|
|
250
|
+
// Spam strongly suppresses priority — promotional text is rarely urgent.
|
|
251
|
+
priorityLL -= spam * 2.5;
|
|
252
|
+
|
|
253
|
+
const priority = clamp01(sigmoid(priorityLL));
|
|
254
|
+
|
|
255
|
+
// ---- Label resolution --------------------------------------------------
|
|
256
|
+
let label: TriageLabel = 'normal';
|
|
257
|
+
let score: number;
|
|
258
|
+
if (spam >= spamThreshold && spam >= priority) {
|
|
259
|
+
label = 'spam';
|
|
260
|
+
score = spam;
|
|
261
|
+
} else if (priority >= priorityThreshold) {
|
|
262
|
+
label = 'priority';
|
|
263
|
+
score = priority;
|
|
264
|
+
} else {
|
|
265
|
+
label = 'normal';
|
|
266
|
+
// Confidence in 'normal' is how far both signals sit below their gates.
|
|
267
|
+
score = clamp01(1 - Math.max(spam, priority));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
score: round2(score),
|
|
272
|
+
label,
|
|
273
|
+
signals: { spam: round2(spam), priority: round2(priority) },
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Map a label to the canonical provider-side tag string applied by the tagger. */
|
|
278
|
+
export function labelToTag(label: TriageLabel): string {
|
|
279
|
+
switch (label) {
|
|
280
|
+
case 'spam':
|
|
281
|
+
return 'GoodVibes/Spam';
|
|
282
|
+
case 'priority':
|
|
283
|
+
return 'GoodVibes/Priority';
|
|
284
|
+
default:
|
|
285
|
+
return 'GoodVibes/Normal';
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Triage tagger — Discord provider.
|
|
3
|
+
//
|
|
4
|
+
// Two paths, in priority order:
|
|
5
|
+
// 1. REAL thread tags: when the item targets a forum/media-channel thread and
|
|
6
|
+
// a forum-tag mapping resolves >=1 tag id, PATCH the thread's applied_tags
|
|
7
|
+
// (read-then-merge — never blind overwrite). Exact fidelity to the
|
|
8
|
+
// contract's "Discord thread tags".
|
|
9
|
+
// 2. Reaction analog: Discord has no arbitrary per-message tags, so otherwise
|
|
10
|
+
// add a unicode reaction (PUT .../reactions/{emoji}/@me).
|
|
11
|
+
//
|
|
12
|
+
// The bot token is resolved per-apply from the daemon credential store and is
|
|
13
|
+
// never logged or returned.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
import type { HandlerContext } from '../../context.ts';
|
|
17
|
+
import type { DaemonCredentialStore } from '../../credentials.ts';
|
|
18
|
+
import { HandlerError } from '../../errors.ts';
|
|
19
|
+
import type { InboundChannelItem } from '../types.ts';
|
|
20
|
+
import type { ApplyTagsResult, TaggerProviderConfig } from './shared.ts';
|
|
21
|
+
import { discordEmojiForTag, stringMeta } from './shared.ts';
|
|
22
|
+
|
|
23
|
+
export async function applyDiscord(
|
|
24
|
+
item: InboundChannelItem,
|
|
25
|
+
tags: string[],
|
|
26
|
+
providers: TaggerProviderConfig,
|
|
27
|
+
credentials: DaemonCredentialStore,
|
|
28
|
+
fetchImpl: typeof fetch,
|
|
29
|
+
ctx: HandlerContext,
|
|
30
|
+
base: ApplyTagsResult,
|
|
31
|
+
): Promise<ApplyTagsResult> {
|
|
32
|
+
const cfg = providers.discord;
|
|
33
|
+
if (!cfg) return { ...base, reason: 'discord-not-configured' };
|
|
34
|
+
const channelId = stringMeta(item, 'channelId') ?? item.conversationId;
|
|
35
|
+
const messageId = stringMeta(item, 'messageId') ?? item.id;
|
|
36
|
+
if (!channelId || !messageId) return { ...base, reason: 'discord-missing-target' };
|
|
37
|
+
if (tags.length === 0) return { ...base, reason: 'no-tags' };
|
|
38
|
+
|
|
39
|
+
const token = await credentials.resolveConfigSecret(cfg.tokenConfigKey);
|
|
40
|
+
if (!token) return { ...base, reason: 'discord-no-credentials' };
|
|
41
|
+
|
|
42
|
+
// Exact-fidelity path: when the item targets a forum/media-channel thread and
|
|
43
|
+
// a forum-tag mapping resolves at least one tag, apply REAL Discord thread
|
|
44
|
+
// tags (PATCH the thread's applied_tags). A forum post's thread id is the
|
|
45
|
+
// thread's own channel id; accept an explicit metadata.threadId or fall back
|
|
46
|
+
// to channelId for that case.
|
|
47
|
+
const threadId = stringMeta(item, 'threadId') ?? channelId;
|
|
48
|
+
const tagIds = resolveDiscordTagIds(tags, cfg.forumTagIds);
|
|
49
|
+
if (tagIds.length > 0) {
|
|
50
|
+
return applyDiscordThreadTags(item, threadId, tags, tagIds, token, fetchImpl, ctx);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Analog fallback: Discord has no arbitrary per-message tags, so apply a
|
|
54
|
+
// unicode reaction on the source message instead.
|
|
55
|
+
const applied: string[] = [];
|
|
56
|
+
for (const tag of tags) {
|
|
57
|
+
const emoji = encodeURIComponent(discordEmojiForTag(tag));
|
|
58
|
+
const url = `https://discord.com/api/v10/channels/${channelId}/messages/${messageId}/reactions/${emoji}/@me`;
|
|
59
|
+
const response = await fetchImpl(url, {
|
|
60
|
+
method: 'PUT',
|
|
61
|
+
headers: {
|
|
62
|
+
Authorization: `Bot ${token}`,
|
|
63
|
+
'Content-Length': '0',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
// 204 No Content is the success case for adding a reaction.
|
|
67
|
+
if (response.status !== 204 && response.status !== 200) {
|
|
68
|
+
const detail = await response.text().catch(() => '');
|
|
69
|
+
ctx.logger.warn('triage: discord reaction rejected', { status: response.status });
|
|
70
|
+
throw new HandlerError(
|
|
71
|
+
`Discord reaction HTTP ${response.status}${detail ? `: ${detail.slice(0, 120)}` : ''}`,
|
|
72
|
+
'TRIAGE_DISCORD_TAG_FAILED',
|
|
73
|
+
502,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
applied.push(tag);
|
|
77
|
+
}
|
|
78
|
+
return { surface: item.surface, itemId: item.id, appliedTags: applied, skipped: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Map GoodVibes triage tags to configured Discord forum-tag snowflake ids,
|
|
83
|
+
* preserving order and dropping unmapped tags. De-duplicates ids.
|
|
84
|
+
*/
|
|
85
|
+
function resolveDiscordTagIds(
|
|
86
|
+
tags: readonly string[],
|
|
87
|
+
forumTagIds: Record<string, string> | undefined,
|
|
88
|
+
): string[] {
|
|
89
|
+
if (!forumTagIds) return [];
|
|
90
|
+
const ids: string[] = [];
|
|
91
|
+
for (const tag of tags) {
|
|
92
|
+
const id = forumTagIds[tag];
|
|
93
|
+
if (typeof id === 'string' && id.length > 0 && !ids.includes(id)) ids.push(id);
|
|
94
|
+
}
|
|
95
|
+
return ids;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Apply real Discord thread tags by merging the resolved forum-tag ids into the
|
|
100
|
+
* thread's existing applied_tags via PATCH /channels/{thread.id}. Idempotent:
|
|
101
|
+
* already-present tag ids are kept and not duplicated.
|
|
102
|
+
*/
|
|
103
|
+
async function applyDiscordThreadTags(
|
|
104
|
+
item: InboundChannelItem,
|
|
105
|
+
threadId: string,
|
|
106
|
+
tags: readonly string[],
|
|
107
|
+
tagIds: readonly string[],
|
|
108
|
+
token: string,
|
|
109
|
+
fetchImpl: typeof fetch,
|
|
110
|
+
ctx: HandlerContext,
|
|
111
|
+
): Promise<ApplyTagsResult> {
|
|
112
|
+
const url = `https://discord.com/api/v10/channels/${threadId}`;
|
|
113
|
+
// DATA-LOSS GUARD: we PATCH the thread's full applied_tags array, so we must
|
|
114
|
+
// first read the EXISTING tags and merge. If that read fails (network error
|
|
115
|
+
// OR a non-ok HTTP status), we have no idea what tags are currently on the
|
|
116
|
+
// thread — PATCHing with only our new ids would silently destroy whatever
|
|
117
|
+
// forum tags were already applied. Abort instead of overwriting.
|
|
118
|
+
const existing = await fetchDiscordAppliedTags(url, token, fetchImpl, ctx);
|
|
119
|
+
if (existing === null) {
|
|
120
|
+
throw new HandlerError(
|
|
121
|
+
'Discord thread tag read failed; aborting to avoid overwriting existing applied_tags.',
|
|
122
|
+
'TRIAGE_DISCORD_TAG_FAILED',
|
|
123
|
+
502,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const merged = [...new Set([...existing, ...tagIds])];
|
|
127
|
+
const response = await fetchImpl(url, {
|
|
128
|
+
method: 'PATCH',
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: `Bot ${token}`,
|
|
131
|
+
'Content-Type': 'application/json',
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify({ applied_tags: merged }),
|
|
134
|
+
});
|
|
135
|
+
if (response.status !== 200 && response.status !== 204) {
|
|
136
|
+
const detail = await response.text().catch(() => '');
|
|
137
|
+
ctx.logger.warn('triage: discord thread tag rejected', { status: response.status });
|
|
138
|
+
throw new HandlerError(
|
|
139
|
+
`Discord thread tag HTTP ${response.status}${detail ? `: ${detail.slice(0, 120)}` : ''}`,
|
|
140
|
+
'TRIAGE_DISCORD_TAG_FAILED',
|
|
141
|
+
502,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return { surface: item.surface, itemId: item.id, appliedTags: [...tags], skipped: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read a thread's current applied_tags.
|
|
149
|
+
*
|
|
150
|
+
* Returns:
|
|
151
|
+
* - string[] -> the read SUCCEEDED; the array is the current applied_tags
|
|
152
|
+
* (possibly empty when the thread genuinely has no tags, or
|
|
153
|
+
* when applied_tags is absent/malformed in a 2xx body).
|
|
154
|
+
* - null -> the read FAILED (thrown error OR non-ok HTTP status). The
|
|
155
|
+
* caller MUST NOT proceed with a PATCH in this case, because a
|
|
156
|
+
* failed read means the existing tag set is unknown and a
|
|
157
|
+
* PATCH would overwrite it (data loss).
|
|
158
|
+
*/
|
|
159
|
+
async function fetchDiscordAppliedTags(
|
|
160
|
+
url: string,
|
|
161
|
+
token: string,
|
|
162
|
+
fetchImpl: typeof fetch,
|
|
163
|
+
ctx: HandlerContext,
|
|
164
|
+
): Promise<string[] | null> {
|
|
165
|
+
try {
|
|
166
|
+
const response = await fetchImpl(url, {
|
|
167
|
+
method: 'GET',
|
|
168
|
+
headers: { Authorization: `Bot ${token}` },
|
|
169
|
+
});
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
ctx.logger.warn('triage: discord thread tag read failed', { status: response.status });
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const payload = (await response.json().catch(() => null)) as
|
|
175
|
+
| { applied_tags?: unknown }
|
|
176
|
+
| null;
|
|
177
|
+
const current = payload?.applied_tags;
|
|
178
|
+
if (!Array.isArray(current)) return [];
|
|
179
|
+
return current.filter((t): t is string => typeof t === 'string');
|
|
180
|
+
} catch (error) {
|
|
181
|
+
ctx.logger.warn('triage: discord thread tag read errored', {
|
|
182
|
+
message: error instanceof Error ? error.message : String(error),
|
|
183
|
+
});
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|