@aaroncql/pim-agent 0.0.1

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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,275 @@
1
+ import { chmod, mkdtemp, rm, stat } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
+
6
+ import { Fs } from "../shared/Fs";
7
+ import { Config, type TelegramConfig } from "./Config";
8
+
9
+ const ENV_KEYS = [
10
+ "PIM_TELEGRAM_BOT_TOKEN",
11
+ "PIM_TELEGRAM_ALLOW",
12
+ "PIM_HOME_DIR",
13
+ ] as const;
14
+
15
+ let savedEnv: Record<string, string | undefined>;
16
+ let tmp: string;
17
+
18
+ beforeEach(async () => {
19
+ savedEnv = {};
20
+ for (const k of ENV_KEYS) {
21
+ savedEnv[k] = process.env[k];
22
+ delete process.env[k];
23
+ }
24
+ tmp = await mkdtemp(join(tmpdir(), "pim-telegram-test-"));
25
+ });
26
+
27
+ afterEach(async () => {
28
+ for (const k of ENV_KEYS) {
29
+ if (savedEnv[k] === undefined) {
30
+ delete process.env[k];
31
+ } else {
32
+ process.env[k] = savedEnv[k];
33
+ }
34
+ }
35
+ await rm(tmp, { recursive: true, force: true });
36
+ });
37
+
38
+ describe("Config.parseArgs", () => {
39
+ test("parses space-separated flags", () => {
40
+ const cli = Config.parseArgs([
41
+ "--mode",
42
+ "telegram",
43
+ "--token",
44
+ "abc:xyz",
45
+ "--allow",
46
+ "111,222",
47
+ "--cwd",
48
+ "/work",
49
+ "--model",
50
+ "sonnet",
51
+ "--config-dir",
52
+ "/c",
53
+ ]);
54
+ expect(cli).toEqual({
55
+ token: "abc:xyz",
56
+ allow: "111,222",
57
+ cwd: "/work",
58
+ model: "sonnet",
59
+ configDir: "/c",
60
+ printConfig: false,
61
+ });
62
+ });
63
+
64
+ test("parses --key=value form", () => {
65
+ const cli = Config.parseArgs([
66
+ "--mode=telegram",
67
+ "--token=abc",
68
+ "--allow=1",
69
+ ]);
70
+ expect(cli.token).toBe("abc");
71
+ expect(cli.allow).toBe("1");
72
+ });
73
+
74
+ test("--print-config is a boolean flag", () => {
75
+ const cli = Config.parseArgs(["--print-config", "--token", "t"]);
76
+ expect(cli.printConfig).toBe(true);
77
+ expect(cli.token).toBe("t");
78
+ });
79
+
80
+ test("ignores unknown positional args and unknown flags", () => {
81
+ const cli = Config.parseArgs([
82
+ "positional",
83
+ "--unknown",
84
+ "x",
85
+ "--token",
86
+ "t",
87
+ ]);
88
+ expect(cli.token).toBe("t");
89
+ });
90
+
91
+ test("consumes --mode value so it does not leak into the next flag", () => {
92
+ const cli = Config.parseArgs(["--mode", "telegram", "--token", "t"]);
93
+ expect(cli.token).toBe("t");
94
+ });
95
+ });
96
+
97
+ describe("Config.load precedence", () => {
98
+ test("CLI wins over env and file", async () => {
99
+ process.env.PIM_TELEGRAM_BOT_TOKEN = "env-tok";
100
+ process.env.PIM_TELEGRAM_ALLOW = "9";
101
+ await Bun.write(
102
+ join(tmp, "config.json"),
103
+ JSON.stringify({ token: "file-tok", allow: [1] })
104
+ );
105
+ const cfg = await Config.load({
106
+ token: "cli-tok",
107
+ allow: "2,3",
108
+ configDir: tmp,
109
+ printConfig: false,
110
+ });
111
+ expect(cfg.token).toBe("cli-tok");
112
+ expect(cfg.allow).toEqual([2, 3]);
113
+ });
114
+
115
+ test("env wins over file when no CLI value", async () => {
116
+ process.env.PIM_TELEGRAM_BOT_TOKEN = "env-tok";
117
+ process.env.PIM_TELEGRAM_ALLOW = "9";
118
+ await Bun.write(
119
+ join(tmp, "config.json"),
120
+ JSON.stringify({ token: "file-tok", allow: [1] })
121
+ );
122
+ const cfg = await Config.load({
123
+ configDir: tmp,
124
+ printConfig: false,
125
+ });
126
+ expect(cfg.token).toBe("env-tok");
127
+ expect(cfg.allow).toEqual([9]);
128
+ });
129
+
130
+ test("file values used when neither CLI nor env set", async () => {
131
+ await Bun.write(
132
+ join(tmp, "config.json"),
133
+ JSON.stringify({ token: "file-tok", allow: [1, 2], cwd: "/from-file" })
134
+ );
135
+ const cfg = await Config.load({
136
+ configDir: tmp,
137
+ printConfig: false,
138
+ });
139
+ expect(cfg.token).toBe("file-tok");
140
+ expect(cfg.allow).toEqual([1, 2]);
141
+ expect(cfg.cwd).toBe("/from-file");
142
+ });
143
+
144
+ test("PIM_HOME_DIR resolves the config directory", async () => {
145
+ process.env.PIM_HOME_DIR = tmp;
146
+ process.env.PIM_TELEGRAM_BOT_TOKEN = "t";
147
+ const cfg = await Config.load({ printConfig: false });
148
+ expect(cfg.configDir).toBe(join(tmp, "telegram"));
149
+ });
150
+
151
+ test("throws when no token from any source", async () => {
152
+ await expect(
153
+ Config.load({ configDir: tmp, printConfig: false })
154
+ ).rejects.toThrow(/token required/i);
155
+ });
156
+
157
+ test("missing config.json is not an error", async () => {
158
+ const cfg = await Config.load({
159
+ token: "t",
160
+ configDir: tmp,
161
+ printConfig: false,
162
+ });
163
+ expect(cfg.allow).toEqual([]);
164
+ });
165
+
166
+ test("rejects non-numeric allow entries", async () => {
167
+ await expect(
168
+ Config.load({
169
+ token: "t",
170
+ allow: "1,abc",
171
+ configDir: tmp,
172
+ printConfig: false,
173
+ })
174
+ ).rejects.toThrow(/Invalid chat ID/);
175
+ });
176
+
177
+ test("trims whitespace and drops empty entries in allow", async () => {
178
+ const cfg = await Config.load({
179
+ token: "t",
180
+ allow: " 1 , ,2 ",
181
+ configDir: tmp,
182
+ printConfig: false,
183
+ });
184
+ expect(cfg.allow).toEqual([1, 2]);
185
+ });
186
+
187
+ test("accepts allow as a single number in config.json", async () => {
188
+ await Bun.write(
189
+ join(tmp, "config.json"),
190
+ JSON.stringify({ token: "t", allow: 12345 })
191
+ );
192
+ const cfg = await Config.load({ configDir: tmp, printConfig: false });
193
+ expect(cfg.allow).toEqual([12345]);
194
+ });
195
+
196
+ test("accepts allow as a comma-separated string in config.json", async () => {
197
+ await Bun.write(
198
+ join(tmp, "config.json"),
199
+ JSON.stringify({ token: "t", allow: "1, 2 ,3" })
200
+ );
201
+ const cfg = await Config.load({ configDir: tmp, printConfig: false });
202
+ expect(cfg.allow).toEqual([1, 2, 3]);
203
+ });
204
+
205
+ test("accepts allow as a mixed-type array in config.json", async () => {
206
+ await Bun.write(
207
+ join(tmp, "config.json"),
208
+ JSON.stringify({ token: "t", allow: [1, "2", 3] })
209
+ );
210
+ const cfg = await Config.load({ configDir: tmp, printConfig: false });
211
+ expect(cfg.allow).toEqual([1, 2, 3]);
212
+ });
213
+
214
+ test("malformed config.json surfaces a clear error", async () => {
215
+ await Bun.write(join(tmp, "config.json"), "{not json");
216
+ await expect(
217
+ Config.load({ token: "t", configDir: tmp, printConfig: false })
218
+ ).rejects.toThrow(/Failed to parse/);
219
+ });
220
+ });
221
+
222
+ describe("Config.save + Fs.writeAtomic", () => {
223
+ test("save writes a file readable by load", async () => {
224
+ const cfg: TelegramConfig = {
225
+ token: "tok",
226
+ allow: [1, 2],
227
+ cwd: "/work",
228
+ model: "sonnet",
229
+ configDir: tmp,
230
+ };
231
+ await Config.save(cfg);
232
+ const reloaded = await Config.load({
233
+ configDir: tmp,
234
+ printConfig: false,
235
+ });
236
+ expect(reloaded.token).toBe("tok");
237
+ expect(reloaded.allow).toEqual([1, 2]);
238
+ expect(reloaded.cwd).toBe("/work");
239
+ expect(reloaded.model).toBe("sonnet");
240
+ });
241
+
242
+ test("writeAtomic applies explicit mode", async () => {
243
+ const path = join(tmp, "secret.json");
244
+ await Fs.writeAtomic(path, "{}", 0o600);
245
+ const s = await stat(path);
246
+ expect(s.mode & 0o777).toBe(0o600);
247
+ });
248
+
249
+ test("writeAtomic preserves an existing file's mode when no mode is passed", async () => {
250
+ const path = join(tmp, "existing.json");
251
+ await Bun.write(path, "{}");
252
+ await chmod(path, 0o640);
253
+ await Fs.writeAtomic(path, "{}");
254
+ const s = await stat(path);
255
+ expect(s.mode & 0o777).toBe(0o640);
256
+ });
257
+
258
+ test("Config.save writes config.json as 0600", async () => {
259
+ await Config.save({
260
+ token: "tok",
261
+ allow: [],
262
+ cwd: "/work",
263
+ configDir: tmp,
264
+ });
265
+ const s = await stat(join(tmp, "config.json"));
266
+ expect(s.mode & 0o777).toBe(0o600);
267
+ });
268
+
269
+ test("writeAtomic leaves no temp files behind", async () => {
270
+ await Fs.writeAtomic(join(tmp, "x.json"), "{}");
271
+ const glob = new Bun.Glob("x.json.tmp-*");
272
+ const leftovers = await Array.fromAsync(glob.scan({ cwd: tmp }));
273
+ expect(leftovers).toEqual([]);
274
+ });
275
+ });
@@ -0,0 +1,162 @@
1
+ import { join } from "node:path";
2
+
3
+ import { Fs } from "../shared/Fs";
4
+ import { Paths } from "../shared/Paths";
5
+
6
+ export type Cli = {
7
+ readonly token?: string;
8
+ readonly allow?: string;
9
+ readonly cwd?: string;
10
+ readonly model?: string;
11
+ readonly configDir?: string;
12
+ readonly printConfig: boolean;
13
+ };
14
+
15
+ export type TelegramConfig = {
16
+ readonly token: string;
17
+ readonly allow: ReadonlyArray<number>;
18
+ readonly cwd: string;
19
+ readonly model?: string;
20
+ readonly configDir: string;
21
+ };
22
+
23
+ export const THINKING_LEVELS = [
24
+ "off",
25
+ "minimal",
26
+ "low",
27
+ "medium",
28
+ "high",
29
+ "xhigh",
30
+ ] as const;
31
+ export type ThinkingLevelOpt = (typeof THINKING_LEVELS)[number];
32
+
33
+ export const LOGS_MODES = ["off", "tool", "text", "verbose"] as const;
34
+ export type LogsMode = (typeof LOGS_MODES)[number];
35
+
36
+ export class Config {
37
+ public static parseArgs(args: ReadonlyArray<string>): Cli {
38
+ let token: string | undefined;
39
+ let allow: string | undefined;
40
+ let cwd: string | undefined;
41
+ let model: string | undefined;
42
+ let configDir: string | undefined;
43
+ let printConfig = false;
44
+
45
+ for (let i = 0; i < args.length; i++) {
46
+ const arg = args[i]!;
47
+ if (!arg.startsWith("--")) {
48
+ continue;
49
+ }
50
+ const eqIdx = arg.indexOf("=");
51
+ const key = eqIdx >= 0 ? arg.slice(0, eqIdx) : arg;
52
+ const inline = eqIdx >= 0 ? arg.slice(eqIdx + 1) : undefined;
53
+ const take = (): string | undefined => {
54
+ if (inline !== undefined) {
55
+ return inline;
56
+ }
57
+ i += 1;
58
+ return args[i];
59
+ };
60
+
61
+ switch (key) {
62
+ case "--token":
63
+ token = take();
64
+ break;
65
+ case "--allow":
66
+ allow = take();
67
+ break;
68
+ case "--cwd":
69
+ cwd = take();
70
+ break;
71
+ case "--model":
72
+ model = take();
73
+ break;
74
+ case "--config-dir":
75
+ configDir = take();
76
+ break;
77
+ case "--print-config":
78
+ printConfig = true;
79
+ break;
80
+ case "--mode":
81
+ take();
82
+ break;
83
+ }
84
+ }
85
+ return { token, allow, cwd, model, configDir, printConfig };
86
+ }
87
+
88
+ public static async load(cli: Cli): Promise<TelegramConfig> {
89
+ const configDir = cli.configDir ?? join(Paths.pimHomeDir(), "telegram");
90
+
91
+ const filePath = join(configDir, "config.json");
92
+ const fileConfig = await Fs.readJsonOrEmpty<Partial<TelegramConfig>>(
93
+ filePath,
94
+ {}
95
+ );
96
+
97
+ const token =
98
+ cli.token ?? process.env.PIM_TELEGRAM_BOT_TOKEN ?? fileConfig.token;
99
+ if (!token) {
100
+ throw new Error(
101
+ "Bot token required (set --token, PIM_TELEGRAM_BOT_TOKEN, or 'token' in config.json)"
102
+ );
103
+ }
104
+
105
+ const allowSrc = cli.allow ?? process.env.PIM_TELEGRAM_ALLOW;
106
+ const allow = allowSrc
107
+ ? Config.parseAllow(allowSrc)
108
+ : Config.normalizeAllow(fileConfig.allow);
109
+
110
+ const cwd = cli.cwd ?? fileConfig.cwd ?? process.cwd();
111
+ const model = cli.model ?? fileConfig.model;
112
+
113
+ return { token, allow, cwd, model, configDir };
114
+ }
115
+
116
+ public static async save(config: TelegramConfig): Promise<void> {
117
+ const filePath = join(config.configDir, "config.json");
118
+ const data = JSON.stringify(
119
+ {
120
+ token: config.token,
121
+ allow: config.allow,
122
+ cwd: config.cwd,
123
+ model: config.model,
124
+ },
125
+ null,
126
+ 2
127
+ );
128
+ await Fs.writeAtomic(filePath, data, 0o600);
129
+ }
130
+
131
+ private static parseAllow(s: string): ReadonlyArray<number> {
132
+ return s
133
+ .split(",")
134
+ .map((x) => x.trim())
135
+ .filter((x) => x.length > 0)
136
+ .map((x) => {
137
+ const n = Number(x);
138
+ if (!Number.isFinite(n)) {
139
+ throw new Error(`Invalid chat ID in allow list: ${x}`);
140
+ }
141
+ return n;
142
+ });
143
+ }
144
+
145
+ private static normalizeAllow(value: unknown): ReadonlyArray<number> {
146
+ if (value === undefined || value === null) {
147
+ return [];
148
+ }
149
+ if (typeof value === "string") {
150
+ return Config.parseAllow(value);
151
+ }
152
+ if (typeof value === "number") {
153
+ return Config.parseAllow(String(value));
154
+ }
155
+ if (Array.isArray(value)) {
156
+ return Config.parseAllow(value.join(","));
157
+ }
158
+ throw new Error(
159
+ `Invalid 'allow' in config.json: expected number, string, or array, got ${typeof value}`
160
+ );
161
+ }
162
+ }
@@ -0,0 +1,143 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { Markdown } from "./Markdown";
4
+
5
+ describe("toHtml", () => {
6
+ test("escapes html-special characters in plain text", () => {
7
+ expect(Markdown.toHtml("a < b & c > d")).toBe("a &lt; b &amp; c &gt; d");
8
+ });
9
+
10
+ test("bold + italic + strike + inline code", () => {
11
+ expect(Markdown.toHtml("**b** *i* ~~s~~ `c`")).toBe(
12
+ "<b>b</b> <i>i</i> <s>s</s> <code>c</code>"
13
+ );
14
+ });
15
+
16
+ test("headings: h1 is underline+bold, others bold", () => {
17
+ expect(Markdown.toHtml("# H1")).toBe("<u><b>H1</b></u>");
18
+ expect(Markdown.toHtml("## H2")).toBe("<b>H2</b>");
19
+ expect(Markdown.toHtml("### H3")).toBe("<b>H3</b>");
20
+ });
21
+
22
+ test("fenced code block with language", () => {
23
+ expect(Markdown.toHtml("```ts\nconst x = 1;\n```")).toBe(
24
+ '<pre><code class="language-ts">const x = 1;</code></pre>'
25
+ );
26
+ });
27
+
28
+ test("fenced code block without language", () => {
29
+ expect(Markdown.toHtml("```\nplain\n```")).toBe("<pre>plain</pre>");
30
+ });
31
+
32
+ test("escapes html inside code block", () => {
33
+ expect(Markdown.toHtml("```\na <b> & c\n```")).toBe(
34
+ "<pre>a &lt;b&gt; &amp; c</pre>"
35
+ );
36
+ });
37
+
38
+ test("safe links pass through, javascript: dropped", () => {
39
+ expect(Markdown.toHtml("[ok](https://example.com)")).toBe(
40
+ '<a href="https://example.com">ok</a>'
41
+ );
42
+ expect(Markdown.toHtml("[bad](javascript:alert(1))")).toBe("bad");
43
+ });
44
+
45
+ test("images render as link to src", () => {
46
+ expect(Markdown.toHtml("![alt](https://e.com/a.png)")).toBe(
47
+ '<a href="https://e.com/a.png">alt</a>'
48
+ );
49
+ });
50
+
51
+ test("blockquote wraps multi-line content", () => {
52
+ expect(Markdown.toHtml("> a\n> b")).toBe("<blockquote>a\nb</blockquote>");
53
+ });
54
+
55
+ test("unordered list bullets and nested ◦", () => {
56
+ const out = Markdown.toHtml("- one\n- two\n - nested\n - also");
57
+ expect(out).toBe("• one\n• two\n ◦ nested\n ◦ also");
58
+ });
59
+
60
+ test("ordered list renders 1. 2. markers", () => {
61
+ expect(Markdown.toHtml("1. a\n2. b\n3. c")).toBe("1. a\n2. b\n3. c");
62
+ });
63
+
64
+ test("task list checkboxes", () => {
65
+ const out = Markdown.toHtml("- [x] done\n- [ ] todo");
66
+ expect(out).toBe("✅ done\n⬜ todo");
67
+ });
68
+
69
+ test("thematic break renders em-dashes", () => {
70
+ expect(Markdown.toHtml("a\n\n───\n\nb")).toBe("a\n\n───\n\nb");
71
+ });
72
+
73
+ test("collapses 3+ newlines to two", () => {
74
+ expect(Markdown.toHtml("a\n\n\n\nb")).toBe("a\n\nb");
75
+ });
76
+
77
+ test("trims leading and trailing whitespace", () => {
78
+ expect(Markdown.toHtml("\n\nhello\n\n")).toBe("hello");
79
+ });
80
+
81
+ test("table renders as vertical labeled cards", () => {
82
+ const md = "| Name | Score |\n| ---- | ----- |\n| Aaron | 99 |\n| Bo | 7 |";
83
+ expect(Markdown.toHtml(md)).toBe(
84
+ "───\n<b>Name</b>: Aaron\n<b>Score</b>: 99\n───\n<b>Name</b>: Bo\n<b>Score</b>: 7\n───"
85
+ );
86
+ });
87
+
88
+ test("table renders markdown formatting in cells", () => {
89
+ const md = "| a | b |\n| - | - |\n| **x** | `y` |";
90
+ const out = Markdown.toHtml(md);
91
+ expect(out).toContain("<b>a</b>: <b>x</b>");
92
+ expect(out).toContain("<b>b</b>: <code>y</code>");
93
+ });
94
+
95
+ test("table escapes html inside cells", () => {
96
+ const md = "| a | b |\n| - | - |\n| <x> | & |";
97
+ const out = Markdown.toHtml(md);
98
+ expect(out).toContain("&lt;x&gt;");
99
+ expect(out).toContain("&amp;");
100
+ expect(out).not.toContain("<pre>");
101
+ });
102
+
103
+ test("table surrounded by prose is rendered with both segments", () => {
104
+ const md = "Hello\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\nDone.";
105
+ const out = Markdown.toHtml(md);
106
+ expect(out.startsWith("Hello")).toBe(true);
107
+ expect(out).toContain("───");
108
+ expect(out).toContain("<b>a</b>: 1");
109
+ expect(out).toContain("<b>b</b>: 2");
110
+ expect(out.endsWith("Done.")).toBe(true);
111
+ });
112
+
113
+ test("link inside emphasis nests correctly", () => {
114
+ expect(Markdown.toHtml("*[a](https://e.com)*")).toBe(
115
+ '<i><a href="https://e.com">a</a></i>'
116
+ );
117
+ });
118
+
119
+ test("paragraphs are separated by blank line", () => {
120
+ expect(Markdown.toHtml("one\n\ntwo")).toBe("one\n\ntwo");
121
+ });
122
+
123
+ test("inline html in source is escaped, never passed through", () => {
124
+ expect(Markdown.toHtml("hello <script>alert(1)</script>")).toContain(
125
+ "&lt;script&gt;"
126
+ );
127
+ });
128
+
129
+ test("empty input returns empty string", () => {
130
+ expect(Markdown.toHtml("")).toBe("");
131
+ expect(Markdown.toHtml(" \n\n ")).toBe("");
132
+ });
133
+
134
+ test("escape covers all four Telegram-supported named entities", () => {
135
+ expect(Markdown.escape('& < > "')).toBe("&amp; &lt; &gt; &quot;");
136
+ });
137
+
138
+ test('link href containing " is escaped to &quot;', () => {
139
+ expect(Markdown.toHtml('[x](https://e.com/?q="a")')).toBe(
140
+ '<a href="https://e.com/?q=&quot;a&quot;">x</a>'
141
+ );
142
+ });
143
+ });