@chronicle.app/slack 0.4.0 → 0.5.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/README.md CHANGED
@@ -45,7 +45,7 @@ chronicle extract slack --input ./my-slack-export --since 2024-01-01
45
45
 
46
46
  ## Configuration
47
47
 
48
- - `--input` (required): path to the slackdump export directory
48
+ - `--input` (required): the slackdump export, as a folder or zip
49
49
  - `--attachments`: include file attachments (default: false)
50
50
  - `--since`: only extract messages on or after this date
51
51
  - `--until`: only extract messages on or before this date
@@ -55,7 +55,7 @@ chronicle extract slack --input ./my-slack-export --since 2024-01-01
55
55
 
56
56
  This plugin maps Slack data onto the following Chronicle types:
57
57
 
58
- - **Messages** → `MessageAction` whose `object` is a `Message` (`source: slack`, `body`, `author`, `recipient`)
58
+ - **Messages** → `MessageAction` whose `result` is a `Message` (`source: slack`, `body`, `author`, `recipient`)
59
59
  - **Users** → `Agent` (resolved to a `handle` and `name` from `users.json`, group-DM metadata, and message profiles)
60
60
  - **File attachments** → `ImageObject` / `AudioObject` / `VideoObject` / `DocumentObject` (by MIME type), with `contentPath` to the locally exported file when available
61
61
 
@@ -1,3 +1,4 @@
1
+ import type { ExportFiles } from '@chronicle.app/etl';
1
2
  import type { ResolvedUser } from './types/slack.js';
2
3
  /**
3
4
  * Resolves Slack user IDs to names/handles by combining multiple data sources:
@@ -9,7 +10,7 @@ export declare class UserResolver {
9
10
  private users;
10
11
  get(userId: string): ResolvedUser | undefined;
11
12
  getOrStub(userId: string, teamId?: string): ResolvedUser;
12
- resolve(basePath: string): Promise<Map<string, ResolvedUser>>;
13
+ resolve(files: ExportFiles): Promise<Map<string, ResolvedUser>>;
13
14
  private loadUsersJson;
14
15
  private loadMpimCoOccurrence;
15
16
  private scanMessagesForProfiles;
@@ -1,5 +1,3 @@
1
- import { readFile, readdir } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
1
  /**
4
2
  * Resolves Slack user IDs to names/handles by combining multiple data sources:
5
3
  * 1. users.json - workspace members with full profiles
@@ -17,18 +15,18 @@ export class UserResolver {
17
15
  return existing;
18
16
  return { id: userId, teamId };
19
17
  }
20
- async resolve(basePath) {
18
+ async resolve(files) {
21
19
  // Phase 1: Load users.json (most complete source)
22
- await this.loadUsersJson(basePath);
20
+ await this.loadUsersJson(files);
23
21
  // Phase 2: Co-occurrence from mpims.json purpose fields
24
- await this.loadMpimCoOccurrence(basePath);
22
+ await this.loadMpimCoOccurrence(files);
25
23
  // Phase 3: Scan messages for user_profile fields
26
- await this.scanMessagesForProfiles(basePath);
24
+ await this.scanMessagesForProfiles(files);
27
25
  return this.users;
28
26
  }
29
- async loadUsersJson(basePath) {
27
+ async loadUsersJson(files) {
30
28
  try {
31
- const raw = await readFile(join(basePath, 'users.json'), 'utf-8');
29
+ const raw = await files.readText('users.json');
32
30
  const users = JSON.parse(raw);
33
31
  for (const u of users) {
34
32
  const name = u.profile?.display_name || u.profile?.real_name || u.real_name || undefined;
@@ -44,9 +42,9 @@ export class UserResolver {
44
42
  // users.json may not exist
45
43
  }
46
44
  }
47
- async loadMpimCoOccurrence(basePath) {
45
+ async loadMpimCoOccurrence(files) {
48
46
  try {
49
- const raw = await readFile(join(basePath, 'mpims.json'), 'utf-8');
47
+ const raw = await files.readText('mpims.json');
50
48
  const mpims = JSON.parse(raw);
51
49
  // Iterate until stable: match member IDs to @usernames in purpose field
52
50
  for (const mpim of mpims) {
@@ -85,19 +83,18 @@ export class UserResolver {
85
83
  // mpims.json may not exist
86
84
  }
87
85
  }
88
- async scanMessagesForProfiles(basePath) {
86
+ async scanMessagesForProfiles(files) {
89
87
  try {
90
- const entries = await readdir(basePath, { withFileTypes: true });
91
- const dirs = entries.filter(e => e.isDirectory() && e.name !== 'attachments');
88
+ const dirs = (await files.children(''))
89
+ .filter(entry => entry.endsWith('/') && entry !== 'attachments/')
90
+ .map(entry => entry.slice(0, -1));
92
91
  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 : []);
92
+ const jsonFiles = await files.children(dir);
96
93
  for (const file of jsonFiles) {
97
94
  if (!file.endsWith('.json'))
98
95
  continue;
99
96
  try {
100
- const raw = await readFile(join(dirPath, file), 'utf-8');
97
+ const raw = await files.readText(`${dir}/${file}`);
101
98
  const messages = JSON.parse(raw);
102
99
  for (const msg of messages) {
103
100
  if (!msg.user)
@@ -1,4 +1,4 @@
1
- import { Extractor, Record } from '@chronicle.app/etl';
1
+ import { Extractor, Record, type KindSurvey, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import { z } from 'zod';
3
3
  import SlackTransformer from './SlackTransformer.js';
4
4
  export declare class SlackExtractor extends Extractor<typeof SlackExtractor> {
@@ -29,14 +29,30 @@ export declare class SlackExtractor extends Extractor<typeof SlackExtractor> {
29
29
  until?: Date | undefined;
30
30
  limit?: number | undefined;
31
31
  }>;
32
+ private files;
32
33
  private conversations;
33
34
  private userMap;
34
35
  private teamId;
35
36
  setup(): Promise<void>;
37
+ teardown(): Promise<void>;
36
38
  extract(): AsyncGenerator<Record>;
39
+ /**
40
+ * Each downloaded file's path on disk, by its id. slackdump writes a file as
41
+ * `<fileId>-<name>`, in the conversation's own `attachments/` folder or a
42
+ * top-level aggregate one; a sanitized name still matches by its id. A file
43
+ * slackdump didn't download has none.
44
+ */
45
+ private attachmentPaths;
37
46
  /** Yield a single conversation's messages newest-first, applying filters. */
