Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

169 lines
8.7 KiB
JavaScript

// message-read.js
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import { authHeadersFragment, resolveShortIdFragment } from './in-page.js';
import { dispatchEvaluateResult } from './errors.js';
import { SLOCK_SITE, SLOCK_DOMAIN, SLOCK_HOME_URL, SLOCK_API_BASE } from './shared.js';
import { UUID_RE, classifyThreadTarget, parsePositiveInteger } from './resolve.js';
function mapRow(m, threadsMap) {
const info = threadsMap && typeof threadsMap === 'object' ? threadsMap[m.id ?? m.messageId] : undefined;
const threadsUnavailable = threadsMap === null;
return {
id: m.id ?? m.messageId ?? '',
seq: typeof m.seq === 'number' ? m.seq : null,
createdAt: m.createdAt ?? m.created_at ?? '',
senderName: m.sender?.name ?? m.user?.name ?? m.senderName ?? '',
content: m.content ?? '',
threadChannelId: threadsUnavailable ? null : (info?.threadChannelId ?? null),
replyCount: threadsUnavailable ? null : (typeof info?.replyCount === 'number' ? info.replyCount : 0),
unreadCount: threadsUnavailable ? null : (typeof info?.unreadCount === 'number' ? info.unreadCount : 0),
lastReplyAt: threadsUnavailable ? null : (info?.lastReplyAt ?? null),
};
}
cli({
site: SLOCK_SITE,
name: 'message-read',
access: 'read',
description: 'Read messages in a channel or thread. Thread form: "#channel:msgIdOrShort". Use --after seq|UUID for cursor.',
domain: SLOCK_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
siteSession: 'persistent',
args: [
{ name: 'channel', positional: true, required: true, help: 'channelId UUID, "#name", or "#channel:msgIdOrShort"' },
{ name: 'after', help: 'Cursor: seq number or messageId UUID (exclusive)' },
{ name: 'before', help: 'seq to page before' },
{ name: 'limit', type: 'int', default: 50, help: 'Max messages' },
{ name: 'no-threads', type: 'bool', default: false, help: 'Skip /threads enrichment' },
{ name: 'server', help: 'Override active server' },
],
columns: ['id', 'seq', 'createdAt', 'senderName', 'content', 'threadChannelId', 'replyCount', 'unreadCount', 'lastReplyAt'],
func: async (page, kwargs) => {
const channel = String(kwargs.channel ?? '').trim();
if (!channel) throw new ArgumentError('channel required');
const tt = classifyThreadTarget(channel);
const isUuid = UUID_RE.test(channel);
const after = kwargs.after !== undefined ? String(kwargs.after) : '';
if (after && !/^\d+$/.test(after) && !UUID_RE.test(after)) {
throw new ArgumentError(`--after must be a seq number or messageId UUID (got "${after}")`);
}
const limit = parsePositiveInteger(kwargs.limit, '--limit', { defaultValue: 50 });
const before = kwargs.before !== undefined ? String(kwargs.before) : '';
if (before && !/^\d+$/.test(before)) {
throw new ArgumentError(`--before must be a seq number (got "${before}")`);
}
if (before) parsePositiveInteger(before, '--before');
const noThreads = !!kwargs['no-threads'];
// R1 — pass the raw override through to authHeadersFragment; it owns the
// UUID-vs-slug resolution against /servers/ now.
const override = kwargs.server ?? null;
await page.goto(SLOCK_HOME_URL);
const params = {
isUuid, channel, after, before, limit, noThreads, override,
parentTarget: tt?.parentTarget ?? '',
parentMsgId: tt?.parentMsgId ?? '',
isThread: !!tt,
};
const snippet = buildReadSnippet(params);
const result = await page.evaluate(`(async () => { ${snippet} })()`);
if (result?.kind === 'no-thread') {
return [{
id: '', seq: null, createdAt: '', senderName: '',
content: `(${result.parent} — no thread yet, 0 replies.)`,
threadChannelId: null, replyCount: 0, unreadCount: 0, lastReplyAt: null,
}];
}
const list = dispatchEvaluateResult(result);
if (!Array.isArray(list)) {
throw new CommandExecutionError(`expected array of rows from server, got ${typeof list} (contract drift?)`);
}
const threadsMap = result.meta?.threadsMap ?? {};
const threadsDegraded = result.meta?.threadsDegraded === true;
const mapArg = threadsDegraded ? null : threadsMap;
const rows = list.map((m) => mapRow(m, mapArg));
if (threadsDegraded) {
rows.unshift({
id: '', seq: null, createdAt: '', senderName: '',
content: '(threads-enrichment unavailable — replyCount/threadChannelId set to null. Retry to get reply counts.)',
threadChannelId: null, replyCount: null, unreadCount: null, lastReplyAt: null,
});
}
return rows;
},
});
function buildReadSnippet(p) {
const target = JSON.stringify(p.channel.replace(/^#/, '').toLowerCase());
const parentTarget = JSON.stringify(p.parentTarget.replace(/^#/, '').toLowerCase());
const parentMsgId = JSON.stringify(p.parentMsgId);
const after = JSON.stringify(p.after);
const before = JSON.stringify(p.before);
const limit = JSON.stringify(p.limit);
return `
${authHeadersFragment({ serverScoped: true, serverIdOverride: p.override })}
let channelId;
${p.isThread ? `
// thread shape: resolve parent channel, then short-id → full, then /threads/:msgId
const cres = await fetch('${SLOCK_API_BASE}/channels/', { credentials:'include', headers });
if (!cres.ok) return { kind: cres.status===401?'auth':'http', status: cres.status, where:'/channels/' };
const carr = await cres.json();
const carrL = Array.isArray(carr) ? carr : (carr.channels || carr.data || []);
const phit = carrL.find((c) => (c.name||c.slug||'').toLowerCase() === ${parentTarget});
if (!phit) return { kind: 'unresolvable', detail: 'parent channel not found: ' + ${parentTarget} };
const parentChannelId = phit.id;
let fullMsgId = ${parentMsgId};
${resolveShortIdFragment({ shortIdVar: 'fullMsgId', parentChannelIdVar: 'parentChannelId', contextDescription: parentTarget })}
const tres = await fetch('${SLOCK_API_BASE}/channels/' + encodeURIComponent(parentChannelId) + '/threads/' + encodeURIComponent(fullMsgId), { credentials:'include', headers });
if (tres.status === 404) return { kind: 'no-thread', parent: ${JSON.stringify(p.channel)} };
if (!tres.ok) return { kind: tres.status===401?'auth':'http', status: tres.status, where:'/threads/:msgId' };
const tinfo = await tres.json();
channelId = tinfo.threadChannelId || tinfo.channelId || tinfo.id;
` : `
if (${p.isUuid}) {
channelId = ${JSON.stringify(p.channel)};
} else {
const cres = await fetch('${SLOCK_API_BASE}/channels/', { credentials:'include', headers });
if (!cres.ok) return { kind: cres.status===401?'auth':'http', status: cres.status, where:'/channels/' };
const arr = await cres.json();
const arrL = Array.isArray(arr) ? arr : (arr.channels || arr.data || []);
const hit = arrL.find((c) => (c.name||c.slug||'').toLowerCase() === ${target});
if (!hit) return { kind: 'unresolvable', detail: 'no channel matches ' + ${target} };
channelId = hit.id;
}
`}
let afterSeq = ${after};
if (afterSeq && /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(afterSeq)) {
const cx = await fetch('${SLOCK_API_BASE}/messages/context/' + encodeURIComponent(afterSeq), { credentials:'include', headers });
if (!cx.ok) return { kind: cx.status===401?'auth':'http', status: cx.status, where:'/messages/context (--after)' };
const cxd = await cx.json();
const anchor = (cxd.messages || []).find((m) => (m.id||m.messageId) === cxd.targetMessageId);
afterSeq = String(anchor?.seq ?? '');
}
const qs = new URLSearchParams();
if (afterSeq) qs.set('after', afterSeq);
if (${before}) qs.set('before', ${before});
qs.set('limit', ${limit});
const mres = await fetch('${SLOCK_API_BASE}/messages/channel/' + encodeURIComponent(channelId) + '?' + qs.toString(), { credentials:'include', headers });
if (!mres.ok) return { kind: mres.status===401?'auth':'http', status: mres.status, where: '/messages/channel/:id' };
const mdata = await mres.json();
const messages = Array.isArray(mdata) ? mdata : (mdata.messages || mdata.data || []);
let threadsMap = {};
let threadsDegraded = false;
${p.noThreads ? '' : `
try {
const thres = await fetch('${SLOCK_API_BASE}/channels/' + encodeURIComponent(channelId) + '/threads', { credentials:'include', headers });
if (thres.ok) {
const tmap = await thres.json();
if (tmap && typeof tmap === 'object' && !Array.isArray(tmap)) threadsMap = tmap;
} else { threadsDegraded = true; }
} catch { threadsDegraded = true; }
`}
return { kind: 'ok', rows: messages, meta: { threadsMap: threadsDegraded ? null : threadsMap, threadsDegraded } };
`;
}