tibodog 0.1.2 → 0.1.4
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 +7 -2
- 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 +66 -29
- package/package.json +3 -2
- package/src/bark.js +18 -9
- package/src/detect.js +1 -1
- package/src/i18n.js +2 -2
- package/src/sources.js +146 -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,10 @@ 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
|
+
// RSS mirrors are mostly volunteer-run and refresh every few minutes at best: 5 minutes is plenty.
|
|
158
|
+
const seconds = Number(opt.interval) || 300;
|
|
159
|
+
const intervalMs = Math.min(Math.max(seconds, 15), 86_400) * 1000;
|
|
126
160
|
let primed = !firstRun || opt['bark-old'];
|
|
127
161
|
|
|
128
162
|
async function tick() {
|
|
@@ -131,14 +165,15 @@ async function tick() {
|
|
|
131
165
|
let barked = 0;
|
|
132
166
|
for (const p of fresh) {
|
|
133
167
|
seen.add(p.id);
|
|
134
|
-
if (primed && isResetPost(p.text,
|
|
168
|
+
if (primed && isResetPost(p.text, keywords)) {
|
|
135
169
|
barked++;
|
|
136
170
|
await alert(p);
|
|
137
171
|
}
|
|
138
172
|
}
|
|
139
173
|
if (!primed) log(t.primed(posts.length));
|
|
140
174
|
else if (fresh.length && !barked) log(t.quiet(fresh.length));
|
|
141
|
-
|
|
175
|
+
// An empty first fetch (a mirror's error page) doesn't count: priming waits for real posts.
|
|
176
|
+
if (posts.length) primed = true;
|
|
142
177
|
save();
|
|
143
178
|
}
|
|
144
179
|
|
|
@@ -149,7 +184,9 @@ while (true) {
|
|
|
149
184
|
let wait = intervalMs;
|
|
150
185
|
try {
|
|
151
186
|
await tick();
|
|
187
|
+
process.exitCode = 0;
|
|
152
188
|
} catch (e) {
|
|
189
|
+
process.exitCode = 1; // --once: let cron see a failed check
|
|
153
190
|
if (e instanceof RateLimited) {
|
|
154
191
|
wait = Math.max(intervalMs, e.retryAt - Date.now() + 1000);
|
|
155
192
|
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.4",
|
|
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/detect.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const RESET = /\b(re-?set(s|ting)?|refresh(ed|ing)?|wiped)\b|리셋|초기화/i;
|
|
4
4
|
const CONTEXT = /\b(limits?|usage|rate|quotas?|codex|plans?|weekly|5h|allowance)\b|사용량|한도/i;
|
|
5
5
|
// How Tibo announces a reset on its own, with no "limits" in the post ("Day 21/ ... But today is a reset.").
|
|
6
|
-
const DECLARED = /\b(
|
|
6
|
+
const DECLARED = /\b((is|that'?s|that’s) a reset|reset day|and reset|(have|has|been|just) reset)\b/i;
|
|
7
7
|
// Cheap guard against obvious non-announcements ("password reset", "factory reset").
|
|
8
8
|
const NOISE = /\b(password|factory|hard reset|git reset)\b/i;
|
|
9
9
|
|
package/src/i18n.js
CHANGED
|
@@ -30,7 +30,7 @@ Sources (pick one):
|
|
|
30
30
|
|
|
31
31
|
Options:
|
|
32
32
|
--user <handle> account to watch (default: thsottiaux)
|
|
33
|
-
--interval <sec> poll interval (default:
|
|
33
|
+
--interval <sec> poll interval (default: 300)
|
|
34
34
|
--keyword <text> extra trigger phrase, repeatable
|
|
35
35
|
--barks <n> times to play the bark per alert (default: 1)
|
|
36
36
|
--sound <file.wav|synth> your own dog, or 'synth' for the synthesized bark
|
|
@@ -73,7 +73,7 @@ Options:
|
|
|
73
73
|
|
|
74
74
|
옵션:
|
|
75
75
|
--user <handle> 감시할 계정 (기본: thsottiaux)
|
|
76
|
-
--interval <초> 확인 주기 (기본:
|
|
76
|
+
--interval <초> 확인 주기 (기본: 300초)
|
|
77
77
|
--keyword <텍스트> 추가 트리거 문구, 여러 번 지정 가능
|
|
78
78
|
--barks <n> 알림당 짖는 소리 반복 횟수 (기본: 1)
|
|
79
79
|
--sound <file.wav|synth> 내 개 소리 파일, 또는 synth로 합성 소리 사용
|
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,112 @@ 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, tagName) {
|
|
132
|
+
const name = tagName.toLowerCase(); // pubDate, ...
|
|
133
|
+
const lower = xml.toLowerCase();
|
|
134
|
+
let i = 0;
|
|
135
|
+
while ((i = lower.indexOf(`<${name}`, i)) !== -1) {
|
|
136
|
+
const next = lower[i + name.length + 1];
|
|
137
|
+
if (next === '>' || next === '/' || /\s/.test(next ?? '')) {
|
|
138
|
+
const headEnd = lower.indexOf('>', i);
|
|
139
|
+
if (headEnd === -1) return null;
|
|
140
|
+
const head = xml.slice(i, headEnd + 1);
|
|
141
|
+
if (lower[headEnd - 1] === '/') return { head, inner: '' };
|
|
142
|
+
const close = lower.indexOf(`</${name}`, headEnd);
|
|
143
|
+
if (close === -1) return null;
|
|
144
|
+
return { head, inner: xml.slice(headEnd + 1, close) };
|
|
145
|
+
}
|
|
146
|
+
i += name.length + 1;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const text = (xml, ...names) => {
|
|
152
|
+
for (const n of names) {
|
|
153
|
+
const el = element(xml, n);
|
|
154
|
+
if (el && el.inner.trim()) return decode(el.inner);
|
|
155
|
+
}
|
|
156
|
+
return '';
|
|
69
157
|
};
|
|
70
158
|
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
159
|
+
function items(xml) {
|
|
160
|
+
const lower = xml.toLowerCase();
|
|
161
|
+
const out = [];
|
|
162
|
+
let i = 0;
|
|
163
|
+
while (out.length < MAX_ITEMS) {
|
|
164
|
+
const a = lower.indexOf('<item', i);
|
|
165
|
+
const b = lower.indexOf('<entry', i);
|
|
166
|
+
const start = a === -1 ? b : b === -1 ? a : Math.min(a, b);
|
|
167
|
+
if (start === -1) break;
|
|
168
|
+
const tagName = start === a ? 'item' : 'entry';
|
|
169
|
+
const end = lower.indexOf(`</${tagName}>`, start);
|
|
170
|
+
if (end === -1) break;
|
|
171
|
+
out.push(xml.slice(start, end));
|
|
172
|
+
i = end + tagName.length + 3;
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parseFeed(xml, username) {
|
|
178
|
+
return items(xml).map((b) => {
|
|
179
|
+
const linkEl = element(b, 'link');
|
|
180
|
+
const href = linkEl?.head.match(/href\s*=\s*"([^"]*)"/i)?.[1] ?? '';
|
|
181
|
+
const link = (linkEl && decode(linkEl.inner)) || decode(href);
|
|
182
|
+
const guid = text(b, 'guid', 'id');
|
|
183
|
+
const title = text(b, 'title');
|
|
184
|
+
const body = text(b, 'description', 'content', 'summary');
|
|
185
|
+
// Mirrors link to themselves (nitter.net/<user>/status/<id>); the status id is what's shared.
|
|
186
|
+
const sid = link.match(STATUS)?.[1] ?? guid.match(STATUS)?.[1] ?? (/^\d{5,25}$/.test(guid) ? guid : null);
|
|
77
187
|
return {
|
|
78
|
-
id:
|
|
188
|
+
id: sid ?? (guid || link),
|
|
79
189
|
text: body.length > title.length ? body : title,
|
|
80
|
-
url:
|
|
81
|
-
createdAt:
|
|
190
|
+
url: postUrl(username, sid),
|
|
191
|
+
createdAt: text(b, 'pubDate', 'published', 'updated'),
|
|
82
192
|
};
|
|
83
193
|
});
|
|
84
194
|
}
|
|
85
195
|
|
|
86
|
-
export function rssSource({ url }) {
|
|
196
|
+
export function rssSource({ url, username }) {
|
|
87
197
|
return {
|
|
88
198
|
name: `RSS ${new URL(url).host}`,
|
|
89
199
|
async fetch() {
|
|
90
|
-
const res = await
|
|
200
|
+
const res = await get(url);
|
|
91
201
|
if (res.status === 429) throw new RateLimited(Date.now() + 5 * 60_000);
|
|
92
202
|
if (!res.ok) throw new Error(`RSS ${res.status}`);
|
|
93
|
-
return parseFeed(await res
|
|
203
|
+
return parseFeed(await readText(res), username);
|
|
94
204
|
},
|
|
95
205
|
};
|
|
96
206
|
}
|