38
47
  private extractConversation;
39
48
  determineCount(): Promise<number | null>;
49
+ /**
50
+ * The messages a run reads, when they were sent, and in how many
51
+ * conversations, from every day file in range: the export has no index.
52
+ */
53
+ survey(options?: SurveyOptions): Promise<{
54
+ messages: KindSurvey;
55
+ } & Survey>;
40
56
  private loadConversations;
41
57
  private tsToDate;
42
58
  }
@@ -1,6 +1,4 @@
1
- import { Extractor } from '@chronicle.app/etl';
2
- import { readFile, readdir } from 'node:fs/promises';
3
- import { join } from 'node:path';
1
+ import { Extractor, countMonth, openExport, } from '@chronicle.app/etl';
4
2
  import { z } from 'zod';
5
3
  import { UserResolver } from '../UserResolver.js';
6
4
  import SlackTransformer from './SlackTransformer.js';
@@ -13,18 +11,19 @@ export class SlackExtractor extends Extractor {
13
11
  static default = true;
14
12
  static defaultTransformer = SlackTransformer;
15
13
  static schema = Extractor.schema.extend({
16
- input: z.string().describe('Path to slackdump export directory'),
14
+ input: z.string().describe('The slackdump export, as a folder or zip'),
17
15
  attachments: z.boolean().optional().default(false).describe('Include file attachments'),
18
16
  });
17
+ files;
19
18
  conversations = [];
20
19
  userMap = new Map();
21
20
  teamId;
22
21
  async setup() {
23
22
  const config = this.config;
24
- const basePath = config.input;
23
+ this.files = await openExport(config.input, 'slackdump export');
25
24
  // Resolve users
26
25
  const resolver = new UserResolver();
27
- this.userMap = await resolver.resolve(basePath);
26
+ this.userMap = await resolver.resolve(this.files);
28
27
  // Extract teamId from first user that has one
29
28
  for (const user of this.userMap.values()) {
30
29
  if (user.teamId) {
@@ -33,7 +32,11 @@ export class SlackExtractor extends Extractor {
33
32
  }
34
33
  }
35
34
  // Load conversation metadata
36
- await this.loadConversations(basePath);
35
+ await this.loadConversations();
36
+ }
37
+ async teardown() {
38
+ await this.files?.close();
39
+ await super.teardown();
37
40
  }
38
41
  async *extract() {
39
42
  const config = this.config;
@@ -66,11 +69,21 @@ export class SlackExtractor extends Extractor {
66
69
  }
67
70
  const head = heads[maxIndex];
68
71
  const { msg, conversation } = head.value;
72
+ // Where each attached file's bytes are, for the transformer to point at.
73
+ const attachmentPaths = config.attachments && msg.files?.length
74
+ ? await this.attachmentPaths(msg.files, conversation.dirName)
75
+ : undefined;
69
76
  yield this.createRecord({
70
77
  type: 'message',
71
78
  message: msg,
72
79
  includeAttachments: config.attachments,
73
- }, { conversation, userMap, teamId: this.teamId, basePath });
80
+ }, {
81
+ conversation,
82
+ userMap,
83
+ teamId: this.teamId,
84
+ basePath,
85
+ ...(attachmentPaths && { attachmentPaths }),
86
+ });
74
87
  count++;
75
88
  // Advance the chosen stream; drop it once exhausted.
76
89
  const next = await head.iterator.next();
@@ -83,16 +96,30 @@ export class SlackExtractor extends Extractor {
83
96
  }
84
97
  }
