feat(cli): mentions target user

This commit is contained in:
Peter Steinberger
2025-12-26 12:23:00 +01:00
parent 5613caa149
commit 3df7969b16
4 changed files with 90 additions and 5 deletions
+3
View File
@@ -5,6 +5,9 @@
### Fixed
- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available.
### Changed
- `mentions`: no hardcoded user; defaults to authenticated user or accepts `--user @handle`.
## 0.1.1 — 2025-12-26
### Changed
+50 -5
View File
@@ -19,6 +19,7 @@ import { resolveCliInvocation } from './lib/cli-args.js';
import { resolveCredentials } from './lib/cookies.js';
import { type EngineMode, resolveEngineMode, shouldUseSweetistics } from './lib/engine.js';
import { extractTweetId } from './lib/extract-tweet-id.js';
import { normalizeHandle } from './lib/normalize-handle.js';
import { SweetisticsClient } from './lib/sweetistics-client.js';
import { type TweetData, TwitterClient } from './lib/twitter-client.js';
import { getCliVersion } from './lib/version.js';
@@ -804,10 +805,11 @@ program
// Mentions command - shortcut to search for @username mentions
program
.command('mentions')
.description('Find tweets mentioning @clawdbot')
.description('Find tweets mentioning a user (defaults to current user)')
.option('-u, --user <handle>', 'User handle (e.g. @steipete)')
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
@@ -815,6 +817,9 @@ program
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
const explicitHandle = normalizeHandle(cmdOpts.user);
let query: string | null = explicitHandle ? `@${explicitHandle}` : null;
if (useSweetistics) {
if (!sweetistics.apiKey) {
console.error('❌ Sweetistics engine selected but no API key provided.');
@@ -825,7 +830,18 @@ program
apiKey: sweetistics.apiKey,
timeoutMs,
});
const result = await client.search('@clawdbot', count);
if (!query) {
const who = await client.getCurrentUser();
const handle = normalizeHandle(who.user?.username);
if (!handle) {
console.error(`❌ Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`);
process.exit(1);
}
query = `@${handle}`;
}
const result = await client.search(query, count);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
return;
@@ -850,7 +866,36 @@ program
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.search('@clawdbot', count);
if (!query) {
const who = await client.getCurrentUser();
const handle = normalizeHandle(who.user?.username);
if (!handle) {
if (sweetistics.apiKey) {
console.error(
`⚠️ Could not determine current user (${who.error ?? 'Unknown error'}); trying Sweetistics fallback...`,
);
const fallbackWho = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey,
timeoutMs,
}).getCurrentUser();
const fallbackHandle = normalizeHandle(fallbackWho.user?.username);
if (!fallbackHandle) {
console.error(`❌ Could not determine current user. Use --user <handle>.`);
process.exit(1);
}
query = `@${fallbackHandle}`;
} else {
console.error(`❌ Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`);
process.exit(1);
}
} else {
query = `@${handle}`;
}
}
const result = await client.search(query, count);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
@@ -860,7 +905,7 @@ program
baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey,
timeoutMs,
}).search('@clawdbot', count);
}).search(query, count);
if (fallback.success && fallback.tweets) {
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
} else {
+13
View File
@@ -0,0 +1,13 @@
export function normalizeHandle(input?: string | null): string | null {
const raw = (input ?? '').trim();
if (!raw) return null;
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
const handle = withoutAt.trim();
if (!handle) return null;
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null;
return handle;
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { normalizeHandle } from '../src/lib/normalize-handle.js';
describe('normalizeHandle', () => {
it('accepts bare handle', () => {
expect(normalizeHandle('steipete')).toBe('steipete');
});
it('accepts @handle and trims whitespace', () => {
expect(normalizeHandle(' @steipete ')).toBe('steipete');
});
it('rejects empty input', () => {
expect(normalizeHandle('')).toBeNull();
expect(normalizeHandle(' ')).toBeNull();
expect(normalizeHandle(null)).toBeNull();
});
it('rejects invalid characters and too-long handles', () => {
expect(normalizeHandle('@stei-pete')).toBeNull();
expect(normalizeHandle('@steipete!')).toBeNull();
expect(normalizeHandle('a'.repeat(16))).toBeNull();
});
});