@chronicle.app/slack 0.3.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):
|
|
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 `
|
|
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
|
|
package/dist/UserResolver.d.ts
CHANGED
|
@@ -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(
|
|
13
|
+
resolve(files: ExportFiles): Promise<Map<string, ResolvedUser>>;
|
|
13
14
|
private loadUsersJson;
|
|
14
15
|
private loadMpimCoOccurrence;
|
|
15
16
|
private scanMessagesForProfiles;
|
package/dist/UserResolver.js
CHANGED
|
@@ -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(
|
|
18
|
+
async resolve(files) {
|
|
21
19
|
// Phase 1: Load users.json (most complete source)
|
|
22
|
-
await this.loadUsersJson(
|
|
20
|
+
await this.loadUsersJson(files);
|
|
23
21
|
// Phase 2: Co-occurrence from mpims.json purpose fields
|
|
24
|
-
await this.loadMpimCoOccurrence(
|
|
22
|
+
await this.loadMpimCoOccurrence(files);
|
|
25
23
|
// Phase 3: Scan messages for user_profile fields
|
|
26
|
-
await this.scanMessagesForProfiles(
|
|
24
|
+
await this.scanMessagesForProfiles(files);
|
|
27
25
|
return this.users;
|
|
28
26
|
}
|
|
29
|
-
async loadUsersJson(
|
|
27
|
+
async loadUsersJson(files) {
|
|
30
28
|
try {
|
|
31
|
-
const raw = await
|
|
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(
|
|
45
|
+
async loadMpimCoOccurrence(files) {
|
|
48
46
|
try {
|
|
49
|
-
const raw = await
|
|
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(
|
|
86
|
+
async scanMessagesForProfiles(files) {
|
|
89
87
|
try {
|
|
90
|
-
const
|
|
91
|
-
|
|
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
|
|
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
|
|
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('
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
}, {
|
|
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 =
|
|
89
|
-
|
|
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
|
|
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
|
|
141
|
-
let
|
|
176
|
+
const messages = { count: 0, ...(options.byMonth && { byMonth: {} }) };
|
|
177
|
+
let conversations = 0;
|
|
142
178
|
for (const conversation of this.conversations) {
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
193
|
+
messages.measures = [{ key: 'conversations', label: 'conversations', value: conversations }];
|
|
194
|
+
return { messages };
|
|
165
195
|
}
|
|
166
|
-
async loadConversations(
|
|
196
|
+
async loadConversations() {
|
|
167
197
|
// Load channels
|
|
168
198
|
try {
|
|
169
|
-
const raw = await
|
|
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
|
|
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
|
|
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
|
|
219
|
-
const entries = await
|
|
220
|
-
const dirNames = new Set(entries.filter(e => e.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
26
|
-
"@chronicle.app/schema": "0.
|
|
27
|
-
"@chronicle.app/tsconfig": "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
|
],
|