85
98
  }
99
+ /**
100
+ * Each downloaded file's path on disk, by its id. slackdump writes a file as
101
+ * `<fileId>-<name>`, in the conversation's own `attachments/` folder or a
102
+ * top-level aggregate one; a sanitized name still matches by its id. A file
103
+ * slackdump didn't download has none.
104
+ */
105
+ async attachmentPaths(files, dirName) {
106
+ const paths = {};
107
+ for (const file of files) {
108
+ for (const dir of [`${dirName}/attachments`, 'attachments']) {
109
+ const match = (await this.files.children(dir)).find(entry => entry.startsWith(`${file.id}-`));
110
+ const path = match && (await this.files.localPath(`${dir}/${match}`));
111
+ if (path) {
112
+ paths[file.id] = path;
113
+ break;
114
+ }
115
+ }
116
+ }
117
+ return paths;
118
+ }
86
119
  /** Yield a single conversation's messages newest-first, applying filters. */
87
120
  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
- }
121
+ const dirPath = conversation.dirName;
122
+ const files = await this.files.children(dirPath);
96
123
  // Dated JSON files, newest first.
97
124
  const jsonFiles = files
98
125
  .filter(f => /^\d{4}-\d{2}-\d{2}\.json$/.test(f))
@@ -108,7 +135,7 @@ export class SlackExtractor extends Extractor {
108
135
  }
109
136
  let messages;
110
137
  try {
111
- const raw = await readFile(join(dirPath, file), 'utf-8');
138
+ const raw = await this.files.readText(`${dirPath}/${file}`);
112
139
  messages = JSON.parse(raw);
113
140
  }
114
141
  catch {
@@ -136,37 +163,40 @@ export class SlackExtractor extends Extractor {
136
163
  }
137
164
  }
138
165
  async determineCount() {
166
+ const { count } = (await this.survey()).messages;
167
+ const limit = this.getEffectiveLimit();
168
+ return limit === null ? count : Math.min(count, limit);
169
+ }
170
+ /**
171
+ * The messages a run reads, when they were sent, and in how many
172
+ * conversations, from every day file in range: the export has no index.
173
+ */
174
+ async survey(options = {}) {
139
175
  const config = this.config;
140
- const basePath = config.input;
141
- let total = 0;
176
+ const messages = { count: 0, ...(options.byMonth && { byMonth: {} }) };
177
+ let conversations = 0;
142
178
  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
- }
179
+ const before = messages.count;
180
+ for await (const { msg } of this.extractConversation(conversation, config)) {
181
+ const sent = this.tsToDate(msg.ts);
182
+ messages.count++;
183
+ if (!messages.earliest || sent < messages.earliest)
184
+ messages.earliest = sent;
185
+ if (!messages.latest || sent > messages.latest)
186
+ messages.latest = sent;
187
+ if (messages.byMonth)
188
+ countMonth(messages.byMonth, sent);
162
189
  }
190
+ if (messages.count > before)
191
+ conversations++;
163
192
  }
164
- return total;
193
+ messages.measures = [{ key: 'conversations', label: 'conversations', value: conversations }];
194
+ return { messages };
165
195
  }
