wacrawl-dashboard 0.1.2 → 0.2.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 +220 -105
- package/apps/api/dist/demo/demoArchive.js +371 -0
- package/apps/api/dist/demo/demoCast.js +138 -0
- package/apps/api/dist/demo/demoMedia.js +108 -0
- package/apps/api/dist/index.js +109 -29
- package/apps/api/dist/lib/archiveSchema.js +57 -0
- package/apps/api/dist/lib/archiveSync.js +151 -0
- package/apps/api/dist/lib/chatKind.js +41 -0
- package/apps/api/dist/lib/displayName.js +20 -0
- package/apps/api/dist/lib/mediaFsPath.js +27 -0
- package/apps/api/dist/lib/percentile.js +23 -0
- package/apps/api/dist/lib/query.js +33 -7
- package/apps/api/dist/lib/rollupDb.js +253 -0
- package/apps/api/dist/lib/searchFilters.js +141 -0
- package/apps/api/dist/lib/selfIdentity.js +42 -0
- package/apps/api/dist/lib/statsCache.js +83 -0
- package/apps/api/dist/lib/timezone.js +102 -0
- package/apps/api/dist/lib/wordCloudUsefulStopWords.js +36 -3
- package/apps/api/dist/pathsResolve.js +17 -3
- package/apps/api/dist/routes/data.js +141 -77
- package/apps/api/dist/routes/settings.js +3 -0
- package/apps/api/dist/routes/stats/profiles.js +522 -0
- package/apps/api/dist/routes/stats/reports.js +555 -0
- package/apps/api/dist/routes/stats/shared.js +75 -0
- package/apps/api/dist/routes/stats.js +273 -323
- package/apps/api/dist/routes/sync.js +60 -0
- package/apps/api/dist/scripts/generateDemoArchive.js +24 -0
- package/apps/api/package.json +31 -0
- package/apps/web/dist/apple-touch-icon.png +0 -0
- package/apps/web/dist/assets/charts-BMGh5n-d.js +36 -0
- package/apps/web/dist/assets/icons-BdElrSaU.js +1 -0
- package/apps/web/dist/assets/index-Bb8Zj42D.css +1 -0
- package/apps/web/dist/assets/index-wrxENU1e.js +9 -0
- package/apps/web/dist/assets/react-D3RL6ob4.js +3 -0
- package/apps/web/dist/favicon-16x16.png +0 -0
- package/apps/web/dist/favicon-32x32.png +0 -0
- package/apps/web/dist/favicon-512x512.png +0 -0
- package/apps/web/dist/favicon.ico +0 -0
- package/apps/web/dist/favicon.svg +59 -0
- package/apps/web/dist/index.html +11 -2
- package/bin/wacrawl-dashboard.js +64 -15
- package/package.json +20 -4
- package/apps/web/dist/assets/index-CFh_Wz98.js +0 -43
- package/apps/web/dist/assets/index-DJiLhgJZ.css +0 -1
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import { getDb } from '../../db.js';
|
|
2
|
+
import { tableExists } from '../../lib/chatKind.js';
|
|
3
|
+
import { canonicalJidSql, cleanDisplayNameSql, contactDisplayNameSql, contactLeftJoins } from '../../lib/displayName.js';
|
|
4
|
+
import { percentile } from '../../lib/percentile.js';
|
|
5
|
+
import { nameLikePattern, parseLimit, scanCoverage, unixSecondsToIso } from '../../lib/query.js';
|
|
6
|
+
import { resolveSelfJids } from '../../lib/selfIdentity.js';
|
|
7
|
+
import { dateInTimezone, firstQueryString, hourInTimezone, monthInTimezone, resolveStatsTimeZone, tsModifierSql, } from '../../lib/timezone.js';
|
|
8
|
+
import { ChatNotFoundError, DAY_SECONDS, EMOJI_REGEX, isNoiseToken, NotAGroupError, SESSION_GAP_SECONDS, summarizeGaps, topCounted, WORD_TOKEN_RE, } from './shared.js';
|
|
9
|
+
/** Messages of one chat pulled in for the profile's per-message analysis. */
|
|
10
|
+
const CONTACT_PROFILE_MAX_ROWS = 100_000;
|
|
11
|
+
/**
|
|
12
|
+
* Everything one relationship looks like, in a single response.
|
|
13
|
+
*/
|
|
14
|
+
export function getContactProfile(params, db = getDb()) {
|
|
15
|
+
const jid = firstQueryString(params.jid);
|
|
16
|
+
if (!jid)
|
|
17
|
+
throw new ChatNotFoundError('');
|
|
18
|
+
const limit = parseLimit(params.limit, 15, 100);
|
|
19
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
20
|
+
const now = Math.floor(Date.now() / 1000);
|
|
21
|
+
const identity = db.prepare(`
|
|
22
|
+
SELECT
|
|
23
|
+
chats.jid AS jid,
|
|
24
|
+
chats.kind AS kind,
|
|
25
|
+
COALESCE(
|
|
26
|
+
${cleanDisplayNameSql('chats.name')},
|
|
27
|
+
${contactDisplayNameSql('chat_contacts')},
|
|
28
|
+
${cleanDisplayNameSql('chats.jid')},
|
|
29
|
+
'Unknown'
|
|
30
|
+
) AS name,
|
|
31
|
+
COALESCE(chat_contacts_jid.phone, chat_contacts_lid.phone) AS phone
|
|
32
|
+
FROM chats
|
|
33
|
+
${contactLeftJoins('chat_contacts', 'chats.jid')}
|
|
34
|
+
WHERE chats.jid = @jid
|
|
35
|
+
`).get({ jid });
|
|
36
|
+
if (!identity)
|
|
37
|
+
throw new ChatNotFoundError(jid);
|
|
38
|
+
const totalsRow = db.prepare(`
|
|
39
|
+
SELECT
|
|
40
|
+
COUNT(*) AS messages,
|
|
41
|
+
SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sentByMe,
|
|
42
|
+
SUM(CASE WHEN media_type IS NOT NULL AND media_type <> '' THEN 1 ELSE 0 END) AS media,
|
|
43
|
+
MIN(ts) AS firstTs,
|
|
44
|
+
MAX(ts) AS lastTs,
|
|
45
|
+
COUNT(DISTINCT date(${tsModifierSql(timeZone)})) AS activeDays
|
|
46
|
+
FROM messages
|
|
47
|
+
WHERE chat_jid = @jid
|
|
48
|
+
`).get({ jid });
|
|
49
|
+
const mediaBreakdown = db.prepare(`
|
|
50
|
+
SELECT media_type AS mediaType, COUNT(*) AS count, COALESCE(SUM(media_size), 0) AS totalBytes
|
|
51
|
+
FROM messages
|
|
52
|
+
WHERE chat_jid = @jid AND media_type IS NOT NULL AND media_type <> ''
|
|
53
|
+
GROUP BY media_type
|
|
54
|
+
ORDER BY count DESC, mediaType ASC
|
|
55
|
+
`).all({ jid }).map((row) => ({
|
|
56
|
+
mediaType: row.mediaType,
|
|
57
|
+
count: row.count,
|
|
58
|
+
totalBytes: row.totalBytes ?? 0,
|
|
59
|
+
}));
|
|
60
|
+
const messages = db.prepare(`
|
|
61
|
+
SELECT ts, from_me, text, media_type
|
|
62
|
+
FROM (
|
|
63
|
+
SELECT ts, from_me, text, media_type, rowid
|
|
64
|
+
FROM messages
|
|
65
|
+
WHERE chat_jid = @jid
|
|
66
|
+
ORDER BY ts DESC, rowid DESC
|
|
67
|
+
LIMIT ${CONTACT_PROFILE_MAX_ROWS}
|
|
68
|
+
)
|
|
69
|
+
ORDER BY ts ASC, rowid ASC
|
|
70
|
+
`).all({ jid });
|
|
71
|
+
const monthly = new Map();
|
|
72
|
+
const hourCounts = new Array(24).fill(0);
|
|
73
|
+
const wordCounts = new Map();
|
|
74
|
+
const emojiCounts = new Map();
|
|
75
|
+
const mineGaps = [];
|
|
76
|
+
const theirGaps = [];
|
|
77
|
+
const initiation = { byMe: 0, byThem: 0 };
|
|
78
|
+
const lastWord = { mine: 0, theirs: 0 };
|
|
79
|
+
let longestSilence = null;
|
|
80
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
81
|
+
const message = messages[i];
|
|
82
|
+
const previous = i > 0 ? messages[i - 1] : null;
|
|
83
|
+
const month = monthInTimezone(message.ts, timeZone);
|
|
84
|
+
let bucket = monthly.get(month);
|
|
85
|
+
if (!bucket) {
|
|
86
|
+
bucket = { sent: 0, received: 0 };
|
|
87
|
+
monthly.set(month, bucket);
|
|
88
|
+
}
|
|
89
|
+
if (message.from_me)
|
|
90
|
+
bucket.sent += 1;
|
|
91
|
+
else
|
|
92
|
+
bucket.received += 1;
|
|
93
|
+
hourCounts[hourInTimezone(message.ts, timeZone)] += 1;
|
|
94
|
+
for (const term of (message.text ?? '').toLowerCase().match(WORD_TOKEN_RE) ?? []) {
|
|
95
|
+
if (isNoiseToken(term))
|
|
96
|
+
continue;
|
|
97
|
+
wordCounts.set(term, (wordCounts.get(term) ?? 0) + 1);
|
|
98
|
+
}
|
|
99
|
+
for (const emoji of (message.text ?? '').match(EMOJI_REGEX) ?? []) {
|
|
100
|
+
emojiCounts.set(emoji, (emojiCounts.get(emoji) ?? 0) + 1);
|
|
101
|
+
}
|
|
102
|
+
if (previous === null || message.ts - previous.ts >= SESSION_GAP_SECONDS) {
|
|
103
|
+
if (message.from_me)
|
|
104
|
+
initiation.byMe += 1;
|
|
105
|
+
else
|
|
106
|
+
initiation.byThem += 1;
|
|
107
|
+
if (previous !== null) {
|
|
108
|
+
if (previous.from_me)
|
|
109
|
+
lastWord.mine += 1;
|
|
110
|
+
else
|
|
111
|
+
lastWord.theirs += 1;
|
|
112
|
+
const gapDays = Math.floor((message.ts - previous.ts) / DAY_SECONDS);
|
|
113
|
+
if (longestSilence === null || gapDays > longestSilence.days) {
|
|
114
|
+
longestSilence = {
|
|
115
|
+
days: gapDays,
|
|
116
|
+
startedAt: unixSecondsToIso(previous.ts),
|
|
117
|
+
endedAt: unixSecondsToIso(message.ts),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else if (previous.from_me !== message.from_me) {
|
|
123
|
+
(message.from_me ? mineGaps : theirGaps).push(message.ts - previous.ts);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (messages.length > 0) {
|
|
127
|
+
const final = messages[messages.length - 1];
|
|
128
|
+
if (final.from_me)
|
|
129
|
+
lastWord.mine += 1;
|
|
130
|
+
else
|
|
131
|
+
lastWord.theirs += 1;
|
|
132
|
+
}
|
|
133
|
+
const side = (gaps) => {
|
|
134
|
+
const { median, p90 } = summarizeGaps(gaps);
|
|
135
|
+
return { count: gaps.length, medianSeconds: median, p90Seconds: p90 };
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
jid: identity.jid,
|
|
139
|
+
name: identity.name ?? identity.jid,
|
|
140
|
+
kind: identity.kind,
|
|
141
|
+
phone: identity.phone?.trim() ? identity.phone.trim() : null,
|
|
142
|
+
totals: {
|
|
143
|
+
messages: totalsRow.messages,
|
|
144
|
+
sentByMe: totalsRow.sentByMe ?? 0,
|
|
145
|
+
sentByThem: totalsRow.messages - (totalsRow.sentByMe ?? 0),
|
|
146
|
+
media: totalsRow.media ?? 0,
|
|
147
|
+
firstMessageAt: unixSecondsToIso(totalsRow.firstTs),
|
|
148
|
+
lastMessageAt: unixSecondsToIso(totalsRow.lastTs),
|
|
149
|
+
daysSinceLastMessage: totalsRow.lastTs == null ? null : Math.floor((now - totalsRow.lastTs) / DAY_SECONDS),
|
|
150
|
+
activeDays: totalsRow.activeDays,
|
|
151
|
+
},
|
|
152
|
+
monthlyVolume: [...monthly.entries()]
|
|
153
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
154
|
+
.map(([month, counts]) => ({ month, ...counts })),
|
|
155
|
+
hourOfDay: hourCounts
|
|
156
|
+
.map((count, hour) => ({ hour, count }))
|
|
157
|
+
.filter((point) => point.count > 0),
|
|
158
|
+
latency: { mine: side(mineGaps), theirs: side(theirGaps) },
|
|
159
|
+
initiation,
|
|
160
|
+
lastWord,
|
|
161
|
+
longestSilence,
|
|
162
|
+
mediaBreakdown,
|
|
163
|
+
topWords: topCounted(wordCounts, limit, (text, value) => ({ text, value })),
|
|
164
|
+
topEmojis: topCounted(emojiCounts, limit, (emoji, count) => ({ emoji, count })),
|
|
165
|
+
scan: scanCoverage(messages.length, CONTACT_PROFILE_MAX_ROWS),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** Messages of one group pulled in for the per-message analysis. */
|
|
169
|
+
const GROUP_PROFILE_MAX_ROWS = 100_000;
|
|
170
|
+
/** Roster rows are cheap, but a 900-member group should not ship 900 objects. */
|
|
171
|
+
const GROUP_QUIET_MEMBER_LIMIT = 50;
|
|
172
|
+
function isNamed(member) {
|
|
173
|
+
return member.name !== null && member.name !== member.jid;
|
|
174
|
+
}
|
|
175
|
+
function mergeRoster(rows) {
|
|
176
|
+
const merged = new Map();
|
|
177
|
+
for (const row of rows) {
|
|
178
|
+
const existing = merged.get(row.jid);
|
|
179
|
+
if (!existing) {
|
|
180
|
+
merged.set(row.jid, {
|
|
181
|
+
jid: row.jid,
|
|
182
|
+
rawJids: [row.rawJid],
|
|
183
|
+
name: row.name,
|
|
184
|
+
isAdmin: row.isAdmin === 1,
|
|
185
|
+
isActive: row.isActive === 1,
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
existing.rawJids.push(row.rawJid);
|
|
190
|
+
existing.name ??= row.name;
|
|
191
|
+
existing.isAdmin ||= row.isAdmin === 1;
|
|
192
|
+
existing.isActive ||= row.isActive === 1;
|
|
193
|
+
}
|
|
194
|
+
return merged;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Everything one group looks like, in a single response.
|
|
198
|
+
*/
|
|
199
|
+
export function getGroupProfile(params, db = getDb()) {
|
|
200
|
+
const jid = firstQueryString(params.jid);
|
|
201
|
+
if (!jid)
|
|
202
|
+
throw new ChatNotFoundError('');
|
|
203
|
+
const limit = parseLimit(params.limit, 15, 100);
|
|
204
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
205
|
+
const now = Math.floor(Date.now() / 1000);
|
|
206
|
+
const hasRoster = tableExists(db, 'group_participants');
|
|
207
|
+
const hasGroupsTable = tableExists(db, 'groups');
|
|
208
|
+
const chat = db.prepare(`
|
|
209
|
+
SELECT
|
|
210
|
+
chats.jid AS jid,
|
|
211
|
+
chats.kind AS kind,
|
|
212
|
+
COALESCE(${cleanDisplayNameSql('chats.name')}, ${cleanDisplayNameSql('chats.jid')}, 'Unknown') AS name
|
|
213
|
+
FROM chats
|
|
214
|
+
WHERE chats.jid = @jid
|
|
215
|
+
`).get({ jid });
|
|
216
|
+
if (!chat)
|
|
217
|
+
throw new ChatNotFoundError(jid);
|
|
218
|
+
if (chat.kind !== 'group')
|
|
219
|
+
throw new NotAGroupError(jid);
|
|
220
|
+
const groupRow = hasGroupsTable
|
|
221
|
+
? db.prepare(`
|
|
222
|
+
SELECT jid, ${cleanDisplayNameSql('name')} AS name, owner_jid AS ownerJid, created_at AS createdAt
|
|
223
|
+
FROM groups
|
|
224
|
+
WHERE jid = @jid
|
|
225
|
+
`).get({ jid })
|
|
226
|
+
: undefined;
|
|
227
|
+
const totalsRow = db.prepare(`
|
|
228
|
+
SELECT
|
|
229
|
+
COUNT(*) AS messages,
|
|
230
|
+
SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sentByMe,
|
|
231
|
+
SUM(CASE WHEN media_type IS NOT NULL AND media_type <> '' THEN 1 ELSE 0 END) AS media,
|
|
232
|
+
MIN(ts) AS firstTs,
|
|
233
|
+
MAX(ts) AS lastTs,
|
|
234
|
+
COUNT(DISTINCT date(${tsModifierSql(timeZone)})) AS activeDays
|
|
235
|
+
FROM messages
|
|
236
|
+
WHERE chat_jid = @jid
|
|
237
|
+
`).get({ jid });
|
|
238
|
+
const mediaBreakdown = db.prepare(`
|
|
239
|
+
SELECT media_type AS mediaType, COUNT(*) AS count, COALESCE(SUM(media_size), 0) AS totalBytes
|
|
240
|
+
FROM messages
|
|
241
|
+
WHERE chat_jid = @jid AND media_type IS NOT NULL AND media_type <> ''
|
|
242
|
+
GROUP BY media_type
|
|
243
|
+
ORDER BY count DESC, mediaType ASC
|
|
244
|
+
`).all({ jid }).map((row) => ({
|
|
245
|
+
mediaType: row.mediaType,
|
|
246
|
+
count: row.count,
|
|
247
|
+
totalBytes: row.totalBytes ?? 0,
|
|
248
|
+
}));
|
|
249
|
+
const speakers = db.prepare(`
|
|
250
|
+
SELECT
|
|
251
|
+
${canonicalJidSql('sender_contacts', 'messages.sender_jid')} AS jid,
|
|
252
|
+
${contactDisplayNameSql('sender_contacts')} AS contactName,
|
|
253
|
+
${cleanDisplayNameSql('MAX(messages.sender_name)')} AS pushName,
|
|
254
|
+
COUNT(*) AS messageCount,
|
|
255
|
+
MIN(messages.ts) AS firstTs,
|
|
256
|
+
MAX(messages.ts) AS lastTs
|
|
257
|
+
FROM messages
|
|
258
|
+
${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
|
|
259
|
+
WHERE messages.chat_jid = @jid
|
|
260
|
+
AND messages.from_me = 0
|
|
261
|
+
AND messages.sender_jid IS NOT NULL
|
|
262
|
+
AND messages.sender_jid <> messages.chat_jid
|
|
263
|
+
GROUP BY 1
|
|
264
|
+
ORDER BY messageCount DESC
|
|
265
|
+
`).all({ jid });
|
|
266
|
+
const roster = hasRoster
|
|
267
|
+
? db.prepare(`
|
|
268
|
+
SELECT
|
|
269
|
+
${canonicalJidSql('member_contacts', 'group_participants.user_jid')} AS jid,
|
|
270
|
+
group_participants.user_jid AS rawJid,
|
|
271
|
+
COALESCE(
|
|
272
|
+
${cleanDisplayNameSql('group_participants.contact_name')},
|
|
273
|
+
${cleanDisplayNameSql('group_participants.first_name')},
|
|
274
|
+
${contactDisplayNameSql('member_contacts')},
|
|
275
|
+
${cleanDisplayNameSql('group_participants.user_jid')}
|
|
276
|
+
) AS name,
|
|
277
|
+
group_participants.is_admin AS isAdmin,
|
|
278
|
+
group_participants.is_active AS isActive
|
|
279
|
+
FROM group_participants
|
|
280
|
+
${contactLeftJoins('member_contacts', 'group_participants.user_jid')}
|
|
281
|
+
WHERE group_participants.group_jid = @jid
|
|
282
|
+
`).all({ jid })
|
|
283
|
+
: [];
|
|
284
|
+
const selfJids = hasRoster ? resolveSelfJids(db) : new Set();
|
|
285
|
+
const rosterByJid = mergeRoster(roster);
|
|
286
|
+
const members = [...rosterByJid.values()];
|
|
287
|
+
const spokeJids = new Set(speakers.map((speaker) => speaker.jid).filter((value) => value !== null));
|
|
288
|
+
const activeMembers = members.filter((member) => member.isActive);
|
|
289
|
+
const activeOthers = activeMembers.filter((member) => !member.rawJids.some((raw) => selfJids.has(raw)) && !selfJids.has(member.jid));
|
|
290
|
+
const receivedTotal = speakers.reduce((sum, speaker) => sum + speaker.messageCount, 0);
|
|
291
|
+
const share = (count) => (receivedTotal === 0 ? 0 : count / receivedTotal);
|
|
292
|
+
const topParticipants = speakers.slice(0, limit).map((speaker) => {
|
|
293
|
+
const member = speaker.jid ? rosterByJid.get(speaker.jid) : undefined;
|
|
294
|
+
return {
|
|
295
|
+
jid: speaker.jid ?? '',
|
|
296
|
+
name: speaker.contactName ?? member?.name ?? speaker.pushName ?? speaker.jid ?? 'Unknown',
|
|
297
|
+
isAdmin: member?.isAdmin ?? false,
|
|
298
|
+
isActive: member?.isActive ?? false,
|
|
299
|
+
messageCount: speaker.messageCount,
|
|
300
|
+
share: share(speaker.messageCount),
|
|
301
|
+
firstMessageAt: unixSecondsToIso(speaker.firstTs),
|
|
302
|
+
lastMessageAt: unixSecondsToIso(speaker.lastTs),
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
const quiet = activeOthers.filter((member) => !spokeJids.has(member.jid));
|
|
306
|
+
const quietMembers = quiet
|
|
307
|
+
.slice()
|
|
308
|
+
.sort((a, b) => Number(b.isAdmin) - Number(a.isAdmin)
|
|
309
|
+
|| Number(isNamed(b)) - Number(isNamed(a))
|
|
310
|
+
|| (a.name ?? '').localeCompare(b.name ?? ''))
|
|
311
|
+
.slice(0, GROUP_QUIET_MEMBER_LIMIT)
|
|
312
|
+
.map((member) => ({ jid: member.jid, name: member.name ?? member.jid, isAdmin: member.isAdmin }));
|
|
313
|
+
const messages = db.prepare(`
|
|
314
|
+
SELECT ts, from_me, text, media_type
|
|
315
|
+
FROM (
|
|
316
|
+
SELECT ts, from_me, text, media_type, rowid
|
|
317
|
+
FROM messages
|
|
318
|
+
WHERE chat_jid = @jid
|
|
319
|
+
ORDER BY ts DESC, rowid DESC
|
|
320
|
+
LIMIT ${GROUP_PROFILE_MAX_ROWS}
|
|
321
|
+
)
|
|
322
|
+
ORDER BY ts ASC, rowid ASC
|
|
323
|
+
`).all({ jid });
|
|
324
|
+
const monthly = new Map();
|
|
325
|
+
const hourCounts = new Array(24).fill(0);
|
|
326
|
+
const wordCounts = new Map();
|
|
327
|
+
const emojiCounts = new Map();
|
|
328
|
+
for (const message of messages) {
|
|
329
|
+
const month = monthInTimezone(message.ts, timeZone);
|
|
330
|
+
let bucket = monthly.get(month);
|
|
331
|
+
if (!bucket) {
|
|
332
|
+
bucket = { sent: 0, received: 0 };
|
|
333
|
+
monthly.set(month, bucket);
|
|
334
|
+
}
|
|
335
|
+
if (message.from_me)
|
|
336
|
+
bucket.sent += 1;
|
|
337
|
+
else
|
|
338
|
+
bucket.received += 1;
|
|
339
|
+
hourCounts[hourInTimezone(message.ts, timeZone)] += 1;
|
|
340
|
+
for (const term of (message.text ?? '').toLowerCase().match(WORD_TOKEN_RE) ?? []) {
|
|
341
|
+
if (isNoiseToken(term))
|
|
342
|
+
continue;
|
|
343
|
+
wordCounts.set(term, (wordCounts.get(term) ?? 0) + 1);
|
|
344
|
+
}
|
|
345
|
+
for (const emoji of (message.text ?? '').match(EMOJI_REGEX) ?? []) {
|
|
346
|
+
emojiCounts.set(emoji, (emojiCounts.get(emoji) ?? 0) + 1);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const ownerJid = groupRow?.ownerJid?.trim() ? groupRow.ownerJid.trim() : null;
|
|
350
|
+
return {
|
|
351
|
+
jid: chat.jid,
|
|
352
|
+
name: groupRow?.name ?? chat.name,
|
|
353
|
+
ownerJid,
|
|
354
|
+
ownerName: ownerJid ? rosterByJid.get(ownerJid)?.name ?? null : null,
|
|
355
|
+
createdAt: unixSecondsToIso(groupRow?.createdAt ?? null),
|
|
356
|
+
hasRoster,
|
|
357
|
+
totals: {
|
|
358
|
+
messages: totalsRow.messages,
|
|
359
|
+
sentByMe: totalsRow.sentByMe ?? 0,
|
|
360
|
+
media: totalsRow.media ?? 0,
|
|
361
|
+
firstMessageAt: unixSecondsToIso(totalsRow.firstTs),
|
|
362
|
+
lastMessageAt: unixSecondsToIso(totalsRow.lastTs),
|
|
363
|
+
daysSinceLastMessage: totalsRow.lastTs == null ? null : Math.floor((now - totalsRow.lastTs) / DAY_SECONDS),
|
|
364
|
+
activeDays: totalsRow.activeDays,
|
|
365
|
+
},
|
|
366
|
+
roster: {
|
|
367
|
+
total: members.length,
|
|
368
|
+
active: activeMembers.length,
|
|
369
|
+
departed: members.length - activeMembers.length,
|
|
370
|
+
others: activeOthers.length,
|
|
371
|
+
admins: activeOthers.filter((member) => member.isAdmin).length,
|
|
372
|
+
speakers: activeOthers.length - quiet.length,
|
|
373
|
+
lurkers: quiet.length,
|
|
374
|
+
},
|
|
375
|
+
topParticipants,
|
|
376
|
+
quietMembers,
|
|
377
|
+
concentration: {
|
|
378
|
+
topOneShare: share(speakers[0]?.messageCount ?? 0),
|
|
379
|
+
topFiveShare: share(speakers.slice(0, 5).reduce((sum, speaker) => sum + speaker.messageCount, 0)),
|
|
380
|
+
},
|
|
381
|
+
monthlyVolume: [...monthly.entries()]
|
|
382
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
383
|
+
.map(([month, counts]) => ({ month, ...counts })),
|
|
384
|
+
hourOfDay: hourCounts.map((count, hour) => ({ hour, count })).filter((point) => point.count > 0),
|
|
385
|
+
mediaBreakdown,
|
|
386
|
+
topWords: topCounted(wordCounts, limit, (text, value) => ({ text, value })),
|
|
387
|
+
topEmojis: topCounted(emojiCounts, limit, (emoji, count) => ({ emoji, count })),
|
|
388
|
+
scan: scanCoverage(messages.length, GROUP_PROFILE_MAX_ROWS),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Every group, with the roster size the chat list cannot show.
|
|
393
|
+
*/
|
|
394
|
+
export function getGroupDirectory(params, db = getDb()) {
|
|
395
|
+
const limit = parseLimit(params.limit, 50, 500);
|
|
396
|
+
const like = nameLikePattern(params.q);
|
|
397
|
+
const sort = params.sort === 'members' || params.sort === 'messages' ? params.sort : 'recent';
|
|
398
|
+
const hasRoster = tableExists(db, 'group_participants');
|
|
399
|
+
const hasGroupsTable = tableExists(db, 'groups');
|
|
400
|
+
const orderBy = sort === 'members'
|
|
401
|
+
? 'memberCount DESC NULLS LAST, messageCount DESC'
|
|
402
|
+
: sort === 'messages'
|
|
403
|
+
? 'messageCount DESC'
|
|
404
|
+
: 'COALESCE(lastTs, 0) DESC';
|
|
405
|
+
const nameSql = `
|
|
406
|
+
COALESCE(
|
|
407
|
+
${hasGroupsTable ? `${cleanDisplayNameSql('groups.name')},` : ''}
|
|
408
|
+
${cleanDisplayNameSql('chats.name')},
|
|
409
|
+
${cleanDisplayNameSql('MAX(messages.chat_name)')},
|
|
410
|
+
${cleanDisplayNameSql('chats.jid')},
|
|
411
|
+
'Unknown'
|
|
412
|
+
)
|
|
413
|
+
`;
|
|
414
|
+
const rows = db.prepare(`
|
|
415
|
+
SELECT
|
|
416
|
+
chats.jid AS jid,
|
|
417
|
+
${nameSql} AS name,
|
|
418
|
+
${hasRoster
|
|
419
|
+
? `(
|
|
420
|
+
SELECT COUNT(DISTINCT ${canonicalJidSql('roster_contacts', 'group_participants.user_jid')})
|
|
421
|
+
FROM group_participants
|
|
422
|
+
${contactLeftJoins('roster_contacts', 'group_participants.user_jid')}
|
|
423
|
+
WHERE group_participants.group_jid = chats.jid AND group_participants.is_active = 1
|
|
424
|
+
)`
|
|
425
|
+
: 'NULL'} AS memberCount,
|
|
426
|
+
COUNT(messages.rowid) AS messageCount,
|
|
427
|
+
COALESCE(SUM(messages.from_me), 0) AS sentByMe,
|
|
428
|
+
MAX(messages.ts) AS lastTs,
|
|
429
|
+
${hasGroupsTable ? 'groups.created_at' : 'NULL'} AS createdAt
|
|
430
|
+
FROM chats
|
|
431
|
+
${hasGroupsTable ? 'LEFT JOIN groups ON groups.jid = chats.jid' : ''}
|
|
432
|
+
LEFT JOIN messages ON messages.chat_jid = chats.jid
|
|
433
|
+
WHERE chats.kind = 'group'
|
|
434
|
+
GROUP BY chats.jid
|
|
435
|
+
${like ? `HAVING ${nameSql} LIKE @like ESCAPE '\\'` : ''}
|
|
436
|
+
ORDER BY ${orderBy}
|
|
437
|
+
LIMIT @limit
|
|
438
|
+
`).all({ limit, like });
|
|
439
|
+
return rows.map((row) => ({
|
|
440
|
+
jid: row.jid,
|
|
441
|
+
name: row.name ?? row.jid,
|
|
442
|
+
memberCount: row.memberCount,
|
|
443
|
+
messageCount: row.messageCount,
|
|
444
|
+
sentByMe: row.sentByMe,
|
|
445
|
+
lastMessageAt: unixSecondsToIso(row.lastTs),
|
|
446
|
+
createdAt: unixSecondsToIso(row.createdAt),
|
|
447
|
+
}));
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* How groups age, and how many of them have quietly died.
|
|
451
|
+
*/
|
|
452
|
+
export function getGroupLifecycle(params, db = getDb()) {
|
|
453
|
+
const dormantDays = parseLimit(params.dormantDays, 180, 3650);
|
|
454
|
+
const limit = parseLimit(params.limit, 8, 50);
|
|
455
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
456
|
+
const hasGroupsTable = tableExists(db, 'groups');
|
|
457
|
+
const rows = db.prepare(`
|
|
458
|
+
SELECT
|
|
459
|
+
chats.jid AS jid,
|
|
460
|
+
COALESCE(
|
|
461
|
+
${hasGroupsTable ? `${cleanDisplayNameSql('groups.name')},` : ''}
|
|
462
|
+
${cleanDisplayNameSql('chats.name')},
|
|
463
|
+
${cleanDisplayNameSql('MAX(messages.chat_name)')},
|
|
464
|
+
${cleanDisplayNameSql('chats.jid')},
|
|
465
|
+
'Unknown'
|
|
466
|
+
) AS name,
|
|
467
|
+
COUNT(messages.rowid) AS messageCount,
|
|
468
|
+
NULLIF(MAX(COALESCE(MAX(messages.ts), 0), COALESCE(chats.last_message_at, 0)), 0) AS lastTs,
|
|
469
|
+
${hasGroupsTable ? 'NULLIF(groups.created_at, 0)' : 'NULL'} AS createdAt
|
|
470
|
+
FROM chats
|
|
471
|
+
${hasGroupsTable ? 'LEFT JOIN groups ON groups.jid = chats.jid' : ''}
|
|
472
|
+
LEFT JOIN messages ON messages.chat_jid = chats.jid
|
|
473
|
+
WHERE chats.kind = 'group'
|
|
474
|
+
GROUP BY chats.jid
|
|
475
|
+
`).all();
|
|
476
|
+
const now = Math.floor(Date.now() / 1000);
|
|
477
|
+
const cutoff = now - dormantDays * DAY_SECONDS;
|
|
478
|
+
const toEntry = (row) => ({
|
|
479
|
+
jid: row.jid,
|
|
480
|
+
name: row.name ?? row.jid,
|
|
481
|
+
messageCount: row.messageCount,
|
|
482
|
+
createdAt: unixSecondsToIso(row.createdAt),
|
|
483
|
+
lastMessageAt: unixSecondsToIso(row.lastTs),
|
|
484
|
+
daysSilent: row.lastTs == null ? null : Math.floor((now - row.lastTs) / DAY_SECONDS),
|
|
485
|
+
});
|
|
486
|
+
const isDead = (row) => row.lastTs == null || row.lastTs < cutoff;
|
|
487
|
+
const dead = rows.filter(isDead);
|
|
488
|
+
const alive = rows.filter((row) => !isDead(row));
|
|
489
|
+
const ages = rows
|
|
490
|
+
.filter((row) => row.createdAt != null && row.createdAt <= now)
|
|
491
|
+
.map((row) => (now - row.createdAt) / DAY_SECONDS)
|
|
492
|
+
.sort((a, b) => a - b);
|
|
493
|
+
const oldestAliveRow = alive
|
|
494
|
+
.filter((row) => row.createdAt != null)
|
|
495
|
+
.sort((a, b) => a.createdAt - b.createdAt)[0];
|
|
496
|
+
const byYear = new Map();
|
|
497
|
+
for (const row of rows) {
|
|
498
|
+
if (row.createdAt == null)
|
|
499
|
+
continue;
|
|
500
|
+
const year = Number(dateInTimezone(row.createdAt, timeZone).slice(0, 4));
|
|
501
|
+
const bucket = byYear.get(year) ?? { year, alive: 0, dead: 0 };
|
|
502
|
+
if (isDead(row))
|
|
503
|
+
bucket.dead += 1;
|
|
504
|
+
else
|
|
505
|
+
bucket.alive += 1;
|
|
506
|
+
byYear.set(year, bucket);
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
dormantDays,
|
|
510
|
+
totalGroups: rows.length,
|
|
511
|
+
deadGroups: dead.length,
|
|
512
|
+
neverActiveGroups: dead.filter((row) => row.messageCount === 0).length,
|
|
513
|
+
medianAgeDays: ages.length ? Math.round(percentile(ages, 50)) : null,
|
|
514
|
+
oldestAlive: oldestAliveRow ? toEntry(oldestAliveRow) : null,
|
|
515
|
+
foundedByYear: [...byYear.values()].sort((a, b) => a.year - b.year),
|
|
516
|
+
graveyard: dead
|
|
517
|
+
.filter((row) => row.messageCount > 0)
|
|
518
|
+
.sort((a, b) => b.messageCount - a.messageCount || (b.lastTs ?? 0) - (a.lastTs ?? 0))
|
|
519
|
+
.slice(0, limit)
|
|
520
|
+
.map(toEntry),
|
|
521
|
+
};
|
|
522
|
+
}
|