@ask-thane/thane-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/store.js ADDED
@@ -0,0 +1,1417 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { createHmac } from "node:crypto";
3
+ import { dirname, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { previewSlackExport } from "./slack-import.js";
6
+ const fallbackStorePath = join(process.cwd(), ".thane", "store.json");
7
+ export function resolveStorePath() {
8
+ if (process.env.THANE_STORE_PATH) {
9
+ return process.env.THANE_STORE_PATH;
10
+ }
11
+ if (process.env.THANE_HOME) {
12
+ return join(process.env.THANE_HOME, "store.json");
13
+ }
14
+ if (process.env.THANE_USE_USER_HOME === "1") {
15
+ return join(homedir(), ".thane", "store.json");
16
+ }
17
+ return fallbackStorePath;
18
+ }
19
+ function nowIso() {
20
+ return new Date().toISOString();
21
+ }
22
+ function id(prefix) {
23
+ const random = Math.random().toString(36).slice(2, 10);
24
+ return `${prefix}_${Date.now().toString(36)}${random}`;
25
+ }
26
+ function normalizeEmail(email) {
27
+ return email.trim().toLowerCase();
28
+ }
29
+ function handleFromEmail(email) {
30
+ return normalizeEmail(email).split("@")[0]?.replace(/[^a-z0-9._-]+/gi, "-").toLowerCase() || "user";
31
+ }
32
+ function displayNameFromEmail(email) {
33
+ const handle = handleFromEmail(email);
34
+ return handle.charAt(0).toUpperCase() + handle.slice(1);
35
+ }
36
+ function makeLoginCode() {
37
+ return Math.floor(100000 + Math.random() * 900000).toString();
38
+ }
39
+ export const THANE_CLI_FREE_LIMITS = {
40
+ members: 10,
41
+ privateChannels: 3,
42
+ historyDays: 90
43
+ };
44
+ export const THANE_CLI_TEAM_PRICE = {
45
+ monthlyPerMemberUsd: 8,
46
+ history: "unlimited",
47
+ privateChannels: "unlimited"
48
+ };
49
+ function defaultData() {
50
+ const createdAt = nowIso();
51
+ const workspace = {
52
+ id: "wsp_local",
53
+ slug: "local",
54
+ name: "Local Workspace",
55
+ createdAt
56
+ };
57
+ const account = {
58
+ id: "acct_local",
59
+ email: "you@example.local",
60
+ displayName: "You",
61
+ createdAt
62
+ };
63
+ const user = {
64
+ id: "usr_local",
65
+ workspaceId: workspace.id,
66
+ accountId: account.id,
67
+ handle: "you",
68
+ displayName: "You",
69
+ email: account.email
70
+ };
71
+ const member = {
72
+ id: "mbr_local",
73
+ workspaceId: workspace.id,
74
+ accountId: account.id,
75
+ userId: user.id,
76
+ role: "owner",
77
+ joinedAt: createdAt
78
+ };
79
+ const channels = [
80
+ {
81
+ id: "chn_general",
82
+ workspaceId: workspace.id,
83
+ name: "general",
84
+ kind: "channel",
85
+ visibility: "public",
86
+ memberIds: [user.id],
87
+ topic: "Default team chat",
88
+ createdAt
89
+ },
90
+ {
91
+ id: "chn_engineering",
92
+ workspaceId: workspace.id,
93
+ name: "engineering",
94
+ kind: "channel",
95
+ visibility: "public",
96
+ memberIds: [user.id],
97
+ topic: "Build notes and review requests",
98
+ createdAt
99
+ }
100
+ ];
101
+ return {
102
+ currentAccountId: account.id,
103
+ accounts: [account],
104
+ workspaceMembers: [member],
105
+ askThaneIntegrations: [],
106
+ notificationPreferences: [],
107
+ billingPlans: [],
108
+ pendingLogins: [],
109
+ activeWorkspaceId: workspace.id,
110
+ workspaces: [workspace],
111
+ currentUserId: user.id,
112
+ users: [user],
113
+ channels,
114
+ messages: [
115
+ {
116
+ id: "msg_welcome",
117
+ workspaceId: workspace.id,
118
+ channelId: "chn_general",
119
+ authorId: user.id,
120
+ text: "Welcome to Thane CLI. Try `thane send general \"hello\"` or `thane chat general`.",
121
+ createdAt,
122
+ reactions: [],
123
+ mentions: []
124
+ }
125
+ ],
126
+ readStates: []
127
+ };
128
+ }
129
+ function normalizeWorkspaceSlug(slug) {
130
+ return slug.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
131
+ }
132
+ function migrateData(rawData) {
133
+ if (rawData.workspaces?.length && rawData.activeWorkspaceId) {
134
+ const createdAt = nowIso();
135
+ const accounts = rawData.accounts?.length
136
+ ? rawData.accounts
137
+ : [
138
+ {
139
+ id: "acct_local",
140
+ email: "you@example.local",
141
+ displayName: "You",
142
+ createdAt
143
+ }
144
+ ];
145
+ const currentAccountId = rawData.currentAccountId ?? accounts[0]?.id;
146
+ const users = rawData.users.map((user) => ({
147
+ ...user,
148
+ accountId: user.accountId ?? (user.handle === "you" ? currentAccountId : undefined),
149
+ email: user.email ?? (user.handle === "you" ? accounts.find((account) => account.id === currentAccountId)?.email : undefined)
150
+ }));
151
+ const workspaceMembers = rawData.workspaceMembers?.length
152
+ ? rawData.workspaceMembers
153
+ : users
154
+ .filter((user) => user.accountId)
155
+ .map((user) => ({
156
+ id: id("mbr"),
157
+ workspaceId: user.workspaceId,
158
+ accountId: user.accountId,
159
+ userId: user.id,
160
+ role: user.handle === "you" ? "owner" : "member",
161
+ joinedAt: createdAt
162
+ }));
163
+ return {
164
+ ...rawData,
165
+ currentAccountId,
166
+ accounts,
167
+ workspaceMembers,
168
+ askThaneIntegrations: rawData.askThaneIntegrations ?? [],
169
+ notificationPreferences: rawData.notificationPreferences ?? [],
170
+ billingPlans: rawData.billingPlans ?? [],
171
+ pendingLogins: rawData.pendingLogins ?? [],
172
+ users,
173
+ channels: rawData.channels.map((channel) => ({
174
+ ...channel,
175
+ kind: channel.kind ?? "channel",
176
+ visibility: channel.visibility ?? "public",
177
+ memberIds: channel.memberIds ??
178
+ rawData.users.filter((user) => user.workspaceId === channel.workspaceId).map((user) => user.id)
179
+ })),
180
+ messages: rawData.messages,
181
+ readStates: rawData.readStates
182
+ };
183
+ }
184
+ const createdAt = nowIso();
185
+ const workspace = {
186
+ id: "wsp_local",
187
+ slug: "local",
188
+ name: "Local Workspace",
189
+ createdAt
190
+ };
191
+ const account = {
192
+ id: "acct_local",
193
+ email: "you@example.local",
194
+ displayName: "You",
195
+ createdAt
196
+ };
197
+ return {
198
+ ...rawData,
199
+ currentAccountId: account.id,
200
+ accounts: [account],
201
+ workspaceMembers: rawData.users.map((user) => ({
202
+ id: id("mbr"),
203
+ workspaceId: user.workspaceId ?? workspace.id,
204
+ accountId: account.id,
205
+ userId: user.id,
206
+ role: user.handle === "you" ? "owner" : "member",
207
+ joinedAt: createdAt
208
+ })),
209
+ askThaneIntegrations: [],
210
+ notificationPreferences: [],
211
+ billingPlans: [],
212
+ pendingLogins: [],
213
+ activeWorkspaceId: workspace.id,
214
+ workspaces: [workspace],
215
+ users: rawData.users.map((user) => {
216
+ const migratedUser = {
217
+ ...user,
218
+ workspaceId: user.workspaceId ?? workspace.id
219
+ };
220
+ if (user.accountId || user.handle === "you") {
221
+ migratedUser.accountId = user.accountId ?? account.id;
222
+ }
223
+ if (user.email || user.handle === "you") {
224
+ migratedUser.email = user.email ?? account.email;
225
+ }
226
+ return migratedUser;
227
+ }),
228
+ channels: rawData.channels.map((channel) => ({
229
+ ...channel,
230
+ workspaceId: channel.workspaceId ?? workspace.id,
231
+ kind: channel.kind ?? "channel",
232
+ visibility: channel.visibility ?? "public",
233
+ memberIds: channel.memberIds ?? rawData.users.map((user) => user.id)
234
+ })),
235
+ messages: rawData.messages.map((message) => ({ ...message, workspaceId: message.workspaceId ?? workspace.id })),
236
+ readStates: rawData.readStates.map((state) => ({ ...state, workspaceId: state.workspaceId ?? workspace.id }))
237
+ };
238
+ }
239
+ async function loadData() {
240
+ const path = resolveStorePath();
241
+ try {
242
+ const raw = await readFile(path, "utf8");
243
+ return migrateData(JSON.parse(raw));
244
+ }
245
+ catch (error) {
246
+ if (error.code !== "ENOENT") {
247
+ throw error;
248
+ }
249
+ const data = defaultData();
250
+ await saveData(data);
251
+ return data;
252
+ }
253
+ }
254
+ async function saveData(data) {
255
+ const path = resolveStorePath();
256
+ await mkdir(dirname(path), { recursive: true });
257
+ await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
258
+ }
259
+ function normalizeChannelName(name) {
260
+ return name.trim().replace(/^#/, "").toLowerCase();
261
+ }
262
+ function normalizeHandle(handle) {
263
+ return handle.trim().replace(/^@/, "").toLowerCase();
264
+ }
265
+ function extractMentions(text) {
266
+ const mentions = new Set();
267
+ for (const match of text.matchAll(/@([a-zA-Z0-9._-]+)/g)) {
268
+ const handle = match[1];
269
+ if (handle) {
270
+ mentions.add(handle.toLowerCase());
271
+ }
272
+ }
273
+ return [...mentions];
274
+ }
275
+ function parsePingLocationRequest(text) {
276
+ const normalized = text.toLowerCase();
277
+ const mentionsPing = /\b(ping|notify|message|remind|follow up|follow-up|send)\b/.test(normalized);
278
+ if (!mentionsPing) {
279
+ return undefined;
280
+ }
281
+ if (/\b(both|everywhere|slack and (thane|cli)|thane and slack)\b/.test(normalized)) {
282
+ return "both";
283
+ }
284
+ if (/\b(slack)\b/.test(normalized)) {
285
+ return "slack";
286
+ }
287
+ if (/\b(here|cli|thane cli|terminal|this app)\b/.test(normalized)) {
288
+ return "thane_cli";
289
+ }
290
+ if (/\b(origin|where it started|same place|source)\b/.test(normalized)) {
291
+ return "origin";
292
+ }
293
+ return undefined;
294
+ }
295
+ function describePingLocation(location) {
296
+ switch (location) {
297
+ case "both":
298
+ return "Slack and Thane CLI";
299
+ case "slack":
300
+ return "Slack";
301
+ case "thane_cli":
302
+ return "Thane CLI";
303
+ case "origin":
304
+ return "the place where the task or reminder started";
305
+ }
306
+ }
307
+ function base64UrlJson(value) {
308
+ return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
309
+ }
310
+ function signBillingPayload(secret, payloadEncoded) {
311
+ return createHmac("sha256", secret).update(payloadEncoded).digest("base64url");
312
+ }
313
+ function deterministicId(prefix, ...parts) {
314
+ const normalized = parts.join("_").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 120);
315
+ return `${prefix}_${normalized || "import"}`;
316
+ }
317
+ function slackTsToIso(ts, fallback = nowIso()) {
318
+ if (!ts) {
319
+ return fallback;
320
+ }
321
+ const seconds = Number.parseFloat(ts);
322
+ if (!Number.isFinite(seconds)) {
323
+ return fallback;
324
+ }
325
+ return new Date(Math.floor(seconds * 1000)).toISOString();
326
+ }
327
+ function normalizeSlackHandle(value, fallback) {
328
+ const normalized = (value ?? fallback).trim().replace(/^@/, "").toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
329
+ return normalized || fallback.toLowerCase();
330
+ }
331
+ function decodeSlackEntities(text) {
332
+ return text.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
333
+ }
334
+ export class ThaneStore {
335
+ data;
336
+ constructor(data) {
337
+ this.data = data;
338
+ }
339
+ static async open() {
340
+ return new ThaneStore(await loadData());
341
+ }
342
+ get activeWorkspace() {
343
+ const workspace = this.data.workspaces.find((candidate) => candidate.id === this.data.activeWorkspaceId);
344
+ if (!workspace) {
345
+ throw new Error("Active workspace is missing from the local Thane store.");
346
+ }
347
+ return workspace;
348
+ }
349
+ get currentAccount() {
350
+ return this.data.accounts.find((account) => account.id === this.data.currentAccountId);
351
+ }
352
+ get currentUser() {
353
+ const user = this.data.users.find((candidate) => candidate.id === this.data.currentUserId && candidate.workspaceId === this.activeWorkspace.id) ?? this.data.users.find((candidate) => candidate.workspaceId === this.activeWorkspace.id && candidate.handle === "you");
354
+ if (!user) {
355
+ throw new Error("Current user is missing from the local Thane store.");
356
+ }
357
+ return user;
358
+ }
359
+ currentMember() {
360
+ return this.data.workspaceMembers.find((member) => member.workspaceId === this.activeWorkspace.id && member.userId === this.currentUser.id);
361
+ }
362
+ requireWorkspaceAdmin() {
363
+ const role = this.currentMember()?.role;
364
+ if (role !== "owner" && role !== "admin") {
365
+ throw new Error("Only workspace owners and admins can add people to this workspace.");
366
+ }
367
+ }
368
+ askThaneStatus() {
369
+ return this.data.askThaneIntegrations.find((integration) => integration.workspaceId === this.activeWorkspace.id);
370
+ }
371
+ billingPlan() {
372
+ return (this.data.billingPlans.find((plan) => plan.workspaceId === this.activeWorkspace.id) ?? {
373
+ workspaceId: this.activeWorkspace.id,
374
+ planTier: "free",
375
+ status: "active",
376
+ updatedAt: nowIso()
377
+ });
378
+ }
379
+ billingSummary() {
380
+ return {
381
+ plan: this.billingPlan(),
382
+ limits: THANE_CLI_FREE_LIMITS,
383
+ usage: {
384
+ members: this.listMembers().length,
385
+ privateChannels: this.data.channels.filter((channel) => channel.workspaceId === this.activeWorkspace.id && channel.kind === "channel" && channel.visibility === "private").length
386
+ }
387
+ };
388
+ }
389
+ async setBillingPlan(planTier, status = "active") {
390
+ let plan = this.data.billingPlans.find((candidate) => candidate.workspaceId === this.activeWorkspace.id);
391
+ if (!plan) {
392
+ plan = {
393
+ workspaceId: this.activeWorkspace.id,
394
+ planTier,
395
+ status,
396
+ updatedAt: nowIso()
397
+ };
398
+ this.data.billingPlans.push(plan);
399
+ }
400
+ else {
401
+ plan.planTier = planTier;
402
+ plan.status = status;
403
+ plan.updatedAt = nowIso();
404
+ }
405
+ await saveData(this.data);
406
+ return plan;
407
+ }
408
+ createBillingCheckoutUrl(input) {
409
+ const paymentsBaseUrl = input.paymentsBaseUrl?.trim();
410
+ const signingSecret = input.signingSecret?.trim();
411
+ if (!paymentsBaseUrl) {
412
+ throw new Error("Set THANE_PAYMENTS_BASE_URL to create a Stripe checkout link.");
413
+ }
414
+ if (!signingSecret) {
415
+ throw new Error("Set THANE_BILLING_LINK_SIGNING_SECRET to create a Stripe checkout link.");
416
+ }
417
+ const payloadEncoded = base64UrlJson({
418
+ organizationId: this.activeWorkspace.id,
419
+ workspaceId: this.activeWorkspace.id,
420
+ planTier: "cli_team",
421
+ iat: Math.floor(Date.now() / 1000),
422
+ exp: Math.floor(Date.now() / 1000) + 15 * 60
423
+ });
424
+ const token = `${payloadEncoded}.${signBillingPayload(signingSecret, payloadEncoded)}`;
425
+ const url = new URL("/subscribe", paymentsBaseUrl);
426
+ url.searchParams.set("billing_token", token);
427
+ url.searchParams.set("autostart", "1");
428
+ if (input.email?.trim()) {
429
+ url.searchParams.set("email", input.email.trim());
430
+ }
431
+ return url.toString();
432
+ }
433
+ isCliTeam() {
434
+ const plan = this.billingPlan();
435
+ return plan.planTier === "cli_team" && plan.status === "active";
436
+ }
437
+ enforceMemberLimitForFree() {
438
+ if (this.isCliTeam()) {
439
+ return;
440
+ }
441
+ if (this.listMembers().length >= THANE_CLI_FREE_LIMITS.members) {
442
+ throw new Error(`Free workspaces support up to ${THANE_CLI_FREE_LIMITS.members} members. Upgrade with: thane billing checkout`);
443
+ }
444
+ }
445
+ enforcePrivateChannelLimitForFree() {
446
+ if (this.isCliTeam()) {
447
+ return;
448
+ }
449
+ const privateChannels = this.data.channels.filter((channel) => channel.workspaceId === this.activeWorkspace.id && channel.kind === "channel" && channel.visibility === "private");
450
+ if (privateChannels.length >= THANE_CLI_FREE_LIMITS.privateChannels) {
451
+ throw new Error(`Free workspaces support up to ${THANE_CLI_FREE_LIMITS.privateChannels} private channels. Upgrade with: thane billing checkout`);
452
+ }
453
+ }
454
+ previewSlackImport(exportData) {
455
+ return previewSlackExport(exportData, THANE_CLI_FREE_LIMITS);
456
+ }
457
+ async importSlackExport(exportData) {
458
+ this.requireWorkspaceAdmin();
459
+ const preview = this.previewSlackImport(exportData);
460
+ const privateChannelNames = new Set(exportData.conversations
461
+ .filter((item) => item.source === "groups")
462
+ .map((item) => normalizeChannelName(item.conversation.name ?? item.conversation.id)));
463
+ const existingPrivateChannelNames = new Set(this.data.channels
464
+ .filter((channel) => channel.workspaceId === this.activeWorkspace.id && channel.kind === "channel" && channel.visibility === "private")
465
+ .map((channel) => channel.name));
466
+ const privateChannelsAfterImport = new Set([...existingPrivateChannelNames, ...privateChannelNames]).size;
467
+ if (!this.isCliTeam() && (preview.users > THANE_CLI_FREE_LIMITS.members || privateChannelsAfterImport > THANE_CLI_FREE_LIMITS.privateChannels)) {
468
+ throw new Error(`This Slack export has ${preview.users} users and ${privateChannelsAfterImport} private channels after import. Free workspaces support ${THANE_CLI_FREE_LIMITS.members} members and ${THANE_CLI_FREE_LIMITS.privateChannels} private channels. Upgrade with: thane billing checkout`);
469
+ }
470
+ const workspaceId = this.activeWorkspace.id;
471
+ const userBySlackId = new Map();
472
+ const userByHandle = new Map(this.data.users.filter((user) => user.workspaceId === workspaceId).map((user) => [user.handle, user]));
473
+ let importedUsers = 0;
474
+ for (const slackUser of exportData.users) {
475
+ if (!slackUser.id) {
476
+ continue;
477
+ }
478
+ const email = slackUser.profile?.email?.trim().toLowerCase();
479
+ const displayName = slackUser.profile?.real_name || slackUser.real_name || slackUser.profile?.display_name || slackUser.name || slackUser.id;
480
+ const handle = normalizeSlackHandle(slackUser.name, slackUser.id.toLowerCase());
481
+ const currentAccountMatches = email && this.currentAccount?.email === email;
482
+ let user = (currentAccountMatches ? this.currentUser : undefined) ??
483
+ (email ? this.data.users.find((candidate) => candidate.workspaceId === workspaceId && candidate.email === email) : undefined) ??
484
+ userByHandle.get(handle);
485
+ if (!user) {
486
+ user = {
487
+ id: deterministicId("usr_slack", workspaceId, slackUser.id),
488
+ workspaceId,
489
+ handle,
490
+ displayName,
491
+ ...(email ? { email } : {})
492
+ };
493
+ this.data.users.push(user);
494
+ userByHandle.set(handle, user);
495
+ importedUsers += 1;
496
+ }
497
+ else {
498
+ user.displayName = user.displayName || displayName;
499
+ if (email && !user.email) {
500
+ user.email = email;
501
+ }
502
+ }
503
+ if (email) {
504
+ let account = this.data.accounts.find((candidate) => candidate.email === email);
505
+ if (!account) {
506
+ account = {
507
+ id: deterministicId("acct_slack", email),
508
+ email,
509
+ displayName,
510
+ createdAt: nowIso()
511
+ };
512
+ this.data.accounts.push(account);
513
+ }
514
+ user.accountId = user.accountId ?? account.id;
515
+ if (!this.data.workspaceMembers.some((member) => member.workspaceId === workspaceId && member.userId === user.id)) {
516
+ this.data.workspaceMembers.push({
517
+ id: deterministicId("mbr_slack", workspaceId, user.id),
518
+ workspaceId,
519
+ accountId: account.id,
520
+ userId: user.id,
521
+ role: user.id === this.currentUser.id ? this.currentMember()?.role ?? "owner" : "member",
522
+ joinedAt: nowIso()
523
+ });
524
+ }
525
+ }
526
+ userBySlackId.set(slackUser.id, user);
527
+ }
528
+ const botUserByKey = new Map();
529
+ const ensureBotUser = (message) => {
530
+ const rawName = message.username || message.bot_id || "slackbot";
531
+ const handle = normalizeSlackHandle(rawName, "slackbot");
532
+ const key = message.bot_id || handle;
533
+ const existing = botUserByKey.get(key) ?? this.data.users.find((user) => user.workspaceId === workspaceId && user.handle === handle);
534
+ if (existing) {
535
+ botUserByKey.set(key, existing);
536
+ return existing;
537
+ }
538
+ const user = {
539
+ id: deterministicId("usr_slackbot", workspaceId, key),
540
+ workspaceId,
541
+ handle,
542
+ displayName: rawName
543
+ };
544
+ this.data.users.push(user);
545
+ botUserByKey.set(key, user);
546
+ return user;
547
+ };
548
+ const conversationByFolder = new Map();
549
+ for (const item of exportData.conversations) {
550
+ const conversation = item.conversation;
551
+ const keys = [conversation.id, conversation.name].filter((value) => Boolean(value));
552
+ for (const key of keys) {
553
+ conversationByFolder.set(key, item);
554
+ }
555
+ }
556
+ const channelBySlackId = new Map();
557
+ let importedChannels = 0;
558
+ for (const item of exportData.conversations) {
559
+ const conversation = item.conversation;
560
+ if (!conversation.id) {
561
+ continue;
562
+ }
563
+ const isDm = item.source === "dms" || item.source === "mpims";
564
+ const memberIds = (conversation.members ?? []).map((memberId) => userBySlackId.get(memberId)?.id).filter((value) => Boolean(value));
565
+ if (!memberIds.includes(this.currentUser.id)) {
566
+ memberIds.push(this.currentUser.id);
567
+ }
568
+ const sourceName = conversation.name ?? conversation.id;
569
+ const dmName = isDm && conversation.members?.length
570
+ ? conversation.members
571
+ .map((memberId) => userBySlackId.get(memberId)?.handle)
572
+ .filter((handle) => Boolean(handle && handle !== this.currentUser.handle))
573
+ .join("-") || sourceName
574
+ : sourceName;
575
+ const name = normalizeChannelName(isDm ? dmName : sourceName);
576
+ const visibility = item.source === "channels" ? "public" : "private";
577
+ let channel = this.data.channels.find((candidate) => candidate.workspaceId === workspaceId &&
578
+ candidate.kind === (isDm ? "dm" : "channel") &&
579
+ (candidate.id === deterministicId(isDm ? "dm_slack" : "chn_slack", workspaceId, conversation.id) || candidate.name === name));
580
+ if (!channel) {
581
+ channel = {
582
+ id: deterministicId(isDm ? "dm_slack" : "chn_slack", workspaceId, conversation.id),
583
+ workspaceId,
584
+ name,
585
+ kind: isDm ? "dm" : "channel",
586
+ visibility,
587
+ memberIds,
588
+ createdAt: conversation.created ? slackTsToIso(String(conversation.created)) : nowIso()
589
+ };
590
+ const topic = conversation.topic?.value || conversation.purpose?.value;
591
+ if (topic) {
592
+ channel.topic = topic;
593
+ }
594
+ this.data.channels.push(channel);
595
+ importedChannels += 1;
596
+ }
597
+ else {
598
+ for (const memberId of memberIds) {
599
+ if (!channel.memberIds.includes(memberId)) {
600
+ channel.memberIds.push(memberId);
601
+ }
602
+ }
603
+ }
604
+ channelBySlackId.set(conversation.id, channel);
605
+ }
606
+ const messages = exportData.messageFiles
607
+ .flatMap((file) => file.messages.map((message) => ({
608
+ file,
609
+ message,
610
+ order: Number.parseFloat(message.ts ?? "0")
611
+ })))
612
+ .filter((item) => item.message.ts)
613
+ .sort((a, b) => a.order - b.order);
614
+ let importedMessages = 0;
615
+ let skippedDuplicateMessages = 0;
616
+ for (const item of messages) {
617
+ const sourceConversation = conversationByFolder.get(item.file.folder);
618
+ const channel = sourceConversation ? channelBySlackId.get(sourceConversation.conversation.id) : undefined;
619
+ if (!channel || !item.message.ts) {
620
+ continue;
621
+ }
622
+ const messageId = deterministicId("msg_slack", workspaceId, channel.id, item.message.ts);
623
+ if (this.data.messages.some((message) => message.id === messageId)) {
624
+ skippedDuplicateMessages += 1;
625
+ continue;
626
+ }
627
+ const author = item.message.user ? userBySlackId.get(item.message.user) : undefined;
628
+ const authorUser = author ?? ensureBotUser(item.message);
629
+ const text = this.renderSlackMessageText(item.message, userBySlackId, channelBySlackId);
630
+ const threadRootId = item.message.thread_ts && item.message.thread_ts !== item.message.ts
631
+ ? deterministicId("msg_slack", workspaceId, channel.id, item.message.thread_ts)
632
+ : undefined;
633
+ const importedMessage = {
634
+ id: messageId,
635
+ workspaceId,
636
+ channelId: channel.id,
637
+ authorId: authorUser.id,
638
+ text,
639
+ createdAt: slackTsToIso(item.message.ts),
640
+ reactions: (item.message.reactions ?? []).map((reaction) => ({
641
+ emoji: reaction.name ?? "reaction",
642
+ by: (reaction.users ?? []).map((userId) => userBySlackId.get(userId)?.handle ?? userId).join(", ") || "unknown",
643
+ createdAt: slackTsToIso(item.message.ts)
644
+ })),
645
+ mentions: extractMentions(text),
646
+ ...(threadRootId ? { threadRootId } : {})
647
+ };
648
+ this.data.messages.push(importedMessage);
649
+ importedMessages += 1;
650
+ }
651
+ await saveData(this.data);
652
+ return {
653
+ ...preview,
654
+ importedUsers,
655
+ importedChannels,
656
+ importedMessages,
657
+ skippedDuplicateMessages
658
+ };
659
+ }
660
+ renderSlackMessageText(message, userBySlackId, channelBySlackId) {
661
+ let text = decodeSlackEntities(message.text ?? "");
662
+ text = text.replace(/<@([A-Z0-9]+)>/g, (_match, userId) => {
663
+ const user = userBySlackId.get(userId);
664
+ return user ? `@${user.handle}` : `@${userId}`;
665
+ });
666
+ text = text.replace(/<#([A-Z0-9]+)(?:\|([^>]+))?>/g, (_match, channelId, label) => {
667
+ const channel = channelBySlackId.get(channelId);
668
+ return `#${channel?.name ?? label ?? channelId}`;
669
+ });
670
+ text = text.replace(/<((?:https?:|mailto:)[^>|]+)\|([^>]+)>/g, (_match, url, label) => `${label} (${url})`);
671
+ text = text.replace(/<((?:https?:|mailto:)[^>]+)>/g, (_match, url) => url);
672
+ if (message.files?.length) {
673
+ const fileLines = message.files.map((file) => {
674
+ const label = file.title || file.name || file.id || "Slack file";
675
+ const url = file.permalink || file.url_private;
676
+ return url ? `[file: ${label}] ${url}` : `[file: ${label}]`;
677
+ });
678
+ text = [text, ...fileLines].filter(Boolean).join("\n");
679
+ }
680
+ return text || `[Slack ${message.subtype ?? "message"}]`;
681
+ }
682
+ async enableAskThane() {
683
+ const account = this.currentAccount;
684
+ if (!account) {
685
+ throw new Error("Sign in before enabling Ask Thane: thane login <email>");
686
+ }
687
+ let bot = this.data.users.find((user) => user.workspaceId === this.activeWorkspace.id && user.handle === "thane");
688
+ if (!bot) {
689
+ bot = {
690
+ id: id("usr"),
691
+ workspaceId: this.activeWorkspace.id,
692
+ handle: "thane",
693
+ displayName: "Ask Thane"
694
+ };
695
+ this.data.users.push(bot);
696
+ }
697
+ const existing = this.askThaneStatus();
698
+ const integration = existing ??
699
+ {
700
+ workspaceId: this.activeWorkspace.id,
701
+ enabled: true,
702
+ botUserId: bot.id,
703
+ linkedAccountEmail: account.email,
704
+ provider: "thane_cli",
705
+ externalUserId: account.email,
706
+ connectedAt: nowIso()
707
+ };
708
+ integration.enabled = true;
709
+ integration.botUserId = bot.id;
710
+ integration.linkedAccountEmail = account.email;
711
+ integration.externalUserId = account.email;
712
+ if (!existing) {
713
+ this.data.askThaneIntegrations.push(integration);
714
+ }
715
+ await saveData(this.data);
716
+ return integration;
717
+ }
718
+ async disableAskThane() {
719
+ const existing = this.askThaneStatus();
720
+ if (existing) {
721
+ existing.enabled = false;
722
+ existing.lastEventAt = nowIso();
723
+ await saveData(this.data);
724
+ }
725
+ }
726
+ notificationPreference() {
727
+ const account = this.currentAccount;
728
+ if (!account) {
729
+ throw new Error("Sign in before reading notification settings: thane login <email>");
730
+ }
731
+ return (this.data.notificationPreferences.find((preference) => preference.accountId === account.id) ?? {
732
+ accountId: account.id,
733
+ preferredPingLocation: "origin",
734
+ updatedAt: nowIso()
735
+ });
736
+ }
737
+ async setPingLocation(location, updatedBy = "user") {
738
+ const account = this.currentAccount;
739
+ if (!account) {
740
+ throw new Error("Sign in before changing notification settings: thane login <email>");
741
+ }
742
+ let preference = this.data.notificationPreferences.find((candidate) => candidate.accountId === account.id);
743
+ if (!preference) {
744
+ preference = {
745
+ accountId: account.id,
746
+ preferredPingLocation: location,
747
+ updatedAt: nowIso(),
748
+ updatedBy
749
+ };
750
+ this.data.notificationPreferences.push(preference);
751
+ }
752
+ else {
753
+ preference.preferredPingLocation = location;
754
+ preference.updatedAt = nowIso();
755
+ preference.updatedBy = updatedBy;
756
+ }
757
+ await saveData(this.data);
758
+ return preference;
759
+ }
760
+ async signup(email, displayName) {
761
+ const normalized = normalizeEmail(email);
762
+ if (!normalized.includes("@")) {
763
+ throw new Error("Usage: thane signup <email>");
764
+ }
765
+ const existing = this.data.accounts.find((account) => account.email === normalized);
766
+ const account = existing ??
767
+ {
768
+ id: id("acct"),
769
+ email: normalized,
770
+ displayName: displayName?.trim() || displayNameFromEmail(normalized),
771
+ createdAt: nowIso()
772
+ };
773
+ if (!existing) {
774
+ this.data.accounts.push(account);
775
+ }
776
+ const code = this.createPendingLogin(normalized);
777
+ await saveData(this.data);
778
+ return { account, code };
779
+ }
780
+ async login(email) {
781
+ const normalized = normalizeEmail(email);
782
+ const account = this.data.accounts.find((candidate) => candidate.email === normalized);
783
+ const code = this.createPendingLogin(normalized);
784
+ await saveData(this.data);
785
+ return account ? { account, code } : { code };
786
+ }
787
+ async verify(email, code) {
788
+ const normalized = normalizeEmail(email);
789
+ const pending = this.data.pendingLogins.find((candidate) => candidate.email === normalized && candidate.code === code.trim());
790
+ if (!pending) {
791
+ throw new Error("Invalid verification code.");
792
+ }
793
+ if (new Date(pending.expiresAt).getTime() < Date.now()) {
794
+ throw new Error("Verification code expired.");
795
+ }
796
+ const account = this.data.accounts.find((candidate) => candidate.email === normalized) ??
797
+ {
798
+ id: id("acct"),
799
+ email: normalized,
800
+ displayName: displayNameFromEmail(normalized),
801
+ createdAt: nowIso()
802
+ };
803
+ if (!this.data.accounts.some((candidate) => candidate.id === account.id)) {
804
+ this.data.accounts.push(account);
805
+ }
806
+ this.data.currentAccountId = account.id;
807
+ this.ensureAccountMembership(account, this.activeWorkspace.id, this.data.workspaceMembers.length === 0 ? "owner" : "member");
808
+ this.data.pendingLogins = this.data.pendingLogins.filter((candidate) => candidate !== pending);
809
+ await saveData(this.data);
810
+ return account;
811
+ }
812
+ async logout() {
813
+ delete this.data.currentAccountId;
814
+ await saveData(this.data);
815
+ }
816
+ createPendingLogin(email) {
817
+ const code = makeLoginCode();
818
+ this.data.pendingLogins = this.data.pendingLogins.filter((pending) => pending.email !== email);
819
+ this.data.pendingLogins.push({
820
+ email,
821
+ code,
822
+ createdAt: nowIso(),
823
+ expiresAt: new Date(Date.now() + 10 * 60_000).toISOString()
824
+ });
825
+ return code;
826
+ }
827
+ ensureAccountMembership(account, workspaceId, role) {
828
+ let user = this.data.users.find((candidate) => candidate.workspaceId === workspaceId && candidate.accountId === account.id);
829
+ if (!user) {
830
+ user = {
831
+ id: id("usr"),
832
+ workspaceId,
833
+ accountId: account.id,
834
+ handle: handleFromEmail(account.email),
835
+ displayName: account.displayName,
836
+ email: account.email
837
+ };
838
+ this.data.users.push(user);
839
+ }
840
+ let member = this.data.workspaceMembers.find((candidate) => candidate.workspaceId === workspaceId && candidate.accountId === account.id);
841
+ if (!member) {
842
+ member = {
843
+ id: id("mbr"),
844
+ workspaceId,
845
+ accountId: account.id,
846
+ userId: user.id,
847
+ role,
848
+ joinedAt: nowIso()
849
+ };
850
+ this.data.workspaceMembers.push(member);
851
+ }
852
+ if (workspaceId === this.activeWorkspace.id && account.id === this.data.currentAccountId) {
853
+ this.data.currentUserId = user.id;
854
+ }
855
+ return member;
856
+ }
857
+ listWorkspaces() {
858
+ return [...this.data.workspaces].sort((a, b) => a.slug.localeCompare(b.slug));
859
+ }
860
+ findWorkspace(slugOrId) {
861
+ const normalized = normalizeWorkspaceSlug(slugOrId);
862
+ return this.data.workspaces.find((workspace) => workspace.id === slugOrId || workspace.slug === normalized);
863
+ }
864
+ async createWorkspace(slug, name) {
865
+ const normalized = normalizeWorkspaceSlug(slug);
866
+ if (!normalized) {
867
+ throw new Error("Workspace slug must contain at least one letter or number.");
868
+ }
869
+ const existing = this.findWorkspace(normalized);
870
+ if (existing) {
871
+ return existing;
872
+ }
873
+ const workspace = {
874
+ id: id("wsp"),
875
+ slug: normalized,
876
+ name: name?.trim() || normalized,
877
+ createdAt: nowIso()
878
+ };
879
+ this.data.workspaces.push(workspace);
880
+ if (this.currentAccount) {
881
+ this.ensureAccountMembership(this.currentAccount, workspace.id, "owner");
882
+ }
883
+ else {
884
+ const user = {
885
+ id: id("usr"),
886
+ workspaceId: workspace.id,
887
+ handle: "you",
888
+ displayName: "You"
889
+ };
890
+ this.data.users.push(user);
891
+ this.data.workspaceMembers.push({
892
+ id: id("mbr"),
893
+ workspaceId: workspace.id,
894
+ accountId: "acct_local",
895
+ userId: user.id,
896
+ role: "owner",
897
+ joinedAt: nowIso()
898
+ });
899
+ }
900
+ await saveData(this.data);
901
+ return workspace;
902
+ }
903
+ async useWorkspace(slugOrId) {
904
+ const workspace = this.findWorkspace(slugOrId);
905
+ if (!workspace) {
906
+ throw new Error(`Workspace ${slugOrId} was not found. Create it with: thane workspace create ${slugOrId}`);
907
+ }
908
+ this.data.activeWorkspaceId = workspace.id;
909
+ const existingUser = (this.currentAccount &&
910
+ this.data.users.find((user) => user.workspaceId === workspace.id && user.accountId === this.currentAccount?.id)) ??
911
+ this.data.users.find((user) => user.workspaceId === workspace.id && user.handle === "you");
912
+ if (existingUser) {
913
+ this.data.currentUserId = existingUser.id;
914
+ }
915
+ else {
916
+ const user = {
917
+ id: id("usr"),
918
+ workspaceId: workspace.id,
919
+ ...(this.currentAccount ? { accountId: this.currentAccount.id, email: this.currentAccount.email } : {}),
920
+ handle: "you",
921
+ displayName: this.currentAccount?.displayName ?? "You"
922
+ };
923
+ this.data.users.push(user);
924
+ this.data.currentUserId = user.id;
925
+ }
926
+ await saveData(this.data);
927
+ return workspace;
928
+ }
929
+ listMembers() {
930
+ return this.data.workspaceMembers
931
+ .filter((member) => member.workspaceId === this.activeWorkspace.id)
932
+ .map((member) => {
933
+ const renderedMember = {
934
+ role: member.role,
935
+ joinedAt: member.joinedAt
936
+ };
937
+ const user = this.data.users.find((candidate) => candidate.id === member.userId);
938
+ if (user) {
939
+ renderedMember.user = user;
940
+ }
941
+ const account = this.data.accounts.find((candidate) => candidate.id === member.accountId);
942
+ if (account) {
943
+ renderedMember.account = account;
944
+ }
945
+ return renderedMember;
946
+ })
947
+ .filter((member) => Boolean(member.user))
948
+ .sort((a, b) => a.user.handle.localeCompare(b.user.handle));
949
+ }
950
+ async invite(email, role = "member", handle) {
951
+ this.requireWorkspaceAdmin();
952
+ this.enforceMemberLimitForFree();
953
+ if (role === "owner") {
954
+ throw new Error("Use admin/member for invites in the MVP; ownership transfer is not implemented.");
955
+ }
956
+ const normalized = normalizeEmail(email);
957
+ if (!normalized.includes("@")) {
958
+ throw new Error("Usage: thane invite <email>");
959
+ }
960
+ let account = this.data.accounts.find((candidate) => candidate.email === normalized);
961
+ if (!account) {
962
+ account = {
963
+ id: id("acct"),
964
+ email: normalized,
965
+ displayName: displayNameFromEmail(normalized),
966
+ createdAt: nowIso()
967
+ };
968
+ this.data.accounts.push(account);
969
+ }
970
+ const member = this.ensureAccountMembership(account, this.activeWorkspace.id, role);
971
+ const user = this.data.users.find((candidate) => candidate.id === member.userId);
972
+ if (user && handle) {
973
+ user.handle = normalizeHandle(handle);
974
+ }
975
+ await saveData(this.data);
976
+ return member;
977
+ }
978
+ async setMemberRole(handleOrEmail, role) {
979
+ this.requireWorkspaceAdmin();
980
+ const target = this.data.workspaceMembers.find((member) => {
981
+ if (member.workspaceId !== this.activeWorkspace.id) {
982
+ return false;
983
+ }
984
+ const user = this.data.users.find((candidate) => candidate.id === member.userId);
985
+ const account = this.data.accounts.find((candidate) => candidate.id === member.accountId);
986
+ return user?.handle === normalizeHandle(handleOrEmail) || account?.email === normalizeEmail(handleOrEmail);
987
+ });
988
+ if (!target) {
989
+ throw new Error(`Member ${handleOrEmail} was not found.`);
990
+ }
991
+ target.role = role;
992
+ await saveData(this.data);
993
+ return target;
994
+ }
995
+ listChannels() {
996
+ return this.data.channels
997
+ .filter((channel) => channel.workspaceId === this.activeWorkspace.id && channel.kind === "channel")
998
+ .filter((channel) => channel.visibility === "public" || channel.memberIds.includes(this.currentUser.id))
999
+ .sort((a, b) => a.name.localeCompare(b.name));
1000
+ }
1001
+ listDms() {
1002
+ return this.data.channels
1003
+ .filter((channel) => channel.workspaceId === this.activeWorkspace.id && channel.kind === "dm")
1004
+ .sort((a, b) => a.name.localeCompare(b.name));
1005
+ }
1006
+ listUsers() {
1007
+ return this.data.users
1008
+ .filter((user) => user.workspaceId === this.activeWorkspace.id)
1009
+ .sort((a, b) => a.handle.localeCompare(b.handle));
1010
+ }
1011
+ findUser(handleOrId) {
1012
+ const normalized = normalizeHandle(handleOrId);
1013
+ return this.data.users.find((user) => user.workspaceId === this.activeWorkspace.id && (user.id === handleOrId || user.handle === normalized));
1014
+ }
1015
+ async addUser(handle, displayName) {
1016
+ const normalized = normalizeHandle(handle);
1017
+ if (!normalized) {
1018
+ throw new Error("User handle must contain at least one character.");
1019
+ }
1020
+ const existing = this.findUser(normalized);
1021
+ if (existing) {
1022
+ return existing;
1023
+ }
1024
+ const user = {
1025
+ id: id("usr"),
1026
+ workspaceId: this.activeWorkspace.id,
1027
+ handle: normalized,
1028
+ displayName: displayName?.trim() || normalized
1029
+ };
1030
+ this.data.users.push(user);
1031
+ await saveData(this.data);
1032
+ return user;
1033
+ }
1034
+ findChannel(nameOrId) {
1035
+ const normalized = normalizeChannelName(nameOrId);
1036
+ return this.data.channels.find((channel) => channel.workspaceId === this.activeWorkspace.id &&
1037
+ (channel.id === nameOrId || channel.name === normalized) &&
1038
+ (channel.visibility === "public" || channel.memberIds.includes(this.currentUser.id)));
1039
+ }
1040
+ canReadChannel(channel, userId = this.currentUser.id) {
1041
+ return channel.workspaceId === this.activeWorkspace.id && (channel.visibility === "public" || channel.memberIds.includes(userId));
1042
+ }
1043
+ isJoinedChannel(channel, userId = this.currentUser.id) {
1044
+ return channel.memberIds.includes(userId);
1045
+ }
1046
+ async createChannel(name, topic, visibility = "public") {
1047
+ const normalized = normalizeChannelName(name);
1048
+ const existing = this.findChannel(normalized);
1049
+ if (existing) {
1050
+ return existing;
1051
+ }
1052
+ if (visibility === "private") {
1053
+ this.enforcePrivateChannelLimitForFree();
1054
+ }
1055
+ const channel = {
1056
+ id: id("chn"),
1057
+ workspaceId: this.activeWorkspace.id,
1058
+ name: normalized,
1059
+ kind: "channel",
1060
+ visibility,
1061
+ memberIds: [this.currentUser.id],
1062
+ createdAt: nowIso()
1063
+ };
1064
+ if (topic) {
1065
+ channel.topic = topic;
1066
+ }
1067
+ this.data.channels.push(channel);
1068
+ await saveData(this.data);
1069
+ return channel;
1070
+ }
1071
+ async inviteToChannel(channelName, handleOrEmail) {
1072
+ const channel = this.requireChannel(channelName);
1073
+ if (channel.visibility === "private" && !channel.memberIds.includes(this.currentUser.id)) {
1074
+ throw new Error("Only members of a private channel can invite others to it.");
1075
+ }
1076
+ const user = this.findUser(handleOrEmail) ??
1077
+ this.data.users.find((candidate) => candidate.workspaceId === this.activeWorkspace.id && candidate.email === normalizeEmail(handleOrEmail));
1078
+ if (!user) {
1079
+ throw new Error(`User ${handleOrEmail} was not found in this workspace. Add them with: thane invite ${handleOrEmail}`);
1080
+ }
1081
+ if (!channel.memberIds.includes(user.id)) {
1082
+ channel.memberIds.push(user.id);
1083
+ }
1084
+ await saveData(this.data);
1085
+ return channel;
1086
+ }
1087
+ async joinChannel(channelName) {
1088
+ const channel = this.requireChannel(channelName);
1089
+ if (!this.canReadChannel(channel)) {
1090
+ throw new Error(`Channel ${channelName} was not found.`);
1091
+ }
1092
+ if (!channel.memberIds.includes(this.currentUser.id)) {
1093
+ channel.memberIds.push(this.currentUser.id);
1094
+ }
1095
+ await saveData(this.data);
1096
+ return channel;
1097
+ }
1098
+ async leaveChannel(channelName) {
1099
+ const channel = this.requireChannel(channelName);
1100
+ channel.memberIds = channel.memberIds.filter((memberId) => memberId !== this.currentUser.id);
1101
+ await saveData(this.data);
1102
+ return channel;
1103
+ }
1104
+ channelMembers(channelName) {
1105
+ const channel = this.requireChannel(channelName);
1106
+ return this.data.users
1107
+ .filter((user) => user.workspaceId === this.activeWorkspace.id && channel.memberIds.includes(user.id))
1108
+ .sort((a, b) => a.handle.localeCompare(b.handle));
1109
+ }
1110
+ async findOrCreateDm(handle) {
1111
+ const otherUser = this.findUser(handle) ?? (await this.addUser(handle));
1112
+ if (otherUser.id === this.currentUser.id) {
1113
+ throw new Error("You cannot open a DM with yourself.");
1114
+ }
1115
+ const memberIds = [this.currentUser.id, otherUser.id].sort();
1116
+ const existing = this.data.channels.find((channel) => channel.workspaceId === this.activeWorkspace.id &&
1117
+ channel.kind === "dm" &&
1118
+ channel.memberIds.length === memberIds.length &&
1119
+ memberIds.every((memberId) => channel.memberIds.includes(memberId)));
1120
+ if (existing) {
1121
+ return existing;
1122
+ }
1123
+ const channel = {
1124
+ id: id("dm"),
1125
+ workspaceId: this.activeWorkspace.id,
1126
+ name: otherUser.handle,
1127
+ kind: "dm",
1128
+ visibility: "private",
1129
+ memberIds,
1130
+ createdAt: nowIso()
1131
+ };
1132
+ this.data.channels.push(channel);
1133
+ await saveData(this.data);
1134
+ return channel;
1135
+ }
1136
+ async sendMessage(channelName, text, threadRootId) {
1137
+ const channel = this.findChannel(channelName) ?? (await this.createChannel(channelName));
1138
+ if (!this.canReadChannel(channel)) {
1139
+ throw new Error(`Channel ${channelName} was not found.`);
1140
+ }
1141
+ if (channel.visibility === "private" && !this.isJoinedChannel(channel)) {
1142
+ throw new Error(`You are not a member of #${channel.name}.`);
1143
+ }
1144
+ if (channel.visibility === "public" && !this.isJoinedChannel(channel)) {
1145
+ channel.memberIds.push(this.currentUser.id);
1146
+ }
1147
+ const message = {
1148
+ id: id("msg"),
1149
+ workspaceId: this.activeWorkspace.id,
1150
+ channelId: channel.id,
1151
+ authorId: this.currentUser.id,
1152
+ text,
1153
+ createdAt: nowIso(),
1154
+ ...(threadRootId ? { threadRootId } : {}),
1155
+ reactions: [],
1156
+ mentions: extractMentions(text)
1157
+ };
1158
+ this.data.messages.push(message);
1159
+ this.maybeRespondAsAskThane(channel, message);
1160
+ await saveData(this.data);
1161
+ return message;
1162
+ }
1163
+ maybeRespondAsAskThane(channel, message) {
1164
+ const integration = this.askThaneStatus();
1165
+ if (!integration?.enabled || !message.mentions.includes("thane") || message.authorId === integration.botUserId) {
1166
+ return;
1167
+ }
1168
+ const requestedLocation = parsePingLocationRequest(message.text);
1169
+ let responseText = "Ask Thane is connected for this workspace. In the hosted backend, this mention will run the same task/memory agent used from Slack, linked by your account email.";
1170
+ if (requestedLocation && this.currentAccount) {
1171
+ let preference = this.data.notificationPreferences.find((candidate) => candidate.accountId === this.currentAccount?.id);
1172
+ if (!preference) {
1173
+ preference = {
1174
+ accountId: this.currentAccount.id,
1175
+ preferredPingLocation: requestedLocation,
1176
+ updatedAt: nowIso(),
1177
+ updatedBy: "ask_thane"
1178
+ };
1179
+ this.data.notificationPreferences.push(preference);
1180
+ }
1181
+ else {
1182
+ preference.preferredPingLocation = requestedLocation;
1183
+ preference.updatedAt = nowIso();
1184
+ preference.updatedBy = "ask_thane";
1185
+ }
1186
+ responseText = `Done. I will ping you in ${describePingLocation(requestedLocation)}.`;
1187
+ }
1188
+ const response = {
1189
+ id: id("msg"),
1190
+ workspaceId: this.activeWorkspace.id,
1191
+ channelId: channel.id,
1192
+ authorId: integration.botUserId,
1193
+ text: responseText,
1194
+ createdAt: nowIso(),
1195
+ ...(message.threadRootId ? { threadRootId: message.threadRootId } : { threadRootId: message.id }),
1196
+ reactions: [],
1197
+ mentions: []
1198
+ };
1199
+ integration.lastEventAt = response.createdAt;
1200
+ this.data.messages.push(response);
1201
+ }
1202
+ async sendDm(handle, text) {
1203
+ const channel = await this.findOrCreateDm(handle);
1204
+ return this.sendMessage(channel.id, text);
1205
+ }
1206
+ async reply(messageId, text) {
1207
+ const root = this.data.messages.find((message) => message.workspaceId === this.activeWorkspace.id && message.id === messageId);
1208
+ if (!root) {
1209
+ throw new Error(`Message ${messageId} was not found.`);
1210
+ }
1211
+ return this.sendMessage(root.channelId, text, root.threadRootId ?? root.id);
1212
+ }
1213
+ async react(messageId, emoji) {
1214
+ const message = this.data.messages.find((candidate) => candidate.workspaceId === this.activeWorkspace.id && candidate.id === messageId);
1215
+ if (!message) {
1216
+ throw new Error(`Message ${messageId} was not found.`);
1217
+ }
1218
+ message.reactions.push({ emoji, by: this.currentUser.handle, createdAt: nowIso() });
1219
+ await saveData(this.data);
1220
+ return message;
1221
+ }
1222
+ async markRead(channelName) {
1223
+ const channel = this.requireChannel(channelName);
1224
+ return this.markReadConversation(channel.id);
1225
+ }
1226
+ async markReadConversation(conversationId) {
1227
+ const channel = this.data.channels.find((candidate) => candidate.workspaceId === this.activeWorkspace.id && candidate.id === conversationId);
1228
+ if (!channel) {
1229
+ throw new Error(`Conversation ${conversationId} was not found.`);
1230
+ }
1231
+ const existing = this.data.readStates.find((state) => state.workspaceId === this.activeWorkspace.id && state.channelId === channel.id && state.userId === this.currentUser.id);
1232
+ const state = existing ?? { workspaceId: this.activeWorkspace.id, channelId: channel.id, userId: this.currentUser.id, lastReadAt: nowIso() };
1233
+ state.lastReadAt = nowIso();
1234
+ if (!existing) {
1235
+ this.data.readStates.push(state);
1236
+ }
1237
+ await saveData(this.data);
1238
+ return state;
1239
+ }
1240
+ inbox(options = {}) {
1241
+ const workspaceIds = options.allWorkspaces
1242
+ ? this.data.workspaces.map((workspace) => workspace.id)
1243
+ : [this.activeWorkspace.id];
1244
+ const workspaces = new Map(this.data.workspaces.map((workspace) => [workspace.id, workspace]));
1245
+ const users = new Map(this.data.users.map((user) => [user.id, user]));
1246
+ const readStates = new Map(this.data.readStates.map((state) => [`${state.workspaceId}:${state.channelId}:${state.userId}`, state.lastReadAt]));
1247
+ const summaries = [];
1248
+ for (const channel of this.data.channels.filter((candidate) => workspaceIds.includes(candidate.workspaceId))) {
1249
+ const workspace = workspaces.get(channel.workspaceId);
1250
+ const localUser = this.data.users.find((user) => user.workspaceId === channel.workspaceId && user.handle === "you");
1251
+ if (!workspace || !localUser || !channel.memberIds.includes(localUser.id)) {
1252
+ const isReadablePublic = channel.visibility === "public" && Boolean(localUser);
1253
+ if (!workspace || !localUser || !isReadablePublic) {
1254
+ continue;
1255
+ }
1256
+ }
1257
+ if (channel.visibility === "private" && !channel.memberIds.includes(localUser.id)) {
1258
+ continue;
1259
+ }
1260
+ const messages = this.data.messages
1261
+ .filter((message) => message.workspaceId === channel.workspaceId && message.channelId === channel.id)
1262
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1263
+ const latest = messages.at(-1);
1264
+ const readAt = readStates.get(`${channel.workspaceId}:${channel.id}:${localUser.id}`);
1265
+ const unread = messages.filter((message) => message.authorId !== localUser.id && (!readAt || message.createdAt > readAt));
1266
+ const mentionCount = unread.filter((message) => message.mentions.includes(localUser.handle.toLowerCase())).length;
1267
+ const isJoined = channel.memberIds.includes(localUser.id);
1268
+ const inboxUnread = isJoined ? unread.length : mentionCount;
1269
+ const hasSignal = inboxUnread > 0 || mentionCount > 0 || options.includeQuiet;
1270
+ if (options.onlyUnread && inboxUnread === 0) {
1271
+ continue;
1272
+ }
1273
+ if (!hasSignal) {
1274
+ continue;
1275
+ }
1276
+ summaries.push({
1277
+ workspace: workspace.slug,
1278
+ workspaceId: workspace.id,
1279
+ conversationId: channel.id,
1280
+ conversation: channel.name,
1281
+ conversationKind: channel.kind,
1282
+ unreadCount: inboxUnread,
1283
+ mentionCount,
1284
+ ...(latest ? { latestMessageAt: latest.createdAt } : {}),
1285
+ ...(latest ? { latestAuthor: users.get(latest.authorId)?.handle ?? latest.authorId } : {}),
1286
+ ...(latest ? { latestText: latest.text } : {})
1287
+ });
1288
+ }
1289
+ return summaries.sort((a, b) => (b.latestMessageAt ?? "").localeCompare(a.latestMessageAt ?? ""));
1290
+ }
1291
+ recent(channelName, limit = 20, since) {
1292
+ const channel = channelName ? this.requireChannel(channelName) : undefined;
1293
+ return this.toViews(this.data.messages
1294
+ .filter((message) => message.workspaceId === this.activeWorkspace.id)
1295
+ .filter((message) => !channel || message.channelId === channel.id)
1296
+ .filter((message) => {
1297
+ const messageChannel = this.data.channels.find((candidate) => candidate.id === message.channelId);
1298
+ return Boolean(messageChannel && this.canReadChannel(messageChannel));
1299
+ })
1300
+ .filter((message) => !since || new Date(message.createdAt).getTime() >= since.getTime())
1301
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
1302
+ .slice(-limit));
1303
+ }
1304
+ recentDm(handle, limit = 20, since) {
1305
+ const user = this.findUser(handle);
1306
+ if (!user) {
1307
+ throw new Error(`User ${handle} was not found. Add them with: thane user add ${handle}`);
1308
+ }
1309
+ const channel = this.data.channels.find((candidate) => candidate.workspaceId === this.activeWorkspace.id &&
1310
+ candidate.kind === "dm" &&
1311
+ candidate.memberIds.includes(this.currentUser.id) &&
1312
+ candidate.memberIds.includes(user.id));
1313
+ if (!channel) {
1314
+ return [];
1315
+ }
1316
+ return this.recent(channel.id, limit, since);
1317
+ }
1318
+ mentions(limit = 20, since) {
1319
+ const handle = this.currentUser.handle.toLowerCase();
1320
+ const displayName = this.currentUser.displayName.toLowerCase();
1321
+ return this.toViews(this.data.messages
1322
+ .filter((message) => message.workspaceId === this.activeWorkspace.id)
1323
+ .filter((message) => {
1324
+ const channel = this.data.channels.find((candidate) => candidate.id === message.channelId);
1325
+ return Boolean(channel && this.canReadChannel(channel));
1326
+ })
1327
+ .filter((message) => message.mentions.includes(handle) ||
1328
+ message.text.toLowerCase().includes(`@${displayName}`) ||
1329
+ message.text.toLowerCase().includes(displayName))
1330
+ .filter((message) => !since || new Date(message.createdAt).getTime() >= since.getTime())
1331
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
1332
+ .slice(-limit));
1333
+ }
1334
+ unread(limit = 50) {
1335
+ const byChannel = new Map(this.data.readStates
1336
+ .filter((state) => state.workspaceId === this.activeWorkspace.id)
1337
+ .map((state) => [state.channelId, state.lastReadAt]));
1338
+ return this.toViews(this.data.messages
1339
+ .filter((message) => message.workspaceId === this.activeWorkspace.id)
1340
+ .filter((message) => message.authorId !== this.currentUser.id)
1341
+ .filter((message) => {
1342
+ const channel = this.data.channels.find((candidate) => candidate.id === message.channelId);
1343
+ if (!channel || !this.canReadChannel(channel)) {
1344
+ return false;
1345
+ }
1346
+ return channel.memberIds.includes(this.currentUser.id) || message.mentions.includes(this.currentUser.handle.toLowerCase());
1347
+ })
1348
+ .filter((message) => {
1349
+ const readAt = byChannel.get(message.channelId);
1350
+ return !readAt || message.createdAt > readAt;
1351
+ })
1352
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
1353
+ .slice(-limit));
1354
+ }
1355
+ search(query, limit = 20) {
1356
+ const needle = query.toLowerCase();
1357
+ return this.toViews(this.data.messages
1358
+ .filter((message) => message.workspaceId === this.activeWorkspace.id)
1359
+ .filter((message) => {
1360
+ const channel = this.data.channels.find((candidate) => candidate.id === message.channelId);
1361
+ return Boolean(channel && this.canReadChannel(channel));
1362
+ })
1363
+ .filter((message) => message.text.toLowerCase().includes(needle))
1364
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
1365
+ .slice(-limit));
1366
+ }
1367
+ thread(rootId) {
1368
+ const root = this.data.messages.find((message) => message.workspaceId === this.activeWorkspace.id && message.id === rootId);
1369
+ if (!root) {
1370
+ throw new Error(`Message ${rootId} was not found.`);
1371
+ }
1372
+ const rootChannel = this.data.channels.find((channel) => channel.id === root.channelId);
1373
+ if (!rootChannel || !this.canReadChannel(rootChannel)) {
1374
+ throw new Error(`Message ${rootId} was not found.`);
1375
+ }
1376
+ const realRootId = root.threadRootId ?? root.id;
1377
+ return this.toViews(this.data.messages
1378
+ .filter((message) => message.workspaceId === this.activeWorkspace.id)
1379
+ .filter((message) => message.id === realRootId || message.threadRootId === realRootId)
1380
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
1381
+ }
1382
+ requireChannel(nameOrId) {
1383
+ const channel = this.findChannel(nameOrId);
1384
+ if (!channel) {
1385
+ throw new Error(`Channel ${nameOrId} was not found. Create it with: thane channel create ${nameOrId}`);
1386
+ }
1387
+ return channel;
1388
+ }
1389
+ toViews(messages) {
1390
+ const channels = new Map(this.data.channels
1391
+ .filter((channel) => channel.workspaceId === this.activeWorkspace.id)
1392
+ .map((channel) => [channel.id, channel]));
1393
+ const users = new Map(this.data.users.filter((user) => user.workspaceId === this.activeWorkspace.id).map((user) => [user.id, user.handle]));
1394
+ const repliesByRoot = new Map();
1395
+ for (const message of this.data.messages) {
1396
+ if (message.workspaceId === this.activeWorkspace.id && message.threadRootId) {
1397
+ repliesByRoot.set(message.threadRootId, (repliesByRoot.get(message.threadRootId) ?? 0) + 1);
1398
+ }
1399
+ }
1400
+ const handle = this.currentUser.handle.toLowerCase();
1401
+ return messages.map((message) => ({
1402
+ id: message.id,
1403
+ workspace: this.activeWorkspace.slug,
1404
+ channel: channels.get(message.channelId)?.name ?? message.channelId,
1405
+ conversationKind: channels.get(message.channelId)?.kind ?? "channel",
1406
+ author: users.get(message.authorId) ?? message.authorId,
1407
+ text: message.text,
1408
+ createdAt: message.createdAt,
1409
+ ...(message.threadRootId ? { threadRootId: message.threadRootId } : {}),
1410
+ replyCount: repliesByRoot.get(message.id) ?? 0,
1411
+ reactions: message.reactions,
1412
+ mentions: message.mentions,
1413
+ mentionsMe: message.mentions.includes(handle)
1414
+ }));
1415
+ }
1416
+ }
1417
+ //# sourceMappingURL=store.js.map