@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 +21 -0
- package/README.md +80 -0
- package/dist/UserResolver.d.ts +17 -0
- package/dist/UserResolver.js +141 -0
- package/dist/connectors/SlackExtractor.d.ts +43 -0
- package/dist/connectors/SlackExtractor.js +226 -0
- package/dist/connectors/SlackTransformer.d.ts +18 -0
- package/dist/connectors/SlackTransformer.js +165 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/types/slack.d.ts +143 -0
- package/dist/types/slack.js +1 -0
- package/package.json +73 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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
|
+
}
|