166
- async loadConversations(basePath) {
196
+ async loadConversations() {
167
197
  // Load channels
168
198
  try {
169
- const raw = await readFile(join(basePath, 'channels.json'), 'utf-8');
199
+ const raw = await this.files.readText('channels.json');
170
200
  const channels = JSON.parse(raw);
171
201
  for (const ch of channels) {
172
202
  this.conversations.push({
@@ -184,7 +214,7 @@ export class SlackExtractor extends Extractor {
184
214
  }
185
215
  // Load DMs
186
216
  try {
187
- const raw = await readFile(join(basePath, 'dms.json'), 'utf-8');
217
+ const raw = await this.files.readText('dms.json');
188
218
  const dms = JSON.parse(raw);
189
219
  for (const dm of dms) {
190
220
  this.conversations.push({
@@ -200,7 +230,7 @@ export class SlackExtractor extends Extractor {
200
230
  }
201
231
  // Load MPIMs
202
232
  try {
203
- const raw = await readFile(join(basePath, 'mpims.json'), 'utf-8');
233
+ const raw = await this.files.readText('mpims.json');
204
234
  const mpims = JSON.parse(raw);
205
235
  for (const mpim of mpims) {
206
236
  this.conversations.push({
@@ -215,9 +245,9 @@ export class SlackExtractor extends Extractor {
215
245
  catch {
216
246
  // mpims.json may not exist
217
247
  }
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));
248
+ // Filter to only conversations that have folders in the export
249
+ const entries = await this.files.children('');
250
+ const dirNames = new Set(entries.filter(e => e.endsWith('/')).map(e => e.slice(0, -1)));
221
251
  this.conversations = this.conversations.filter(c => dirNames.has(c.dirName));
222
252
  }
223
253
  tsToDate(ts) {
@@ -34,7 +34,7 @@ export default class SlackTransformer extends ChronicleTransformer {
34
34
  }
35
35
  // Add attachments if enabled and present
36
36
  if (includeAttachments && msg.files && msg.files.length > 0) {
37
- const attachments = this.buildAttachments(msg.files, record.context.basePath, conversation.dirName);
37
+ const attachments = this.buildAttachments(msg.files, record.context.basePath, conversation.dirName, record.context.attachmentPaths);
38
38
  if (attachments.length > 0) {
39
39
  message.contains = attachments;
40
40
  }
@@ -46,7 +46,8 @@ export default class SlackTransformer extends ChronicleTransformer {
46
46
  source: 'slack',
47
47
  timestamp: new Date(Number.parseFloat(msg.ts) * 1000),
48
48
  agent: sender,
49
- object: message,
49
+ // Sending makes the message, so it's the action's result.
50
+ result: message,
50
51
  };
51
52
  return [messageAction];
52
53
  }
@@ -113,15 +114,19 @@ export default class SlackTransformer extends ChronicleTransformer {
113
114
  return [];
114
115
  }
115
116
  }
116
- buildAttachments(files, basePath, dirName) {
117
+ buildAttachments(files, basePath, dirName, paths) {
117
118
  const attachments = [];
118
119
  for (const file of files) {
119
120
  // slackdump writes downloaded files to disk as `<fileId>-<name>`, in the
120
121
  // conversation's own `attachments/` dir (and a top-level aggregate one).
121
122
  // When the bytes are on disk we emit a contentPath so ingest captures
122
123
  // 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);
124
+ // would need an auth token to fetch). The extractor says where each is,
125
+ // a copy when the export is a zip; a record without that is looked up
126
+ // on disk under `basePath`.
127
+ const localPath = paths
128
+ ? paths[file.id]
129
+ : this.resolveAttachmentPath(basePath, dirName, file.id);
125
130
  const mediaObject = createMediaObject({
126
131
  mimeType: file.mimetype,
127
132
  source: 'slack',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronicle.app/slack",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [
@@ -22,9 +22,9 @@
22
22
  "type": "module",
23
23
  "description": "Chronicle plugin for Slack message history via slackdump export",
24
24
  "devDependencies": {
25
- "@chronicle.app/etl": "0.4.0",
26
- "@chronicle.app/schema": "0.4.0",
27
- "@chronicle.app/tsconfig": "0.4.0",
25
+ "@chronicle.app/etl": "0.5.0",
26
+ "@chronicle.app/schema": "0.5.0",
27
+ "@chronicle.app/tsconfig": "0.5.0",
28
28
  "@types/node": "^22.10.1",
29
29
  "typescript": "^5.7.2"
30
30
  },
@@ -61,6 +61,14 @@
61
61
  "strategies": {
62
62
  "archive": {
63
63
  "delivery": "export",
64
+ "layouts": [
65
+ {
66
+ "files": [
67
+ "channels.json",
68
+ "users.json"
69
+ ]
70
+ }
71
+ ],
64
72
  "recordTypes": [
65
73
  "messages"
66
74
  ],