slack-term 1.7.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 +127 -0
- package/SKILL.md +132 -0
- package/dist/cli.js +37 -0
- package/package.json +62 -0
- package/ts/cli.ts +1010 -0
- package/ts/format.ts +77 -0
- package/ts/profiles.ts +190 -0
- package/ts/slack-app.ts +243 -0
- package/ts/slack.ts +493 -0
package/ts/format.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Text-formatting helpers: mention/date-markup resolution, day grouping.
|
|
2
|
+
|
|
3
|
+
import { userName } from "./slack.ts";
|
|
4
|
+
|
|
5
|
+
export async function resolveMentions(
|
|
6
|
+
token: string,
|
|
7
|
+
text: string,
|
|
8
|
+
cache: Map<string, string>,
|
|
9
|
+
): Promise<string> {
|
|
10
|
+
let result = text;
|
|
11
|
+
const ids: string[] = [];
|
|
12
|
+
let search = result;
|
|
13
|
+
let searchOffset = 0;
|
|
14
|
+
while (true) {
|
|
15
|
+
const pos = search.indexOf("<@U", searchOffset);
|
|
16
|
+
if (pos === -1) break;
|
|
17
|
+
const rest = search.slice(pos + 2);
|
|
18
|
+
const endIdx = rest.search(/[>|]/);
|
|
19
|
+
const uid = endIdx === -1 ? rest : rest.slice(0, endIdx);
|
|
20
|
+
if (uid.length >= 9) ids.push(uid);
|
|
21
|
+
searchOffset = pos + 1;
|
|
22
|
+
}
|
|
23
|
+
for (const uid of ids) {
|
|
24
|
+
if (!cache.has(uid)) cache.set(uid, await userName(token, uid));
|
|
25
|
+
const display = cache.get(uid) ?? uid;
|
|
26
|
+
result = result.replaceAll(`<@${uid}>`, `@${display}`);
|
|
27
|
+
// Handle <@UID|label> form — drop label.
|
|
28
|
+
result = result.replace(new RegExp(`<@${uid}\\|[^>]*>`, "g"), `@${display}`);
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveDateMarkup(text: string): string {
|
|
34
|
+
// <!date^EPOCH^{format}|fallback> → formatted date (or fallback)
|
|
35
|
+
return text.replace(/<!date\^(\d+)\^[^|>]*(?:\|([^>]*))?>/g, (_m, epochStr, fallback) => {
|
|
36
|
+
const epoch = Number(epochStr);
|
|
37
|
+
if (!Number.isFinite(epoch)) return fallback ?? "date";
|
|
38
|
+
const d = new Date(epoch * 1000);
|
|
39
|
+
if (Number.isNaN(d.getTime())) return fallback ?? "date";
|
|
40
|
+
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
41
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
42
|
+
const dayName = days[d.getUTCDay()] ?? "";
|
|
43
|
+
const mon = months[d.getUTCMonth()] ?? "";
|
|
44
|
+
return `${dayName}, ${mon} ${String(d.getUTCDate()).padStart(2, "0")}, ${d.getUTCFullYear()}`;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function dayLabel(epochSec: number, now: Date = new Date()): string {
|
|
49
|
+
const d = new Date(epochSec * 1000);
|
|
50
|
+
const today = dateKey(now);
|
|
51
|
+
const yesterday = dateKey(new Date(now.getTime() - 86400_000));
|
|
52
|
+
const key = dateKey(d);
|
|
53
|
+
if (key === today) return "Today";
|
|
54
|
+
if (key === yesterday) return "Yesterday";
|
|
55
|
+
const weekday = d.toLocaleDateString("en-US", { weekday: "long" });
|
|
56
|
+
const month = d.toLocaleDateString("en-US", { month: "short" });
|
|
57
|
+
return `${weekday}, ${month} ${String(d.getDate()).padStart(2, "0")}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function dateKey(d: Date): string {
|
|
61
|
+
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function formatHm(epochSec: number): string {
|
|
65
|
+
const d = new Date(epochSec * 1000);
|
|
66
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatYmdHm(epochSec: number): string {
|
|
70
|
+
const d = new Date(epochSec * 1000);
|
|
71
|
+
const y = d.getFullYear();
|
|
72
|
+
const mo = String(d.getMonth() + 1).padStart(2, "0");
|
|
73
|
+
const da = String(d.getDate()).padStart(2, "0");
|
|
74
|
+
const h = String(d.getHours()).padStart(2, "0");
|
|
75
|
+
const mi = String(d.getMinutes()).padStart(2, "0");
|
|
76
|
+
return `${y}-${mo}-${da} ${h}:${mi}`;
|
|
77
|
+
}
|
package/ts/profiles.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Multi-workspace profile management.
|
|
2
|
+
// Profiles are stored in ~/.config/slack-cli/profiles.json.
|
|
3
|
+
//
|
|
4
|
+
// Workspace selection uses lockfiles:
|
|
5
|
+
// Local (cwd): .slack-cli/workspace — set with: slack workspace use <name>
|
|
6
|
+
// Global (home): ~/.slack-cli/workspace — set with: slack workspace use -g <name>
|
|
7
|
+
//
|
|
8
|
+
// Resolution order:
|
|
9
|
+
// 1. --workspace=<name> flag or SLACK_WORKSPACE env var
|
|
10
|
+
// 2. Local lockfile (.slack-cli/workspace in cwd)
|
|
11
|
+
// 3. Global lockfile (~/.slack-cli/workspace)
|
|
12
|
+
// 4. No profiles → fall back to SLACK_MCP_XOXP_TOKEN
|
|
13
|
+
// 5. Otherwise → throw (no silent defaults)
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
|
|
19
|
+
export type Profile = {
|
|
20
|
+
token: string;
|
|
21
|
+
team: string;
|
|
22
|
+
teamId: string;
|
|
23
|
+
url: string;
|
|
24
|
+
user: string;
|
|
25
|
+
cookie?: string; // xoxd session cookie for internal APIs (drafts, etc.)
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ProfileStore = {
|
|
29
|
+
profiles: Record<string, Profile>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function profilesPath(): string {
|
|
33
|
+
return join(homedir(), ".config", "slack-cli", "profiles.json");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function localLockfilePath(): string {
|
|
37
|
+
return join(process.cwd(), ".slack-cli", "workspace");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function globalLockfilePath(): string {
|
|
41
|
+
return join(homedir(), ".slack-cli", "workspace");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function load(): ProfileStore {
|
|
45
|
+
const path = profilesPath();
|
|
46
|
+
if (!existsSync(path)) return { profiles: {} };
|
|
47
|
+
return JSON.parse(readFileSync(path, "utf8")) as ProfileStore;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function save(store: ProfileStore): void {
|
|
51
|
+
const path = profilesPath();
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
writeFileSync(path, JSON.stringify(store, null, 2) + "\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readLockfile(path: string): string | undefined {
|
|
57
|
+
if (!existsSync(path)) return undefined;
|
|
58
|
+
return readFileSync(path, "utf8").trim() || undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeLockfile(path: string, name: string): void {
|
|
62
|
+
const dir = dirname(path);
|
|
63
|
+
mkdirSync(dir, { recursive: true });
|
|
64
|
+
// Protect the directory from accidental git commits.
|
|
65
|
+
const gi = join(dir, ".gitignore");
|
|
66
|
+
if (!existsSync(gi)) writeFileSync(gi, "*\n");
|
|
67
|
+
writeFileSync(path, name + "\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function listProfiles(): { name: string; profile: Profile; current: boolean }[] {
|
|
71
|
+
const store = load();
|
|
72
|
+
const local = readLockfile(localLockfilePath());
|
|
73
|
+
const global_ = readLockfile(globalLockfilePath());
|
|
74
|
+
const current = local ?? global_;
|
|
75
|
+
return Object.entries(store.profiles).map(([name, profile]) => ({
|
|
76
|
+
name,
|
|
77
|
+
profile,
|
|
78
|
+
current: name === current,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function addProfile(name: string, profile: Profile): void {
|
|
83
|
+
const store = load();
|
|
84
|
+
store.profiles[name] = profile;
|
|
85
|
+
save(store);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function setCookie(name: string, cookie: string): void {
|
|
89
|
+
const store = load();
|
|
90
|
+
if (!(name in store.profiles)) throw new Error(`Profile not found: ${name}`);
|
|
91
|
+
store.profiles[name]!.cookie = cookie;
|
|
92
|
+
save(store);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function removeProfile(name: string): void {
|
|
96
|
+
const store = load();
|
|
97
|
+
if (!(name in store.profiles)) throw new Error(`Profile not found: ${name}`);
|
|
98
|
+
delete store.profiles[name];
|
|
99
|
+
save(store);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function useProfile(name: string, global = false): void {
|
|
103
|
+
const store = load();
|
|
104
|
+
if (!(name in store.profiles)) throw new Error(`Profile not found: ${name}`);
|
|
105
|
+
if (global) {
|
|
106
|
+
writeLockfile(globalLockfilePath(), name);
|
|
107
|
+
} else {
|
|
108
|
+
writeLockfile(localLockfilePath(), name);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function resolveToken(workspaceFlag?: string): string {
|
|
113
|
+
const store = load();
|
|
114
|
+
const profiles = store.profiles;
|
|
115
|
+
const names = Object.keys(profiles);
|
|
116
|
+
const envToken = process.env.SLACK_MCP_XOXP_TOKEN;
|
|
117
|
+
|
|
118
|
+
// Conflict: env token and profiles are mutually exclusive
|
|
119
|
+
if (envToken && names.length > 0) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
"Both SLACK_MCP_XOXP_TOKEN and workspace profiles are configured — keep only one.\n" +
|
|
122
|
+
" • Unset SLACK_MCP_XOXP_TOKEN to use profiles\n" +
|
|
123
|
+
" • Or remove all profiles: slack workspace remove <name>",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Env token only (no profiles) — highest priority when unambiguous
|
|
128
|
+
if (envToken) return envToken;
|
|
129
|
+
|
|
130
|
+
// Explicit per-command selection
|
|
131
|
+
const selected = workspaceFlag ?? process.env.SLACK_WORKSPACE;
|
|
132
|
+
if (selected) {
|
|
133
|
+
const profile = profiles[selected];
|
|
134
|
+
if (!profile) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Workspace "${selected}" not found. Available: ${names.join(", ") || "(none)"}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return profile.token;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Local lockfile (cwd)
|
|
143
|
+
const localName = readLockfile(localLockfilePath());
|
|
144
|
+
if (localName) {
|
|
145
|
+
const profile = profiles[localName];
|
|
146
|
+
if (!profile) throw new Error(`Workspace "${localName}" (from .slack-cli/workspace) not found in profiles.`);
|
|
147
|
+
return profile.token;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Global lockfile (~/.slack-cli/workspace)
|
|
151
|
+
const globalName = readLockfile(globalLockfilePath());
|
|
152
|
+
if (globalName) {
|
|
153
|
+
const profile = profiles[globalName];
|
|
154
|
+
if (!profile) throw new Error(`Workspace "${globalName}" (from ~/.slack-cli/workspace) not found in profiles.`);
|
|
155
|
+
return profile.token;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// No profiles and no env token
|
|
159
|
+
if (names.length === 0) {
|
|
160
|
+
throw new Error("No profiles configured. Run: slack workspace add <name> <token> — or set SLACK_MCP_XOXP_TOKEN");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Profiles exist but none selected — never silently pick one
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Workspace not selected (${names.join(", ")} available).\n` +
|
|
166
|
+
` Select locally: slack workspace use <name> (writes .slack-cli/workspace)\n` +
|
|
167
|
+
` Select globally: slack workspace use -g <name> (writes ~/.slack-cli/workspace)`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Resolve the xoxd session cookie for the active workspace (best-effort). */
|
|
172
|
+
export function resolveCookie(workspaceFlag?: string): string | undefined {
|
|
173
|
+
const store = load();
|
|
174
|
+
const profiles = store.profiles;
|
|
175
|
+
const envToken = process.env.SLACK_MCP_XOXP_TOKEN;
|
|
176
|
+
|
|
177
|
+
// Env-only mode has no stored cookie
|
|
178
|
+
if (envToken) return process.env.SLACK_MCP_XOXD_COOKIE;
|
|
179
|
+
|
|
180
|
+
const selected = workspaceFlag ?? process.env.SLACK_WORKSPACE;
|
|
181
|
+
if (selected) return profiles[selected]?.cookie;
|
|
182
|
+
|
|
183
|
+
const localName = readLockfile(localLockfilePath());
|
|
184
|
+
if (localName) return profiles[localName]?.cookie;
|
|
185
|
+
|
|
186
|
+
const globalName = readLockfile(globalLockfilePath());
|
|
187
|
+
if (globalName) return profiles[globalName]?.cookie;
|
|
188
|
+
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
package/ts/slack-app.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Extract xoxc- tokens from the Slack desktop app's LevelDB (macOS/Linux/Windows).
|
|
2
|
+
// Reads raw .ldb/.log files with regex — works even while Slack is running (no exclusive lock).
|
|
3
|
+
// Also extracts the xoxd session cookie from the Slack Cookies SQLite database (macOS only).
|
|
4
|
+
|
|
5
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { pbkdf2Sync, createDecipheriv } from "node:crypto";
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
export type SlackAppSession = {
|
|
12
|
+
token: string; // xoxc-...
|
|
13
|
+
cookie?: string; // xoxd-... (macOS only, from Cookies SQLite)
|
|
14
|
+
teamId: string;
|
|
15
|
+
teamName?: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function leveldbPath(): string {
|
|
20
|
+
const home = homedir();
|
|
21
|
+
if (process.platform === "darwin") {
|
|
22
|
+
return join(home, "Library", "Application Support", "Slack", "Local Storage", "leveldb");
|
|
23
|
+
}
|
|
24
|
+
if (process.platform === "linux") {
|
|
25
|
+
return join(home, ".config", "Slack", "Local Storage", "leveldb");
|
|
26
|
+
}
|
|
27
|
+
if (process.platform === "win32") {
|
|
28
|
+
const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
|
|
29
|
+
return join(appData, "Slack", "Local Storage", "leveldb");
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cookiesDbPath(): string {
|
|
35
|
+
const home = homedir();
|
|
36
|
+
if (process.platform === "darwin") {
|
|
37
|
+
return join(home, "Library", "Application Support", "Slack", "Cookies");
|
|
38
|
+
}
|
|
39
|
+
// Linux/Windows: cookie extraction not implemented
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Encrypted cookie format (macOS Electron/Chromium v10):
|
|
44
|
+
// bytes [0..3) = "v10"
|
|
45
|
+
// bytes [3..19) = 16-byte prefix (version/key-id, shared across all cookies)
|
|
46
|
+
// bytes [19..35) = 16-byte IV (shared across all cookies in this profile)
|
|
47
|
+
// bytes [35..) = AES-128-CBC ciphertext
|
|
48
|
+
// Key = PBKDF2(keychain_password, "saltysalt", 1003, 16, SHA1)
|
|
49
|
+
function decryptChromeCookie(encryptedValue: Buffer, key: Buffer): string {
|
|
50
|
+
if (encryptedValue.length < 35 || encryptedValue.slice(0, 3).toString() !== "v10") {
|
|
51
|
+
throw new Error("Not a v10 encrypted cookie");
|
|
52
|
+
}
|
|
53
|
+
const iv = encryptedValue.slice(19, 35);
|
|
54
|
+
const ciphertext = encryptedValue.slice(35);
|
|
55
|
+
const decipher = createDecipheriv("aes-128-cbc", key, iv);
|
|
56
|
+
decipher.setAutoPadding(true);
|
|
57
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Extract the xoxd session cookie from Slack's Cookies SQLite (macOS only).
|
|
61
|
+
* Returns undefined if unavailable or decryption fails. */
|
|
62
|
+
export function extractXoxd(): string | undefined {
|
|
63
|
+
if (process.platform !== "darwin") return undefined;
|
|
64
|
+
|
|
65
|
+
const dbPath = cookiesDbPath();
|
|
66
|
+
if (!existsSync(dbPath)) return undefined;
|
|
67
|
+
|
|
68
|
+
let keychainPw: string;
|
|
69
|
+
try {
|
|
70
|
+
keychainPw = execSync(
|
|
71
|
+
`security find-generic-password -w -s "Slack Safe Storage" -a "Slack Key"`,
|
|
72
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
73
|
+
).trimEnd();
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const aesKey = pbkdf2Sync(keychainPw, "saltysalt", 1003, 16, "sha1");
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Use dynamic import so bun:sqlite doesn't break on non-bun runtimes
|
|
81
|
+
const { default: Database } = require("bun:sqlite") as typeof import("bun:sqlite");
|
|
82
|
+
const db = new Database(dbPath, { readonly: true });
|
|
83
|
+
const row = db
|
|
84
|
+
.prepare("SELECT encrypted_value FROM cookies WHERE name='d' AND host_key LIKE '%slack%'")
|
|
85
|
+
.get() as { encrypted_value: Uint8Array } | null;
|
|
86
|
+
db.close();
|
|
87
|
+
if (!row) return undefined;
|
|
88
|
+
return decryptChromeCookie(Buffer.from(row.encrypted_value), aesKey);
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Scan raw LevelDB files for xoxc- tokens without opening the DB exclusively.
|
|
95
|
+
// Works while Slack is running.
|
|
96
|
+
//
|
|
97
|
+
// Strategy:
|
|
98
|
+
// 1. .log files (write-ahead log): values are stored as readable JSON strings.
|
|
99
|
+
// Scan for "token":"xoxc-..." to get complete, clean tokens + workspace URL.
|
|
100
|
+
// 2. .ldb files (sorted tables): values are length-prefixed with binary framing
|
|
101
|
+
// bytes that can split the token mid-segment. Use gap-bridging as fallback.
|
|
102
|
+
export async function extractSessions(): Promise<SlackAppSession[]> {
|
|
103
|
+
const dbPath = leveldbPath();
|
|
104
|
+
if (!existsSync(dbPath)) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Slack desktop app LevelDB not found at:\n ${dbPath}\nIs Slack installed and opened at least once?`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const files = readdirSync(dbPath).filter((f) => f.endsWith(".ldb") || f.endsWith(".log"));
|
|
111
|
+
if (files.length === 0) throw new Error(`No LevelDB data files found in ${dbPath}`);
|
|
112
|
+
|
|
113
|
+
const sessions = new Map<string, SlackAppSession>();
|
|
114
|
+
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
let content: string;
|
|
117
|
+
try {
|
|
118
|
+
content = readFileSync(join(dbPath, file), "latin1");
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (file.endsWith(".log")) {
|
|
124
|
+
// .log files store JSON values verbatim — extract complete token + URL + name in one pass.
|
|
125
|
+
for (const m of content.matchAll(/"token":"(xoxc-[^"]+)"/g)) {
|
|
126
|
+
const token = m[1]!;
|
|
127
|
+
const teamId = token.split("-")[1] ?? "";
|
|
128
|
+
if (!teamId || token.length < 40) continue;
|
|
129
|
+
|
|
130
|
+
// Look for workspace metadata near this token (within ±2KB)
|
|
131
|
+
const start = Math.max(0, m.index! - 2000);
|
|
132
|
+
const end = Math.min(content.length, m.index! + 2000);
|
|
133
|
+
const ctx = content.slice(start, end);
|
|
134
|
+
const urlMatch = ctx.match(/"url":"(https:\/\/[a-z0-9-]+\.slack\.com\/)"/);
|
|
135
|
+
const nameMatch = ctx.match(/"(?:team_name|name)":"([^"]{2,60})"/);
|
|
136
|
+
|
|
137
|
+
const existing = sessions.get(teamId);
|
|
138
|
+
if (!existing || token.length > existing.token.length) {
|
|
139
|
+
const entry: SlackAppSession = { token, teamId };
|
|
140
|
+
const url = urlMatch?.[1] ?? existing?.url;
|
|
141
|
+
const teamName = nameMatch?.[1] ?? existing?.teamName;
|
|
142
|
+
if (url) entry.url = url;
|
|
143
|
+
if (teamName) entry.teamName = teamName;
|
|
144
|
+
sessions.set(teamId, entry);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
// .ldb files: binary framing bytes split the token mid-segment.
|
|
149
|
+
// Match the first 3 numeric segments, then bridge over binary bytes to find the rest.
|
|
150
|
+
for (const m of content.matchAll(/xoxc-(\d+)-(\d+)-(\d+)/g)) {
|
|
151
|
+
const teamId = m[1]!;
|
|
152
|
+
// Skip if already found in a .log file (prefer clean .log data)
|
|
153
|
+
if (sessions.has(teamId)) continue;
|
|
154
|
+
|
|
155
|
+
let i = m.index! + m[0].length;
|
|
156
|
+
const limit = Math.min(i + 10, content.length);
|
|
157
|
+
while (i < limit && (content.charCodeAt(i) < 0x30 || content.charCodeAt(i) > 0x39)) i++;
|
|
158
|
+
|
|
159
|
+
let seg3tail = "";
|
|
160
|
+
while (i < content.length && content.charCodeAt(i) >= 0x30 && content.charCodeAt(i) <= 0x39) {
|
|
161
|
+
seg3tail += content[i++];
|
|
162
|
+
}
|
|
163
|
+
if (content[i] !== "-") continue;
|
|
164
|
+
i++;
|
|
165
|
+
let hex = "";
|
|
166
|
+
while (i < content.length) {
|
|
167
|
+
const code = content.charCodeAt(i);
|
|
168
|
+
if ((code >= 0x30 && code <= 0x39) || (code >= 0x61 && code <= 0x66)) {
|
|
169
|
+
hex += content[i++];
|
|
170
|
+
} else break;
|
|
171
|
+
}
|
|
172
|
+
if (hex.length < 20) continue;
|
|
173
|
+
|
|
174
|
+
const token = `${m[0]}${seg3tail}-${hex}`;
|
|
175
|
+
sessions.set(teamId, { token, teamId });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Second pass: for sessions still missing a name, search all file content
|
|
181
|
+
// (printable-transformed) for team_name/name near the numeric team ID, then
|
|
182
|
+
// fall back to a title-cased URL slug.
|
|
183
|
+
const allFiles = readdirSync(dbPath).filter((f) => f.endsWith(".ldb") || f.endsWith(".log"));
|
|
184
|
+
const allContent = allFiles
|
|
185
|
+
.map((f) => {
|
|
186
|
+
try {
|
|
187
|
+
// Replace non-printable bytes with spaces to expose readable text in binary ldb frames.
|
|
188
|
+
return readFileSync(join(dbPath, f), "latin1").replace(/[^\x20-\x7e]/g, " ");
|
|
189
|
+
} catch {
|
|
190
|
+
return "";
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
.join(" ");
|
|
194
|
+
|
|
195
|
+
for (const session of sessions.values()) {
|
|
196
|
+
if (session.teamName) continue;
|
|
197
|
+
|
|
198
|
+
// Strategy 1: search for "team_name":"..." or "name":"..." near the numeric team ID.
|
|
199
|
+
const idIdx = allContent.indexOf(session.teamId);
|
|
200
|
+
if (idIdx !== -1) {
|
|
201
|
+
const start = Math.max(0, idIdx - 500);
|
|
202
|
+
const end = Math.min(allContent.length, idIdx + 500);
|
|
203
|
+
const ctx = allContent.slice(start, end);
|
|
204
|
+
const nameMatch = ctx.match(/"(?:team_name|name)":"([^"]{2,60})"/);
|
|
205
|
+
if (nameMatch?.[1]) {
|
|
206
|
+
session.teamName = nameMatch[1];
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Strategy 2: search for the workspace URL slug and look for "name":"..." near it.
|
|
212
|
+
if (!session.teamName && session.url) {
|
|
213
|
+
const slug = session.url.match(/https:\/\/([a-z0-9-]+)\.slack\.com\//)?.[1];
|
|
214
|
+
if (slug) {
|
|
215
|
+
const slugIdx = allContent.indexOf(slug);
|
|
216
|
+
if (slugIdx !== -1) {
|
|
217
|
+
const start = Math.max(0, slugIdx - 500);
|
|
218
|
+
const end = Math.min(allContent.length, slugIdx + 500);
|
|
219
|
+
const ctx = allContent.slice(start, end);
|
|
220
|
+
const nameMatch = ctx.match(/"(?:team_name|name)":"([^"]{2,60})"/);
|
|
221
|
+
if (nameMatch?.[1]) {
|
|
222
|
+
session.teamName = nameMatch[1];
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Strategy 3: title-case the slug as a last resort.
|
|
228
|
+
session.teamName = slug
|
|
229
|
+
.split("-")
|
|
230
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
231
|
+
.join(" ");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Attach xoxd cookie to all sessions (shared — one Slack desktop app, one cookie jar)
|
|
237
|
+
const xoxd = extractXoxd();
|
|
238
|
+
const result = [...sessions.values()];
|
|
239
|
+
if (xoxd) {
|
|
240
|
+
for (const s of result) s.cookie = xoxd;
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
}
|