@chronicle.app/slack 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # @chronicle.app/slack
2
+
3
+ Chronicle plugin for Slack message history, read from a [slackdump](https://github.com/rusq/slackdump) export directory.
4
+
5
+ ## Exporting your data
6
+
7
+ Slack's own export supports public channels on all plans, including Free. Business+ and Enterprise also support private-channel and DM exports with the appropriate access; see [Slack's export documentation](https://slack.com/help/articles/201658943-Export-your-workspace-data). [slackdump](https://github.com/rusq/slackdump) exports conversations your account can access — public and private channels, DMs, and group DMs (mpims) — to a local directory.
8
+
9
+ ```bash
10
+ # Authenticate and export in Slack-export layout
11
+ slackdump export -o my-slack-export
12
+ ```
13
+
14
+ This produces a directory like:
15
+
16
+ ```
17
+ my-slack-export/
18
+ ├── channels.json # public/private channels
19
+ ├── dms.json # direct messages
20
+ ├── mpims.json # group DMs
21
+ ├── users.json # workspace members
22
+ ├── <channel-name>/ # one dir per conversation
23
+ │ ├── 2024-03-13.json # messages, one file per day
24
+ │ └── attachments/ # downloaded files (if exported)
25
+ └── attachments/ # top-level aggregate of all files
26
+ ```
27
+
28
+ Attachments are only present on disk if you exported them with slackdump. Chronicle includes attachment metadata only when `--attachments` is enabled; otherwise attachments are omitted entirely. With that flag, extraction includes a `contentPath` for files found locally. Files not found locally are recorded as metadata only. Extraction does not store attachment bytes.
29
+
30
+ ## Usage
31
+
32
+ ```bash
33
+ # Extract to stdout (Chronicle JSON-LD)
34
+ chronicle extract slack --input ./my-slack-export
35
+
36
+ # Include file attachments (with a contentPath for downloaded files)
37
+ chronicle extract slack --input ./my-slack-export --attachments
38
+ ```
39
+
40
+ Use `--since` / `--until` for incremental or windowed extraction:
41
+
42
+ ```bash
43
+ chronicle extract slack --input ./my-slack-export --since 2024-01-01
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ - `--input` (required): path to the slackdump export directory
49
+ - `--attachments`: include file attachments (default: false)
50
+ - `--since`: only extract messages on or after this date
51
+ - `--until`: only extract messages on or before this date
52
+ - `--limit`: maximum number of records to extract (default 100; 0 for no limit)
53
+
54
+ ## Schema
55
+
56
+ This plugin maps Slack data onto the following Chronicle types:
57
+
58
+ - **Messages** → `MessageAction` whose `object` is a `Message` (`source: slack`, `body`, `author`, `recipient`)
59
+ - **Users** → `Agent` (resolved to a `handle` and `name` from `users.json`, group-DM metadata, and message profiles)
60
+ - **File attachments** → `ImageObject` / `AudioObject` / `VideoObject` / `DocumentObject` (by MIME type), with `contentPath` to the locally exported file when available
61
+
62
+ Recipients are inferred from the conversation type: a DM addresses the other person, a group DM addresses every other member, a small private channel addresses its members, and a public/large channel is treated as a broadcast (no recipients).
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ # Build the plugin
68
+ npm run build -w @chronicle.app/slack
69
+
70
+ # Typecheck and lint
71
+ npm run typecheck -w @chronicle.app/slack
72
+ npm run lint -w @chronicle.app/slack
73
+
74
+ # Run tests
75
+ npm run test -w @chronicle.app/slack
76
+ ```
77
+
78
+ Tests build a small synthetic slackdump export (made-up users, channels, DMs,
79
+ and a group DM) in a temporary directory, so they never read the host's Slack
80
+ data or accounts and never touch the network.
@@ -0,0 +1,17 @@
1
+ import type { ResolvedUser } from './types/slack.js';
2
+ /**
3
+ * Resolves Slack user IDs to names/handles by combining multiple data sources:
4
+ * 1. users.json - workspace members with full profiles
5
+ * 2. mpims.json - co-occurrence of member IDs with @usernames in purpose field
6
+ * 3. Message user_profile fields and file author metadata
7
+ */
8
+ export declare class UserResolver {
9
+ private users;
10
+ get(userId: string): ResolvedUser | undefined;
11
+ getOrStub(userId: string, teamId?: string): ResolvedUser;
12
+ resolve(basePath: string): Promise<Map<string, ResolvedUser>>;
13
+ private loadUsersJson;
14
+ private loadMpimCoOccurrence;
15
+ private scanMessagesForProfiles;
16
+ }
17
+ //# sourceMappingURL=UserResolver.d.ts.map
@@ -0,0 +1,141 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * Resolves Slack user IDs to names/handles by combining multiple data sources:
5
+ * 1. users.json - workspace members with full profiles
6
+ * 2. mpims.json - co-occurrence of member IDs with @usernames in purpose field
7
+ * 3. Message user_profile fields and file author metadata
8
+ */
9
+ export class UserResolver {
10
+ users = new Map();
11
+ get(userId) {
12
+ return this.users.get(userId);
13
+ }
14
+ getOrStub(userId, teamId) {
15
+ const existing = this.users.get(userId);
16
+ if (existing)
17
+ return existing;
18
+ return { id: userId, teamId };
19
+ }
20
+ async resolve(basePath) {
21
+ // Phase 1: Load users.json (most complete source)
22
+ await this.loadUsersJson(basePath);
23
+ // Phase 2: Co-occurrence from mpims.json purpose fields
24
+ await this.loadMpimCoOccurrence(basePath);
25
+ // Phase 3: Scan messages for user_profile fields
26
+ await this.scanMessagesForProfiles(basePath);
27
+ return this.users;
28
+ }
29
+ async loadUsersJson(basePath) {
30
+ try {
31
+ const raw = await readFile(join(basePath, 'users.json'), 'utf-8');
32
+ const users = JSON.parse(raw);
33
+ for (const u of users) {
34
+ const name = u.profile?.display_name || u.profile?.real_name || u.real_name || undefined;
35
+ this.users.set(u.id, {
36
+ id: u.id,
37
+ handle: u.name || undefined,
38
+ name,
39
+ teamId: u.team_id,
40
+ });
41
+ }
42
+ }
43
+ catch {
44
+ // users.json may not exist
45
+ }
46
+ }
47
+ async loadMpimCoOccurrence(basePath) {
48
+ try {
49
+ const raw = await readFile(join(basePath, 'mpims.json'), 'utf-8');
50
+ const mpims = JSON.parse(raw);
51
+ // Iterate until stable: match member IDs to @usernames in purpose field
52
+ for (const mpim of mpims) {
53
+ const purpose = mpim.purpose?.value;
54
+ if (!purpose || !mpim.members)
55
+ continue;
56
+ // Extract @usernames from purpose like "Group messaging with: @user1 @user2 @user3"
57
+ const usernameMatches = purpose.match(/@([\w.]+)/g);
58
+ if (!usernameMatches)
59
+ continue;
60
+ const usernames = usernameMatches.map(m => m.slice(1));
61
+ // Also extract from mpim name like "mpdm-pat.example--alex.example--sam-1"
62
+ const nameMatches = mpim.name.match(/^mpdm-(.+)-\d+$/);
63
+ const nameUsernames = nameMatches ? nameMatches[1].split('--') : [];
64
+ const allUsernames = [...new Set([...usernames, ...nameUsernames])];
65
+ // For each member, try to match to a username
66
+ for (const memberId of mpim.members) {
67
+ const existing = this.users.get(memberId);
68
+ // If we already have a handle, skip
69
+ if (existing?.handle)
70
+ continue;
71
+ // Try to match this member to an unmatched username
72
+ // Check if any username matches the existing name
73
+ if (existing?.name) {
74
+ const matched = allUsernames.find(u => u.toLowerCase() === existing.name?.toLowerCase() ||
75
+ existing.name?.toLowerCase().includes(u.toLowerCase()));
76
+ if (matched) {
77
+ existing.handle = matched;
78
+ this.users.set(memberId, existing);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ catch {
85
+ // mpims.json may not exist
86
+ }
87
+ }
88
+ async scanMessagesForProfiles(basePath) {
89
+ try {
90
+ const entries = await readdir(basePath, { withFileTypes: true });
91
+ const dirs = entries.filter(e => e.isDirectory() && e.name !== 'attachments');
92
+ for (const dir of dirs) {
93
+ const dirPath = join(basePath, dir.name);
94
+ const files = await readdir(dirPath).catch(() => []);
95
+ const jsonFiles = (typeof files[0] === 'string' ? files : []);
96
+ for (const file of jsonFiles) {
97
+ if (!file.endsWith('.json'))
98
+ continue;
99
+ try {
100
+ const raw = await readFile(join(dirPath, file), 'utf-8');
101
+ const messages = JSON.parse(raw);
102
+ for (const msg of messages) {
103
+ if (!msg.user)
104
+ continue;
105
+ // Extract from user_profile field
106
+ if (msg.user_profile) {
107
+ const existing = this.users.get(msg.user);
108
+ if (!existing || (!existing.name && !existing.handle)) {
109
+ const profile = msg.user_profile;
110
+ this.users.set(msg.user, {
111
+ id: msg.user,
112
+ handle: existing?.handle || profile.name || profile.display_name || undefined,
113
+ name: existing?.name || profile.real_name || profile.display_name || undefined,
114
+ teamId: existing?.teamId || msg.team,
115
+ });
116
+ }
117
+ }
118
+ // Extract from files' user field (associates file author with user ID)
119
+ if (msg.files) {
120
+ for (const file of msg.files) {
121
+ if (file.user && !this.users.has(file.user)) {
122
+ this.users.set(file.user, {
123
+ id: file.user,
124
+ teamId: msg.team,
125
+ });
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ catch {
132
+ // Skip unreadable message files
133
+ }
134
+ }
135
+ }
136
+ }
137
+ catch {
138
+ // Can't read directory
139
+ }
140
+ }
141
+ }
@@ -0,0 +1,43 @@
1
+ import { Extractor, Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import SlackTransformer from './SlackTransformer.js';
4
+ export declare class SlackExtractor extends Extractor<typeof SlackExtractor> {
5
+ static source: string;
6
+ static description: string;
7
+ static delivery: "export";
8
+ static strategy: string;
9
+ static recordTypes: string[];
10
+ static default: boolean;
11
+ static defaultTransformer: typeof SlackTransformer;
12
+ static schema: z.ZodObject<{
13
+ since: z.ZodOptional<z.ZodDate>;
14
+ until: z.ZodOptional<z.ZodDate>;
15
+ limit: z.ZodOptional<z.ZodNumber>;
16
+ } & {
17
+ input: z.ZodString;
18
+ attachments: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
19
+ }, "strip", z.ZodTypeAny, {
20
+ attachments: boolean;
21
+ input: string;
22
+ since?: Date | undefined;
23
+ until?: Date | undefined;
24
+ limit?: number | undefined;
25
+ }, {
26
+ input: string;
27
+ attachments?: boolean | undefined;
28
+ since?: Date | undefined;
29
+ until?: Date | undefined;
30
+ limit?: number | undefined;
31
+ }>;
32
+ private conversations;
33
+ private userMap;
34
+ private teamId;
35
+ setup(): Promise<void>;
36
+ extract(): AsyncGenerator<Record>;
37
+ /** Yield a single conversation's messages newest-first, applying filters. */
38
+ private extractConversation;
39
+ determineCount(): Promise<number | null>;
40
+ private loadConversations;
41
+ private tsToDate;
42
+ }
43
+ //# sourceMappingURL=SlackExtractor.d.ts.map
@@ -0,0 +1,226 @@
1
+ import { Extractor } from '@chronicle.app/etl';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { UserResolver } from '../UserResolver.js';
6
+ import SlackTransformer from './SlackTransformer.js';
7
+ export class SlackExtractor extends Extractor {
8
+ static source = 'slack';
9
+ static description = 'Messages from a slackdump export';
10
+ static delivery = 'export';
11
+ static strategy = 'archive';
12
+ static recordTypes = ['messages'];
13
+ static default = true;
14
+ static defaultTransformer = SlackTransformer;
15
+ static schema = Extractor.schema.extend({
16
+ input: z.string().describe('Path to slackdump export directory'),
17
+ attachments: z.boolean().optional().default(false).describe('Include file attachments'),
18
+ });
19
+ conversations = [];
20
+ userMap = new Map();
21
+ teamId;
22
+ async setup() {
23
+ const config = this.config;
24
+ const basePath = config.input;
25
+ // Resolve users
26
+ const resolver = new UserResolver();
27
+ this.userMap = await resolver.resolve(basePath);
28
+ // Extract teamId from first user that has one
29
+ for (const user of this.userMap.values()) {
30
+ if (user.teamId) {
31
+ this.teamId = user.teamId;
32
+ break;
33
+ }
34
+ }
35
+ // Load conversation metadata
36
+ await this.loadConversations(basePath);
37
+ }
38
+ async *extract() {
39
+ const config = this.config;
40
+ const basePath = config.input;
41
+ const userMap = Object.fromEntries(this.userMap);
42
+ let count = 0;
43
+ // Each conversation yields its messages newest-first; merge those streams
44
+ // by timestamp (descending) so the output is globally reverse-chronological
45
+ // — `--limit 1` is the single latest message across the whole workspace.
46
+ const heads = [];
47
+ for (const conversation of this.conversations) {
48
+ const iterator = this.extractConversation(conversation, config);
49
+ const next = await iterator.next();
50
+ if (!next.done) {
51
+ heads.push({
52
+ iterator,
53
+ value: next.value,
54
+ ts: Number.parseFloat(next.value.msg.ts),
55
+ });
56
+ }
57
+ }
58
+ while (heads.length > 0) {
59
+ if (this.shouldStopExtracting(count))
60
+ return;
61
+ // Pick the head with the latest timestamp.
62
+ let maxIndex = 0;
63
+ for (let i = 1; i < heads.length; i++) {
64
+ if (heads[i].ts > heads[maxIndex].ts)
65
+ maxIndex = i;
66
+ }
67
+ const head = heads[maxIndex];
68
+ const { msg, conversation } = head.value;
69
+ yield this.createRecord({
70
+ type: 'message',
71
+ message: msg,
72
+ includeAttachments: config.attachments,
73
+ }, { conversation, userMap, teamId: this.teamId, basePath });
74
+ count++;
75
+ // Advance the chosen stream; drop it once exhausted.
76
+ const next = await head.iterator.next();
77
+ if (next.done) {
78
+ heads.splice(maxIndex, 1);
79
+ }
80
+ else {
81
+ head.value = next.value;
82
+ head.ts = Number.parseFloat(next.value.msg.ts);
83
+ }
84
+ }
85
+ }
86
+ /** Yield a single conversation's messages newest-first, applying filters. */
87
+ async *extractConversation(conversation, config) {
88
+ const dirPath = join(config.input, conversation.dirName);
89
+ let files;
90
+ try {
91
+ files = await readdir(dirPath);
92
+ }
93
+ catch {
94
+ return;
95
+ }
96
+ // Dated JSON files, newest first.
97
+ const jsonFiles = files
98
+ .filter(f => /^\d{4}-\d{2}-\d{2}\.json$/.test(f))
99
+ .sort()
100
+ .reverse();
101
+ for (const file of jsonFiles) {
102
+ // Quick date range check using filename
103
+ const fileDate = file.replace('.json', '');
104
+ if (config.until && new Date(fileDate) > config.until)
105
+ continue;
106
+ if (config.since && new Date(fileDate + 'T23:59:59.999Z') < config.since) {
107
+ continue;
108
+ }
109
+ let messages;
110
+ try {
111
+ const raw = await readFile(join(dirPath, file), 'utf-8');
112
+ messages = JSON.parse(raw);
113
+ }
114
+ catch {
115
+ continue;
116
+ }
117
+ // Keep real messages in range, then sort newest-first so the merge across
118
+ // conversations stays correct even if a day file isn't perfectly ordered.
119
+ const valid = messages.filter(msg => {
120
+ if (msg.type !== 'message')
121
+ return false;
122
+ if (msg.subtype && !['bot_message', 'thread_broadcast'].includes(msg.subtype)) {
123
+ return false;
124
+ }
125
+ const msgDate = this.tsToDate(msg.ts);
126
+ if (config.since && msgDate < config.since)
127
+ return false;
128
+ if (config.until && msgDate > config.until)
129
+ return false;
130
+ return true;
131
+ });
132
+ valid.sort((a, b) => Number.parseFloat(b.ts) - Number.parseFloat(a.ts));
133
+ for (const msg of valid) {
134
+ yield { msg, conversation };
135
+ }
136
+ }
137
+ }
138
+ async determineCount() {
139
+ const config = this.config;
140
+ const basePath = config.input;
141
+ let total = 0;
142
+ for (const conversation of this.conversations) {
143
+ const dirPath = join(basePath, conversation.dirName);
144
+ let files;
145
+ try {
146
+ files = await readdir(dirPath);
147
+ }
148
+ catch {
149
+ continue;
150
+ }
151
+ const jsonFiles = files.filter(f => /^\d{4}-\d{2}-\d{2}\.json$/.test(f));
152
+ for (const file of jsonFiles) {
153
+ try {
154
+ const raw = await readFile(join(dirPath, file), 'utf-8');
155
+ const messages = JSON.parse(raw);
156
+ total += messages.filter(m => m.type === 'message' &&
157
+ (!m.subtype || ['bot_message', 'thread_broadcast'].includes(m.subtype))).length;
158
+ }
159
+ catch {
160
+ continue;
161
+ }
162
+ }
163
+ }
164
+ return total;
165
+ }
166
+ async loadConversations(basePath) {
167
+ // Load channels
168
+ try {
169
+ const raw = await readFile(join(basePath, 'channels.json'), 'utf-8');
170
+ const channels = JSON.parse(raw);
171
+ for (const ch of channels) {
172
+ this.conversations.push({
173
+ id: ch.id,
174
+ name: ch.name,
175
+ type: 'channel',
176
+ members: ch.members ?? undefined,
177
+ isPrivate: ch.is_private,
178
+ dirName: ch.name,
179
+ });
180
+ }
181
+ }
182
+ catch {
183
+ // channels.json may not exist
184
+ }
185
+ // Load DMs
186
+ try {
187
+ const raw = await readFile(join(basePath, 'dms.json'), 'utf-8');
188
+ const dms = JSON.parse(raw);
189
+ for (const dm of dms) {
190
+ this.conversations.push({
191
+ id: dm.id,
192
+ type: 'dm',
193
+ members: dm.members,
194
+ dirName: dm.id,
195
+ });
196
+ }
197
+ }
198
+ catch {
199
+ // dms.json may not exist
200
+ }
201
+ // Load MPIMs
202
+ try {
203
+ const raw = await readFile(join(basePath, 'mpims.json'), 'utf-8');
204
+ const mpims = JSON.parse(raw);
205
+ for (const mpim of mpims) {
206
+ this.conversations.push({
207
+ id: mpim.id,
208
+ name: mpim.name,
209
+ type: 'mpim',
210
+ members: mpim.members,
211
+ dirName: mpim.name,
212
+ });
213
+ }
214
+ }
215
+ catch {
216
+ // mpims.json may not exist
217
+ }
218
+ // Filter to only conversations that have directories on disk
219
+ const entries = await readdir(basePath, { withFileTypes: true }).catch(() => []);
220
+ const dirNames = new Set(entries.filter(e => e.isDirectory()).map(e => e.name));
221
+ this.conversations = this.conversations.filter(c => dirNames.has(c.dirName));
222
+ }
223
+ tsToDate(ts) {
224
+ return new Date(Number.parseFloat(ts) * 1000);
225
+ }
226
+ }
@@ -0,0 +1,18 @@
1
+ import { ChronicleTransformer, Record as EtlRecord } from '@chronicle.app/etl';
2
+ import type { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class SlackTransformer extends ChronicleTransformer {
4
+ transform(record: EtlRecord): Promise<ActionAndChildren[]>;
5
+ private buildMessageActions;
6
+ private buildAgent;
7
+ private buildRecipients;
8
+ private buildAttachments;
9
+ /** Listings of attachment dirs, memoized for the run (one instance per run). */
10
+ private attachmentDirCache;
11
+ /**
12
+ * Resolve a Slack file id to the absolute path of its downloaded bytes, or
13
+ * undefined if slackdump did not export the file locally. Matches by id
14
+ * prefix (`<fileId>-...`) so a sanitized on-disk name still resolves.
15
+ */
16
+ private resolveAttachmentPath;
17
+ }
18
+ //# sourceMappingURL=SlackTransformer.d.ts.map
@@ -0,0 +1,165 @@
1
+ import { ChronicleTransformer, createMediaObject } from '@chronicle.app/etl';
2
+ import { readdirSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ const SMALL_CHANNEL_THRESHOLD = 10;
5
+ export default class SlackTransformer extends ChronicleTransformer {
6
+ async transform(record) {
7
+ if (record.data.type === 'message') {
8
+ return this.buildMessageActions(record);
9
+ }
10
+ return [];
11
+ }
12
+ buildMessageActions(record) {
13
+ const msg = record.data.message;
14
+ const { conversation } = record.context;
15
+ const { userMap } = record.context;
16
+ const { teamId } = record.context;
17
+ const { includeAttachments } = record.data;
18
+ const senderId = msg.user || msg.bot_id;
19
+ if (!senderId)
20
+ return [];
21
+ const sender = this.buildAgent(senderId, userMap[senderId], teamId);
22
+ // Build Message entity
23
+ const message = {
24
+ '@type': 'Message',
25
+ '@key': ['@type', 'source', 'body', 'author'],
26
+ source: 'slack',
27
+ body: msg.text,
28
+ author: [sender],
29
+ };
30
+ // Add recipients based on conversation type
31
+ const recipients = this.buildRecipients(conversation, senderId, userMap, teamId);
32
+ if (recipients.length > 0) {
33
+ message.recipient = recipients;
34
+ }
35
+ // Add attachments if enabled and present
36
+ if (includeAttachments && msg.files && msg.files.length > 0) {
37
+ const attachments = this.buildAttachments(msg.files, record.context.basePath, conversation.dirName);
38
+ if (attachments.length > 0) {
39
+ message.contains = attachments;
40
+ }
41
+ }
42
+ // Build MessageAction
43
+ const messageAction = {
44
+ '@type': 'MessageAction',
45
+ '@key': ['@type', 'source', 'timestamp', 'agent'],
46
+ source: 'slack',
47
+ timestamp: new Date(Number.parseFloat(msg.ts) * 1000),
48
+ agent: sender,
49
+ object: message,
50
+ };
51
+ return [messageAction];
52
+ }
53
+ buildAgent(userId, resolved, teamId) {
54
+ const agent = {
55
+ '@type': 'Agent',
56
+ '@key': ['@type', 'source', 'sourceId'],
57
+ source: 'slack',
58
+ sourceId: userId,
59
+ };
60
+ if (resolved?.handle) {
61
+ agent.handle = resolved.handle;
62
+ }
63
+ if (resolved?.name) {
64
+ agent.name = resolved.name;
65
+ }
66
+ // The workspace as a real Realm the agent is a member of — replaces the old
67
+ // `handleNamespace: teamId` scalar with a traversable edge to a keyed
68
+ // workspace node. A Realm (a bounded account domain), not an Organization:
69
+ // the workspace may itself belong to a company.
70
+ const workspaceId = resolved?.teamId || teamId;
71
+ if (workspaceId) {
72
+ agent.memberOf = [
73
+ {
74
+ '@type': 'Realm',
75
+ '@key': ['@type', 'source', 'sourceId'],
76
+ source: 'slack',
77
+ sourceId: workspaceId,
78
+ },
79
+ ];
80
+ }
81
+ return agent;
82
+ }
83
+ buildRecipients(conversation, senderId, userMap, teamId) {
84
+ const { members } = conversation;
85
+ switch (conversation.type) {
86
+ case 'dm':
87
+ // DMs: recipient is the other person
88
+ if (members && members.length === 2) {
89
+ const otherId = members.find(m => m !== senderId);
90
+ if (otherId) {
91
+ return [this.buildAgent(otherId, userMap[otherId], teamId)];
92
+ }
93
+ }
94
+ return [];
95
+ case 'mpim':
96
+ // MPIMs: recipients are all other members
97
+ if (members) {
98
+ return members
99
+ .filter(m => m !== senderId)
100
+ .map(m => this.buildAgent(m, userMap[m], teamId));
101
+ }
102
+ return [];
103
+ case 'channel':
104
+ // Small private channels: recipients are other members
105
+ if (conversation.isPrivate && members && members.length <= SMALL_CHANNEL_THRESHOLD) {
106
+ return members
107
+ .filter(m => m !== senderId)
108
+ .map(m => this.buildAgent(m, userMap[m], teamId));
109
+ }
110
+ // Large/public channels: no recipients (broadcast)
111
+ return [];
112
+ default:
113
+ return [];
114
+ }
115
+ }
116
+ buildAttachments(files, basePath, dirName) {
117
+ const attachments = [];
118
+ for (const file of files) {
119
+ // slackdump writes downloaded files to disk as `<fileId>-<name>`, in the
120
+ // conversation's own `attachments/` dir (and a top-level aggregate one).
121
+ // When the bytes are on disk we emit a contentPath so ingest captures
122
+ // them; otherwise the node stays metadata-only (the url-gated Slack file
123
+ // would need an auth token to fetch).
124
+ const localPath = this.resolveAttachmentPath(basePath, dirName, file.id);
125
+ const mediaObject = createMediaObject({
126
+ mimeType: file.mimetype,
127
+ source: 'slack',
128
+ sourceId: file.id,
129
+ '@key': ['@type', 'source', 'sourceId'],
130
+ name: file.name,
131
+ ...(localPath && { path: localPath }),
132
+ ...(file.original_w && file.original_w > 0 && { width: file.original_w }),
133
+ ...(file.original_h && file.original_h > 0 && { height: file.original_h }),
134
+ });
135
+ attachments.push(mediaObject);
136
+ }
137
+ return attachments;
138
+ }
139
+ /** Listings of attachment dirs, memoized for the run (one instance per run). */
140
+ attachmentDirCache = new Map();
141
+ /**
142
+ * Resolve a Slack file id to the absolute path of its downloaded bytes, or
143
+ * undefined if slackdump did not export the file locally. Matches by id
144
+ * prefix (`<fileId>-...`) so a sanitized on-disk name still resolves.
145
+ */
146
+ resolveAttachmentPath(basePath, dirName, fileId) {
147
+ const candidateDirs = [join(basePath, dirName, 'attachments'), join(basePath, 'attachments')];
148
+ for (const dir of candidateDirs) {
149
+ let entries = this.attachmentDirCache.get(dir);
150
+ if (entries === undefined) {
151
+ try {
152
+ entries = readdirSync(dir);
153
+ }
154
+ catch {
155
+ entries = [];
156
+ }
157
+ this.attachmentDirCache.set(dir, entries);
158
+ }
159
+ const match = entries.find(entry => entry.startsWith(`${fileId}-`));
160
+ if (match)
161
+ return resolve(dir, match);
162
+ }
163
+ return undefined;
164
+ }
165
+ }
@@ -0,0 +1,4 @@
1
+ export { SlackExtractor } from './connectors/SlackExtractor.js';
2
+ export { default as SlackTransformer } from './connectors/SlackTransformer.js';
3
+ export { UserResolver } from './UserResolver.js';
4
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { SlackExtractor } from './connectors/SlackExtractor.js';
2
+ export { default as SlackTransformer } from './connectors/SlackTransformer.js';
3
+ export { UserResolver } from './UserResolver.js';
@@ -0,0 +1,143 @@
1
+ /** Slack user profile from users.json */
2
+ export interface SlackUserProfile {
3
+ title?: string;
4
+ phone?: string;
5
+ real_name?: string;
6
+ real_name_normalized?: string;
7
+ display_name?: string;
8
+ display_name_normalized?: string;
9
+ first_name?: string;
10
+ last_name?: string;
11
+ email?: string;
12
+ image_24?: string;
13
+ image_32?: string;
14
+ image_48?: string;
15
+ image_72?: string;
16
+ image_192?: string;
17
+ image_512?: string;
18
+ bot_id?: string;
19
+ api_app_id?: string;
20
+ }
21
+ /** Slack user from users.json */
22
+ export interface SlackUser {
23
+ id: string;
24
+ team_id: string;
25
+ name: string;
26
+ deleted: boolean;
27
+ real_name?: string;
28
+ tz?: string;
29
+ tz_label?: string;
30
+ tz_offset?: number;
31
+ profile: SlackUserProfile;
32
+ is_bot: boolean;
33
+ is_admin?: boolean;
34
+ is_owner?: boolean;
35
+ is_primary_owner?: boolean;
36
+ updated: number;
37
+ }
38
+ /** Topic or purpose field on channels */
39
+ export interface SlackTopicOrPurpose {
40
+ value: string;
41
+ creator: string;
42
+ last_set: number;
43
+ }
44
+ /** Channel from channels.json */
45
+ export interface SlackChannel {
46
+ id: string;
47
+ name: string;
48
+ name_normalized?: string;
49
+ created: number;
50
+ creator: string;
51
+ is_channel: boolean;
52
+ is_private: boolean;
53
+ is_archived: boolean;
54
+ is_general: boolean;
55
+ is_shared?: boolean;
56
+ is_ext_shared?: boolean;
57
+ num_members?: number;
58
+ members?: string[] | null;
59
+ topic?: SlackTopicOrPurpose;
60
+ purpose?: SlackTopicOrPurpose;
61
+ }
62
+ /** DM conversation from dms.json */
63
+ export interface SlackDm {
64
+ id: string;
65
+ created: number;
66
+ members: [string, string];
67
+ }
68
+ /** Multi-party IM from mpims.json */
69
+ export interface SlackMpim {
70
+ id: string;
71
+ name: string;
72
+ created: number;
73
+ creator?: string;
74
+ is_mpim: boolean;
75
+ members: string[];
76
+ topic?: SlackTopicOrPurpose;
77
+ purpose?: SlackTopicOrPurpose;
78
+ }
79
+ /** Slack file attachment on a message */
80
+ export interface SlackFile {
81
+ id: string;
82
+ name: string;
83
+ title?: string;
84
+ mimetype: string;
85
+ filetype: string;
86
+ pretty_type?: string;
87
+ user: string;
88
+ size: number;
89
+ url_private?: string;
90
+ url_private_download?: string;
91
+ permalink?: string;
92
+ created: number;
93
+ timestamp: number;
94
+ mode?: string;
95
+ is_external?: boolean;
96
+ original_h?: number;
97
+ original_w?: number;
98
+ }
99
+ /** Slack message from dated JSON files */
100
+ export interface SlackMessage {
101
+ client_msg_id?: string;
102
+ type: string;
103
+ subtype?: string;
104
+ user?: string;
105
+ text: string;
106
+ ts: string;
107
+ team?: string;
108
+ thread_ts?: string;
109
+ parent_user_id?: string;
110
+ edited?: {
111
+ user: string;
112
+ ts: string;
113
+ };
114
+ reactions?: Array<{
115
+ name: string;
116
+ count: number;
117
+ users: string[];
118
+ }>;
119
+ files?: SlackFile[];
120
+ blocks?: any[];
121
+ user_profile?: SlackUserProfile & {
122
+ name?: string;
123
+ };
124
+ username?: string;
125
+ bot_id?: string;
126
+ }
127
+ /** Resolved user info for the user map */
128
+ export interface ResolvedUser {
129
+ id: string;
130
+ name?: string;
131
+ handle?: string;
132
+ teamId?: string;
133
+ }
134
+ /** Metadata about a conversation (channel, DM, or MPIM) */
135
+ export interface ConversationMeta {
136
+ id: string;
137
+ name?: string;
138
+ type: 'channel' | 'dm' | 'mpim';
139
+ members?: string[];
140
+ isPrivate?: boolean;
141
+ dirName: string;
142
+ }
143
+ //# sourceMappingURL=slack.d.ts.map
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@chronicle.app/slack",
3
+ "version": "0.3.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [
17
+ "chronicle",
18
+ "slack"
19
+ ],
20
+ "author": "Andrew Louis",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "description": "Chronicle plugin for Slack message history via slackdump export",
24
+ "devDependencies": {
25
+ "@chronicle.app/etl": "0.3.0",
26
+ "@chronicle.app/schema": "0.3.0",
27
+ "@chronicle.app/tsconfig": "0.3.0",
28
+ "@types/node": "^22.10.1",
29
+ "typescript": "^5.7.2"
30
+ },
31
+ "homepage": "https://chronicle.app",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/chronicle-app/chronicle.git",
35
+ "directory": "plugins/slack"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org/"
40
+ },
41
+ "dependencies": {
42
+ "zod": "^3.24.1"
43
+ },
44
+ "peerDependencies": {
45
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
46
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=22.13.0"
50
+ },
51
+ "exports": {
52
+ ".": {
53
+ "types": "./dist/index.d.ts",
54
+ "import": "./dist/index.js"
55
+ }
56
+ },
57
+ "chronicle": {
58
+ "plugin": true,
59
+ "sources": {
60
+ "slack": {
61
+ "strategies": {
62
+ "archive": {
63
+ "delivery": "export",
64
+ "recordTypes": [
65
+ "messages"
66
+ ],
67
+ "default": true
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }