tibodog 0.1.2 → 0.1.3
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/app/Sources/TiboDog/App.swift +5 -2
- package/app/Sources/TiboDog/CodexUsageAPI.swift +12 -2
- package/app/Sources/TiboDog/Credentials.swift +56 -10
- package/app/Sources/TiboDog/DogMonitor.swift +55 -25
- package/app/Sources/TiboDog/Models.swift +2 -2
- package/app/Sources/TiboDog/NotchGeometry.swift +5 -2
- package/app/Sources/TiboDog/NotchView.swift +22 -11
- package/app/Sources/TiboDog/NowPlayingMonitor.swift +7 -4
- package/app/Sources/TiboDog/ResetTracker.swift +23 -6
- package/app/Sources/TiboDog/UsageStore.swift +14 -7
- package/app/Tests/TiboDogTests/CodexUsageTests.swift +77 -0
- package/bin/tibodog.js +65 -29
- package/package.json +3 -2
- package/src/bark.js +18 -9
- package/src/sources.js +145 -36
- package/src/widget.js +44 -15
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import TiboDog
|
|
3
|
+
|
|
4
|
+
final class CodexUsageTests: XCTestCase {
|
|
5
|
+
func testPrefersCodexBucketAndDecodesResetSeconds() throws {
|
|
6
|
+
let result = try CodexUsageAPI.decode([
|
|
7
|
+
"rateLimits": ["primary": ["usedPercent": 99]],
|
|
8
|
+
"rateLimitsByLimitId": [
|
|
9
|
+
"codex": ["planType": "plus", "primary": ["usedPercent": 63, "windowDurationMins": 300, "resetsAt": 1789194640],
|
|
10
|
+
"secondary": ["usedPercent": 16, "windowDurationMins": 10080]],
|
|
11
|
+
"other": ["primary": ["usedPercent": 100]]
|
|
12
|
+
]
|
|
13
|
+
])
|
|
14
|
+
XCTAssertEqual(result.usage.fiveHour?.utilization, 63)
|
|
15
|
+
XCTAssertEqual(result.usage.fiveHour?.resetsAt, Date(timeIntervalSince1970: 1789194640))
|
|
16
|
+
XCTAssertEqual(result.usage.fiveHour?.durationLabel, "5H")
|
|
17
|
+
XCTAssertEqual(result.usage.sevenDay?.durationLabel, "7D")
|
|
18
|
+
XCTAssertEqual(result.plan, "plus")
|
|
19
|
+
XCTAssertNil(result.usage.sevenDayOpus)
|
|
20
|
+
XCTAssertNil(result.resetCredits)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func testDecodesResetCredits() throws {
|
|
24
|
+
let result = try CodexUsageAPI.decode([
|
|
25
|
+
"rateLimits": ["primary": ["usedPercent": 1, "windowDurationMins": 300]],
|
|
26
|
+
"rateLimitResetCredits": ["availableCount": 2]
|
|
27
|
+
])
|
|
28
|
+
XCTAssertEqual(result.resetCredits, 2)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func testLegacySingleWindowAndNonStandardDuration() throws {
|
|
32
|
+
let result = try CodexUsageAPI.decode(["rateLimits": ["primary": ["usedPercent": 0, "windowDurationMins": 15]]])
|
|
33
|
+
XCTAssertEqual(result.usage.fiveHour?.percentText, "0%")
|
|
34
|
+
XCTAssertEqual(result.usage.fiveHour?.durationLabel, "15M")
|
|
35
|
+
XCTAssertNil(result.usage.sevenDay)
|
|
36
|
+
XCTAssertNil(result.usage.fiveHour?.resetsAt)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func testMissingUsageIsNotZeroAndOtherBucketIsNotCodex() {
|
|
40
|
+
XCTAssertThrowsError(try CodexUsageAPI.decode(["rateLimits": NSNull()]))
|
|
41
|
+
XCTAssertThrowsError(try CodexUsageAPI.decode(["rateLimits": ["primary": ["resetsAt": 123]]]))
|
|
42
|
+
XCTAssertThrowsError(try CodexUsageAPI.decode(["rateLimitsByLimitId": ["other": ["primary": ["usedPercent": 12]]]]))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func testInvalidPercentageAndClamping() throws {
|
|
46
|
+
XCTAssertThrowsError(try CodexUsageAPI.decode(["rateLimits": ["primary": ["usedPercent": Double.nan]]]))
|
|
47
|
+
let result = try CodexUsageAPI.decode(["rateLimits": ["primary": ["usedPercent": 110]]])
|
|
48
|
+
XCTAssertEqual(result.usage.fiveHour?.fraction, 1)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func testClaudeDecoderStillWorks() throws {
|
|
52
|
+
let data = Data(#"{"five_hour":{"utilization":42,"resets_at":"2026-09-12T12:00:00Z"},"seven_day":{"utilization":71},"seven_day_opus":{"utilization":18}}"#.utf8)
|
|
53
|
+
let usage = try Usage(json: data)
|
|
54
|
+
XCTAssertEqual(usage.fiveHour?.utilization, 42)
|
|
55
|
+
XCTAssertNotNil(usage.fiveHour?.resetsAt)
|
|
56
|
+
XCTAssertEqual(usage.sevenDayOpus?.utilization, 18)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func testLiveCodexReadWhenRequested() throws {
|
|
60
|
+
guard ProcessInfo.processInfo.environment["TIBODOG_TEST_LIVE_CODEX"] == "1" else {
|
|
61
|
+
throw XCTSkip("Set TIBODOG_TEST_LIVE_CODEX=1 to check the installed Codex login.")
|
|
62
|
+
}
|
|
63
|
+
let result = try CodexUsageAPI.fetch()
|
|
64
|
+
XCTAssertTrue(result.usage.fiveHour != nil || result.usage.sevenDay != nil)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
final class DogLinkTests: XCTestCase {
|
|
69
|
+
func testOnlyPostsOnXOpen() {
|
|
70
|
+
XCTAssertTrue(isPostURL(URL(string: "https://x.com/thsottiaux/status/2114588174249071102")!))
|
|
71
|
+
XCTAssertTrue(isPostURL(URL(string: "https://twitter.com/a/status/123456")!))
|
|
72
|
+
for bad in ["file:///System/Applications/Calculator.app", "http://x.com/a/status/1", "vscode://x/status/1",
|
|
73
|
+
"https://x.com.evil.com/a/status/1", "https://evil.com/x.com/status/1", "https://x.com/settings"] {
|
|
74
|
+
XCTAssertFalse(isPostURL(URL(string: bad)!), bad)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
package/bin/tibodog.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
5
4
|
import { join, dirname } from 'node:path';
|
|
6
5
|
import { isResetPost } from '../src/detect.js';
|
|
7
6
|
import { xSource, rssSource, RateLimited } from '../src/sources.js';
|
|
8
7
|
import { playBark, notify, openUrl } from '../src/bark.js';
|
|
9
8
|
import { showDog, log, barking, SLEEPING, WATCHING } from '../src/dog.js';
|
|
10
9
|
import { detectLang, getMessages } from '../src/i18n.js';
|
|
11
|
-
import { ensureWidget, launchWidget, widgetRunning, widgetSupported, installApp, writeAlert } from '../src/widget.js';
|
|
10
|
+
import { ensureWidget, launchWidget, widgetRunning, widgetSupported, installApp, writeAlert, DIR } from '../src/widget.js';
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
// Anything that slips through still gets logged instead of killing the watcher.
|
|
13
|
+
process.on('unhandledRejection', (e) => log(`⚠️ ${e?.message ?? e}`));
|
|
14
|
+
|
|
15
|
+
const options = {
|
|
15
16
|
user: { type: 'string', default: process.env.TIBODOG_USER ?? 'thsottiaux' },
|
|
16
17
|
rss: { type: 'string', default: process.env.TIBODOG_RSS },
|
|
17
18
|
interval: { type: 'string', default: process.env.TIBODOG_INTERVAL },
|
|
@@ -26,8 +27,17 @@ const { values: opt } = parseArgs({
|
|
|
26
27
|
once: { type: 'boolean', default: false },
|
|
27
28
|
test: { type: 'boolean', default: false },
|
|
28
29
|
help: { type: 'boolean', short: 'h', default: false },
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let opt;
|
|
33
|
+
try {
|
|
34
|
+
({ values: opt } = parseArgs({ options }));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
const t = getMessages(detectLang());
|
|
37
|
+
console.error(`🐕 ${e.message}\n`);
|
|
38
|
+
console.log(t.help);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
31
41
|
|
|
32
42
|
const lang = detectLang(opt.lang);
|
|
33
43
|
const t = getMessages(lang);
|
|
@@ -37,19 +47,35 @@ if (opt.help) {
|
|
|
37
47
|
process.exit(0);
|
|
38
48
|
}
|
|
39
49
|
|
|
50
|
+
// An X handle: also names the state file, so nothing else gets through.
|
|
51
|
+
if (!/^[A-Za-z0-9_]{1,15}$/.test(opt.user)) {
|
|
52
|
+
console.error(`🐕 --user: "${opt.user}" isn't an X handle`);
|
|
53
|
+
process.exit(2);
|
|
54
|
+
}
|
|
55
|
+
const keywords = opt.keyword.map((k) => k.trim()).filter(Boolean);
|
|
56
|
+
|
|
40
57
|
const barks = Math.max(1, Number(opt.barks) || 1);
|
|
41
58
|
const useWidget = widgetSupported() && !opt['no-widget'];
|
|
42
59
|
|
|
43
60
|
// Puts the notch widget (usage, Now Playing, the dog) on screen, building it on first run.
|
|
44
61
|
let widgetLaunchedAt = 0;
|
|
62
|
+
let widgetBroken = false;
|
|
45
63
|
async function startWidget() {
|
|
46
|
-
// Just opened: it can take a moment to show up as running.
|
|
47
|
-
if (!useWidget || widgetRunning() || Date.now() - widgetLaunchedAt < 30_000) return;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
log(
|
|
64
|
+
// Just opened: it can take a moment to show up as running. A failed build isn't retried this run.
|
|
65
|
+
if (!useWidget || widgetBroken || widgetRunning() || Date.now() - widgetLaunchedAt < 30_000) return;
|
|
66
|
+
let result;
|
|
67
|
+
try {
|
|
68
|
+
result = await ensureWidget({ onBuild: () => log(t.widgetBuilding) });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
result = { error: e.message };
|
|
71
|
+
}
|
|
72
|
+
const { app, error, fallback } = result;
|
|
73
|
+
if (error) log(`${t.widgetFailed}\n${error}`);
|
|
74
|
+
if (!app) {
|
|
75
|
+
widgetBroken = true;
|
|
51
76
|
return;
|
|
52
77
|
}
|
|
78
|
+
if (fallback) widgetBroken = true; // running the last good build; don't rebuild on every alert
|
|
53
79
|
launchWidget(app);
|
|
54
80
|
widgetLaunchedAt = Date.now();
|
|
55
81
|
log(t.widgetUp);
|
|
@@ -76,17 +102,21 @@ async function alert(post) {
|
|
|
76
102
|
notify(t.notifyTitle, post.text.slice(0, 180));
|
|
77
103
|
if (opt.open && post.url) openUrl(post.url);
|
|
78
104
|
// The widget picks this up and barks in the notch until it's read.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
105
|
+
try {
|
|
106
|
+
writeAlert({
|
|
107
|
+
id: post.id ?? `test-${Date.now()}`,
|
|
108
|
+
title: t.alertTitle,
|
|
109
|
+
body: post.text,
|
|
110
|
+
url: post.url,
|
|
111
|
+
woof: t.alertWoof,
|
|
112
|
+
open: t.alertOpen,
|
|
113
|
+
postedAt: post.createdAt,
|
|
114
|
+
});
|
|
115
|
+
} catch (e) {
|
|
116
|
+
log(`⚠️ ${e.message}`);
|
|
117
|
+
}
|
|
118
|
+
await playBark(barks, opt.sound); // the bark never waits on the widget
|
|
88
119
|
await startWidget(); // in case it was quit
|
|
89
|
-
await playBark(barks, opt.sound);
|
|
90
120
|
}
|
|
91
121
|
|
|
92
122
|
if (opt.test) {
|
|
@@ -94,14 +124,15 @@ if (opt.test) {
|
|
|
94
124
|
process.exit(0);
|
|
95
125
|
}
|
|
96
126
|
|
|
97
|
-
const statePath = join(
|
|
127
|
+
const statePath = join(DIR, `${opt.user}.json`);
|
|
98
128
|
const seen = new Set((() => {
|
|
99
129
|
try {
|
|
100
|
-
|
|
130
|
+
const saved = JSON.parse(readFileSync(statePath, 'utf8')).seen;
|
|
131
|
+
return Array.isArray(saved) ? saved.map(String) : [];
|
|
101
132
|
} catch {
|
|
102
|
-
return
|
|
133
|
+
return [];
|
|
103
134
|
}
|
|
104
|
-
})()
|
|
135
|
+
})());
|
|
105
136
|
const firstRun = seen.size === 0;
|
|
106
137
|
|
|
107
138
|
function save() {
|
|
@@ -114,7 +145,7 @@ await startWidget();
|
|
|
114
145
|
let source;
|
|
115
146
|
try {
|
|
116
147
|
source = opt.rss
|
|
117
|
-
? rssSource({ url: opt.rss })
|
|
148
|
+
? rssSource({ url: opt.rss, username: opt.user })
|
|
118
149
|
: xSource({ username: opt.user, bearerToken: process.env.X_BEARER_TOKEN, missingTokenMessage: t.noToken });
|
|
119
150
|
} catch (e) {
|
|
120
151
|
showDog(SLEEPING);
|
|
@@ -122,7 +153,9 @@ try {
|
|
|
122
153
|
process.exit(1);
|
|
123
154
|
}
|
|
124
155
|
|
|
125
|
-
|
|
156
|
+
// Between 15 seconds and a day: anything else would hammer the feed or overflow setTimeout.
|
|
157
|
+
const seconds = Number(opt.interval) || (opt.rss ? 60 : 300);
|
|
158
|
+
const intervalMs = Math.min(Math.max(seconds, 15), 86_400) * 1000;
|
|
126
159
|
let primed = !firstRun || opt['bark-old'];
|
|
127
160
|
|
|
128
161
|
async function tick() {
|
|
@@ -131,14 +164,15 @@ async function tick() {
|
|
|
131
164
|
let barked = 0;
|
|
132
165
|
for (const p of fresh) {
|
|
133
166
|
seen.add(p.id);
|
|
134
|
-
if (primed && isResetPost(p.text,
|
|
167
|
+
if (primed && isResetPost(p.text, keywords)) {
|
|
135
168
|
barked++;
|
|
136
169
|
await alert(p);
|
|
137
170
|
}
|
|
138
171
|
}
|
|
139
172
|
if (!primed) log(t.primed(posts.length));
|
|
140
173
|
else if (fresh.length && !barked) log(t.quiet(fresh.length));
|
|
141
|
-
|
|
174
|
+
// An empty first fetch (a mirror's error page) doesn't count: priming waits for real posts.
|
|
175
|
+
if (posts.length) primed = true;
|
|
142
176
|
save();
|
|
143
177
|
}
|
|
144
178
|
|
|
@@ -149,7 +183,9 @@ while (true) {
|
|
|
149
183
|
let wait = intervalMs;
|
|
150
184
|
try {
|
|
151
185
|
await tick();
|
|
186
|
+
process.exitCode = 0;
|
|
152
187
|
} catch (e) {
|
|
188
|
+
process.exitCode = 1; // --once: let cron see a failed check
|
|
153
189
|
if (e instanceof RateLimited) {
|
|
154
190
|
wait = Math.max(intervalMs, e.retryAt - Date.now() + 1000);
|
|
155
191
|
log(t.rateLimited(Math.round(wait / 1000)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tibodog",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "A notch widget (Claude Code/Codex usage, Now Playing) with a dog that barks when Tibo (@thsottiaux) resets the Codex limits on X",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"app/Package.swift",
|
|
14
14
|
"app/LICENSE",
|
|
15
15
|
"app/Sources",
|
|
16
|
+
"app/Tests",
|
|
16
17
|
"app/Resources",
|
|
17
18
|
"app/Scripts",
|
|
18
19
|
"app/Vendor",
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
"test": "node --test test/*.test.js"
|
|
26
27
|
},
|
|
27
28
|
"engines": {
|
|
28
|
-
"node": ">=18"
|
|
29
|
+
"node": ">=18.11"
|
|
29
30
|
},
|
|
30
31
|
"keywords": [
|
|
31
32
|
"codex",
|
package/src/bark.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Plays the bundled bark recording, or a synthesized bark as a fallback.
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
-
import { writeFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { writeFileSync, existsSync, mkdtempSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { isPostUrl } from './sources.js';
|
|
8
9
|
|
|
9
10
|
const RATE = 22050;
|
|
10
11
|
|
|
@@ -58,7 +59,8 @@ export function makeBarkWav(count = 2) {
|
|
|
58
59
|
function players(file) {
|
|
59
60
|
if (process.platform === 'darwin') return [['afplay', [file]]];
|
|
60
61
|
if (process.platform === 'win32') {
|
|
61
|
-
|
|
62
|
+
// The path goes in through the environment, so a quote in it can't change the command.
|
|
63
|
+
return [['powershell', ['-NoProfile', '-c', '(New-Object Media.SoundPlayer $env:TIBODOG_WAV).PlaySync()'], { TIBODOG_WAV: file }]];
|
|
62
64
|
}
|
|
63
65
|
return [
|
|
64
66
|
['paplay', [file]],
|
|
@@ -67,11 +69,16 @@ function players(file) {
|
|
|
67
69
|
];
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
// Never throws: a missing player, a bad argument or a crash just means "didn't work".
|
|
73
|
+
function run(cmd, args, env) {
|
|
71
74
|
return new Promise((resolve) => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
try {
|
|
76
|
+
const p = spawn(cmd, args, { stdio: 'ignore', env: env ? { ...process.env, ...env } : undefined });
|
|
77
|
+
p.on('error', () => resolve(false));
|
|
78
|
+
p.on('exit', (code) => resolve(code === 0));
|
|
79
|
+
} catch {
|
|
80
|
+
resolve(false);
|
|
81
|
+
}
|
|
75
82
|
});
|
|
76
83
|
}
|
|
77
84
|
|
|
@@ -82,15 +89,15 @@ let cached = null;
|
|
|
82
89
|
|
|
83
90
|
function synthFile() {
|
|
84
91
|
if (!cached) {
|
|
85
|
-
cached = join(tmpdir(),
|
|
92
|
+
cached = join(mkdtempSync(join(tmpdir(), 'tibodog-')), 'bark.wav');
|
|
86
93
|
writeFileSync(cached, makeBarkWav(3));
|
|
87
94
|
}
|
|
88
95
|
return cached;
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
async function playOnce(file) {
|
|
92
|
-
for (const [cmd, args] of players(file)) {
|
|
93
|
-
if (await run(cmd, args)) return true;
|
|
99
|
+
for (const [cmd, args, env] of players(file)) {
|
|
100
|
+
if (await run(cmd, args, env)) return true;
|
|
94
101
|
}
|
|
95
102
|
return false;
|
|
96
103
|
}
|
|
@@ -119,7 +126,9 @@ export function notify(title, message) {
|
|
|
119
126
|
return Promise.resolve(false);
|
|
120
127
|
}
|
|
121
128
|
|
|
129
|
+
// Only ever a post on x.com (see sources.js): never a file, an app or another scheme from a feed.
|
|
122
130
|
export function openUrl(url) {
|
|
131
|
+
if (!isPostUrl(url)) return Promise.resolve(false);
|
|
123
132
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
124
133
|
return run(cmd, [url]);
|
|
125
134
|
}
|
package/src/sources.js
CHANGED
|
@@ -9,6 +9,53 @@ export class RateLimited extends Error {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// Network limits: a slow, stuck or huge response can't stall the watcher or fill memory.
|
|
13
|
+
const TIMEOUT_MS = 30_000;
|
|
14
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
async function get(url, headers = {}) {
|
|
17
|
+
return fetch(url, { headers: { 'User-Agent': UA, ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function readText(res) {
|
|
21
|
+
if (!res.body) return '';
|
|
22
|
+
const reader = res.body.getReader();
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let size = 0;
|
|
25
|
+
for (;;) {
|
|
26
|
+
const { done, value } = await reader.read();
|
|
27
|
+
if (done) break;
|
|
28
|
+
size += value.length;
|
|
29
|
+
if (size > MAX_BYTES) {
|
|
30
|
+
await reader.cancel();
|
|
31
|
+
throw new Error('response too large');
|
|
32
|
+
}
|
|
33
|
+
chunks.push(value);
|
|
34
|
+
}
|
|
35
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Post text ends up in the terminal, notifications and the widget: no control characters (NULs, ANSI
|
|
39
|
+
// and OSC escapes), only text.
|
|
40
|
+
export const clean = (s) => String(s ?? '').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '');
|
|
41
|
+
|
|
42
|
+
const STATUS = /\/status(?:es)?\/(\d{5,25})(?:\D|$)/;
|
|
43
|
+
|
|
44
|
+
// The only links tibodog ever opens or hands to the widget: a post on x.com, rebuilt from its id.
|
|
45
|
+
// A feed's own <link> could point anywhere (file://, a custom app scheme, a local app).
|
|
46
|
+
export function postUrl(username, id) {
|
|
47
|
+
return /^\d{5,25}$/.test(String(id)) ? `https://x.com/${username || 'i'}/status/${id}` : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isPostUrl(url) {
|
|
51
|
+
try {
|
|
52
|
+
const u = new URL(url);
|
|
53
|
+
return u.protocol === 'https:' && /^(www\.|mobile\.)?(x|twitter)\.com$/.test(u.hostname) && STATUS.test(u.pathname);
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
12
59
|
// --- X API v2 (needs a bearer token; timeline reads require a paid tier) ---
|
|
13
60
|
|
|
14
61
|
export function xSource({ username, bearerToken, missingTokenMessage }) {
|
|
@@ -16,15 +63,14 @@ export function xSource({ username, bearerToken, missingTokenMessage }) {
|
|
|
16
63
|
let userId = null;
|
|
17
64
|
|
|
18
65
|
async function api(path) {
|
|
19
|
-
const res = await
|
|
20
|
-
headers: { Authorization: `Bearer ${bearerToken}`, 'User-Agent': UA },
|
|
21
|
-
});
|
|
66
|
+
const res = await get(`https://api.x.com/2${path}`, { Authorization: `Bearer ${bearerToken}` });
|
|
22
67
|
if (res.status === 429) {
|
|
23
68
|
const reset = Number(res.headers.get('x-rate-limit-reset'));
|
|
24
69
|
throw new RateLimited(reset ? reset * 1000 : Date.now() + 15 * 60_000);
|
|
25
70
|
}
|
|
26
|
-
|
|
27
|
-
|
|
71
|
+
const body = await readText(res);
|
|
72
|
+
if (!res.ok) throw new Error(`X API ${res.status}: ${clean(body).slice(0, 200)}`);
|
|
73
|
+
return JSON.parse(body);
|
|
28
74
|
}
|
|
29
75
|
|
|
30
76
|
return {
|
|
@@ -39,9 +85,9 @@ export function xSource({ username, bearerToken, missingTokenMessage }) {
|
|
|
39
85
|
`/users/${userId}/tweets?max_results=10&exclude=retweets&tweet.fields=created_at,note_tweet`,
|
|
40
86
|
);
|
|
41
87
|
return data.map((t) => ({
|
|
42
|
-
id: t.id,
|
|
43
|
-
text: t.note_tweet?.text ?? t.text,
|
|
44
|
-
url:
|
|
88
|
+
id: String(t.id),
|
|
89
|
+
text: clean(t.note_tweet?.text ?? t.text).trim(),
|
|
90
|
+
url: postUrl(username, t.id),
|
|
45
91
|
createdAt: t.created_at,
|
|
46
92
|
}));
|
|
47
93
|
},
|
|
@@ -49,48 +95,111 @@ export function xSource({ username, bearerToken, missingTokenMessage }) {
|
|
|
49
95
|
}
|
|
50
96
|
|
|
51
97
|
// --- RSS / Atom (Nitter, RSSHub, rss.app, ... anything that mirrors the account) ---
|
|
98
|
+
// Scanned with indexOf rather than regexes over the whole body, so a malformed or hostile feed stays
|
|
99
|
+
// linear time.
|
|
100
|
+
|
|
101
|
+
const MAX_ITEMS = 50;
|
|
102
|
+
const ENTITIES = { lt: '<', gt: '>', quot: '"', apos: "'", amp: '&', nbsp: ' ' };
|
|
52
103
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
.
|
|
58
|
-
|
|
59
|
-
.
|
|
60
|
-
.
|
|
61
|
-
.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
104
|
+
function unwrapCdata(s) {
|
|
105
|
+
let out = '';
|
|
106
|
+
let i = 0;
|
|
107
|
+
for (;;) {
|
|
108
|
+
const start = s.indexOf('<![CDATA[', i);
|
|
109
|
+
if (start === -1) return out + s.slice(i);
|
|
110
|
+
const end = s.indexOf(']]>', start + 9);
|
|
111
|
+
if (end === -1) return out + s.slice(i, start) + s.slice(start + 9);
|
|
112
|
+
out += s.slice(i, start) + s.slice(start + 9, end);
|
|
113
|
+
i = end + 3;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const stripTags = (s) => s.replace(/<[^<>]*>/g, ' ');
|
|
118
|
+
|
|
119
|
+
const entities = (s) =>
|
|
120
|
+
s.replace(/&(#x[0-9a-f]{1,8}|#\d{1,10}|lt|gt|quot|apos|amp|nbsp);/gi, (_, e) => {
|
|
121
|
+
const name = e.toLowerCase();
|
|
122
|
+
if (name[0] !== '#') return ENTITIES[name];
|
|
123
|
+
const n = name[1] === 'x' ? parseInt(name.slice(2), 16) : Number(name.slice(1));
|
|
124
|
+
return n > 0 && n <= 0x10ffff && (n < 0xd800 || n > 0xdfff) ? String.fromCodePoint(n) : '';
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Escaped HTML in descriptions (<p>) only becomes tags after decoding, so strip twice.
|
|
128
|
+
const decode = (s) => clean(stripTags(entities(stripTags(unwrapCdata(s))))).replace(/\s+/g, ' ').trim();
|
|
129
|
+
|
|
130
|
+
// The first <name ...>...</name> (or a self-closing <name .../>) in xml.
|
|
131
|
+
function element(xml, name) {
|
|
132
|
+
const lower = xml.toLowerCase();
|
|
133
|
+
let i = 0;
|
|
134
|
+
while ((i = lower.indexOf(`<${name}`, i)) !== -1) {
|
|
135
|
+
const next = lower[i + name.length + 1];
|
|
136
|
+
if (next === '>' || next === '/' || /\s/.test(next ?? '')) {
|
|
137
|
+
const headEnd = lower.indexOf('>', i);
|
|
138
|
+
if (headEnd === -1) return null;
|
|
139
|
+
const head = xml.slice(i, headEnd + 1);
|
|
140
|
+
if (lower[headEnd - 1] === '/') return { head, inner: '' };
|
|
141
|
+
const close = lower.indexOf(`</${name}`, headEnd);
|
|
142
|
+
if (close === -1) return null;
|
|
143
|
+
return { head, inner: xml.slice(headEnd + 1, close) };
|
|
144
|
+
}
|
|
145
|
+
i += name.length + 1;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const text = (xml, ...names) => {
|
|
151
|
+
for (const n of names) {
|
|
152
|
+
const el = element(xml, n);
|
|
153
|
+
if (el && el.inner.trim()) return decode(el.inner);
|
|
154
|
+
}
|
|
155
|
+
return '';
|
|
69
156
|
};
|
|
70
157
|
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
158
|
+
function items(xml) {
|
|
159
|
+
const lower = xml.toLowerCase();
|
|
160
|
+
const out = [];
|
|
161
|
+
let i = 0;
|
|
162
|
+
while (out.length < MAX_ITEMS) {
|
|
163
|
+
const a = lower.indexOf('<item', i);
|
|
164
|
+
const b = lower.indexOf('<entry', i);
|
|
165
|
+
const start = a === -1 ? b : b === -1 ? a : Math.min(a, b);
|
|
166
|
+
if (start === -1) break;
|
|
167
|
+
const tagName = start === a ? 'item' : 'entry';
|
|
168
|
+
const end = lower.indexOf(`</${tagName}>`, start);
|
|
169
|
+
if (end === -1) break;
|
|
170
|
+
out.push(xml.slice(start, end));
|
|
171
|
+
i = end + tagName.length + 3;
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function parseFeed(xml, username) {
|
|
177
|
+
return items(xml).map((b) => {
|
|
178
|
+
const linkEl = element(b, 'link');
|
|
179
|
+
const href = linkEl?.head.match(/href\s*=\s*"([^"]*)"/i)?.[1] ?? '';
|
|
180
|
+
const link = (linkEl && decode(linkEl.inner)) || decode(href);
|
|
181
|
+
const guid = text(b, 'guid', 'id');
|
|
182
|
+
const title = text(b, 'title');
|
|
183
|
+
const body = text(b, 'description', 'content', 'summary');
|
|
184
|
+
// Mirrors link to themselves (nitter.net/<user>/status/<id>); the status id is what's shared.
|
|
185
|
+
const sid = link.match(STATUS)?.[1] ?? guid.match(STATUS)?.[1] ?? (/^\d{5,25}$/.test(guid) ? guid : null);
|
|
77
186
|
return {
|
|
78
|
-
id:
|
|
187
|
+
id: sid ?? (guid || link),
|
|
79
188
|
text: body.length > title.length ? body : title,
|
|
80
|
-
url:
|
|
81
|
-
createdAt:
|
|
189
|
+
url: postUrl(username, sid),
|
|
190
|
+
createdAt: text(b, 'pubDate', 'published', 'updated'),
|
|
82
191
|
};
|
|
83
192
|
});
|
|
84
193
|
}
|
|
85
194
|
|
|
86
|
-
export function rssSource({ url }) {
|
|
195
|
+
export function rssSource({ url, username }) {
|
|
87
196
|
return {
|
|
88
197
|
name: `RSS ${new URL(url).host}`,
|
|
89
198
|
async fetch() {
|
|
90
|
-
const res = await
|
|
199
|
+
const res = await get(url);
|
|
91
200
|
if (res.status === 429) throw new RateLimited(Date.now() + 5 * 60_000);
|
|
92
201
|
if (!res.ok) throw new Error(`RSS ${res.status}`);
|
|
93
|
-
return parseFeed(await res
|
|
202
|
+
return parseFeed(await readText(res), username);
|
|
94
203
|
},
|
|
95
204
|
};
|
|
96
205
|
}
|