Skip to content

Commit 1834572

Browse files
committed
fix: preserve shared telegram token state
1 parent 841ce46 commit 1834572

4 files changed

Lines changed: 199 additions & 6 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { Database } from "bun:sqlite";
3+
4+
import { redactPersistedTelegramBotToken } from "./loopndroll-actions";
5+
6+
function createTokenStateSchema(db: Database) {
7+
db.exec(`
8+
create table telegram_update_cursors (
9+
bot_token text primary key,
10+
last_update_id integer not null,
11+
updated_at text not null
12+
);
13+
14+
create table telegram_known_chats (
15+
bot_token text not null,
16+
chat_id text not null,
17+
kind text not null,
18+
username text,
19+
display_name text not null,
20+
updated_at text not null,
21+
primary key (bot_token, chat_id)
22+
);
23+
24+
create table session_awaiting_replies (
25+
thread_id text not null,
26+
bot_token text not null,
27+
chat_id text not null,
28+
turn_id text,
29+
started_at text not null,
30+
primary key (thread_id, bot_token, chat_id)
31+
);
32+
33+
create table telegram_delivery_receipts (
34+
id text primary key,
35+
notification_id text,
36+
thread_id text not null,
37+
bot_token text not null,
38+
chat_id text not null,
39+
telegram_message_id integer not null,
40+
created_at text not null
41+
);
42+
`);
43+
}
44+
45+
describe("telegram bot token state redaction", () => {
46+
test("moves token-scoped bridge state to the migration ref without dropping it", () => {
47+
const db = new Database(":memory:");
48+
createTokenStateSchema(db);
49+
db.query(
50+
`insert into telegram_update_cursors (bot_token, last_update_id, updated_at)
51+
values ('plain-token', 41, '2026-04-30T00:00:00.000Z')`,
52+
).run();
53+
db.query(
54+
`insert into telegram_known_chats (
55+
bot_token,
56+
chat_id,
57+
kind,
58+
username,
59+
display_name,
60+
updated_at
61+
) values ('plain-token', '123', 'private', 'user', 'User', '2026-04-30T00:00:00.000Z')`,
62+
).run();
63+
db.query(
64+
`insert into session_awaiting_replies (
65+
thread_id,
66+
bot_token,
67+
chat_id,
68+
turn_id,
69+
started_at
70+
) values ('thread-1', 'plain-token', '123', 'turn-1', '2026-04-30T00:00:00.000Z')`,
71+
).run();
72+
db.query(
73+
`insert into telegram_delivery_receipts (
74+
id,
75+
notification_id,
76+
thread_id,
77+
bot_token,
78+
chat_id,
79+
telegram_message_id,
80+
created_at
81+
) values ('receipt-1', 'notification-1', 'thread-1', 'plain-token', '123', 10, '2026-04-30T00:00:00.000Z')`,
82+
).run();
83+
84+
redactPersistedTelegramBotToken(
85+
db,
86+
"plain-token",
87+
"keychain://loopndroll/telegram-bot-token/notification-1",
88+
);
89+
90+
expect(db.query("select count(*) as count from telegram_update_cursors").get()).toEqual({
91+
count: 1,
92+
});
93+
expect(db.query("select bot_token, last_update_id from telegram_update_cursors").get()).toEqual(
94+
{
95+
bot_token: "keychain://loopndroll/telegram-bot-token/notification-1",
96+
last_update_id: 41,
97+
},
98+
);
99+
expect(db.query("select bot_token, chat_id from telegram_known_chats").get()).toEqual({
100+
bot_token: "keychain://loopndroll/telegram-bot-token/notification-1",
101+
chat_id: "123",
102+
});
103+
expect(db.query("select bot_token, chat_id from session_awaiting_replies").get()).toEqual({
104+
bot_token: "keychain://loopndroll/telegram-bot-token/notification-1",
105+
chat_id: "123",
106+
});
107+
expect(db.query("select bot_token, chat_id from telegram_delivery_receipts").get()).toEqual({
108+
bot_token: "keychain://loopndroll/telegram-bot-token/notification-1",
109+
chat_id: "123",
110+
});
111+
});
112+
});

src/bun/loopndroll-actions.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
import {
4747
deleteSlackWebhookUrlFromKeychain,
4848
deleteTelegramBotTokenFromKeychain,
49+
getTelegramBotTokenMigrationRef,
4950
isSlackWebhookUrlKeychainRef,
5051
isTelegramBotTokenKeychainRef,
5152
resolveSlackWebhookUrl,
@@ -83,7 +84,7 @@ async function redactSecretsFromManagedLogs(secrets: string[]) {
8384
}
8485
}
8586

86-
function redactPersistedTelegramBotToken(
87+
export function redactPersistedTelegramBotToken(
8788
client: ReturnType<typeof getLoopndrollDatabase>["client"],
8889
plaintextBotToken: string,
8990
botTokenRef: string,
@@ -156,6 +157,28 @@ function redactPersistedTelegramBotToken(
156157
})();
157158
}
158159

160+
function deleteTelegramBotTokenFromKeychainIfUnused(
161+
db: ReturnType<typeof getLoopndrollDatabase>["db"],
162+
botTokenOrRef: string | null | undefined,
163+
) {
164+
if (typeof botTokenOrRef !== "string" || !isTelegramBotTokenKeychainRef(botTokenOrRef)) {
165+
return;
166+
}
167+
const botTokenRef = botTokenOrRef.trim();
168+
169+
const remainingRef = db
170+
.select({ id: notifications.id })
171+
.from(notifications)
172+
.where(and(eq(notifications.channel, "telegram"), eq(notifications.botToken, botTokenRef)))
173+
.limit(1)
174+
.get();
175+
if (remainingRef) {
176+
return;
177+
}
178+
179+
deleteTelegramBotTokenFromKeychain(botTokenRef);
180+
}
181+
159182
export async function saveDefaultPrompt(defaultPrompt: string) {
160183
const paths = getLoopndrollPaths();
161184
const { db } = getLoopndrollDatabase(paths.databasePath);
@@ -262,9 +285,8 @@ export async function updateLoopNotification(notification: UpdateLoopNotificatio
262285
if (notification.channel === "slack") {
263286
const previousWebhookUrl =
264287
currentNotification.channel === "slack" ? currentNotification.webhookUrl : "";
265-
if (currentNotification.channel === "telegram") {
266-
deleteTelegramBotTokenFromKeychain(currentNotification.botToken);
267-
}
288+
const previousBotToken =
289+
currentNotification.channel === "telegram" ? currentNotification.botToken : "";
268290
const webhookUrlRef = isSlackWebhookUrlKeychainRef(notification.webhookUrl)
269291
? notification.webhookUrl.trim()
270292
: storeSlackWebhookUrlInKeychain(notification.id, notification.webhookUrl);
@@ -282,6 +304,7 @@ export async function updateLoopNotification(notification: UpdateLoopNotificatio
282304
})
283305
.where(eq(notifications.id, notification.id))
284306
.run();
307+
deleteTelegramBotTokenFromKeychainIfUnused(db, previousBotToken);
285308
} else {
286309
const previousBotToken =
287310
currentNotification.channel === "telegram" ? currentNotification.botToken : "";
@@ -308,6 +331,7 @@ export async function updateLoopNotification(notification: UpdateLoopNotificatio
308331
})
309332
.where(eq(notifications.id, notification.id))
310333
.run();
334+
deleteTelegramBotTokenFromKeychainIfUnused(db, previousBotToken);
311335
}
312336

313337
return loadSnapshot(paths);
@@ -364,7 +388,7 @@ export async function deleteLoopNotification(notificationId: string) {
364388
.where(eq(settings.globalNotificationId, notificationId))
365389
.run();
366390
});
367-
deleteTelegramBotTokenFromKeychain(existingNotification?.botToken);
391+
deleteTelegramBotTokenFromKeychainIfUnused(db, existingNotification?.botToken);
368392
deleteSlackWebhookUrlFromKeychain(existingNotification?.webhookUrl);
369393

370394
return loadSnapshot(paths);
@@ -378,6 +402,7 @@ export async function migrateNotificationSecretsToKeychain() {
378402
.from(notifications)
379403
.orderBy(asc(notifications.createdAt), asc(notifications.id))
380404
.all();
405+
const migratedTelegramBotTokenRefs = new Map<string, string>();
381406

382407
for (const row of existingNotificationRows) {
383408
const currentNotification = mapNotificationRow(row);
@@ -414,7 +439,16 @@ export async function migrateNotificationSecretsToKeychain() {
414439
continue;
415440
}
416441

417-
const botTokenRef = storeTelegramBotTokenInKeychain(currentNotification.id, botToken);
442+
const botTokenMigration = getTelegramBotTokenMigrationRef(
443+
currentNotification.id,
444+
botToken,
445+
migratedTelegramBotTokenRefs,
446+
);
447+
let botTokenRef = botTokenMigration.ref;
448+
if (botTokenMigration.shouldStore) {
449+
botTokenRef = storeTelegramBotTokenInKeychain(currentNotification.id, botToken);
450+
migratedTelegramBotTokenRefs.set(botToken, botTokenRef);
451+
}
418452
redactPersistedTelegramBotToken(client, botToken, botTokenRef);
419453
await redactSecretsFromManagedLogs([botToken]);
420454
db.update(notifications)

src/bun/secret-store.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test";
33
import {
44
createSlackWebhookUrlKeychainRef,
55
createTelegramBotTokenKeychainRef,
6+
getTelegramBotTokenMigrationRef,
67
isSlackWebhookUrlKeychainRef,
78
isTelegramBotTokenKeychainRef,
89
} from "./secret-store";
@@ -19,6 +20,30 @@ describe("telegram bot token keychain refs", () => {
1920
test("does not classify plain Telegram tokens as keychain references", () => {
2021
expect(isTelegramBotTokenKeychainRef("bot-id:opaque-token-value")).toBe(false);
2122
});
23+
24+
test("reuses one migration ref for notifications sharing a plaintext bot token", () => {
25+
const refsByPlaintextToken = new Map<string, string>();
26+
27+
const first = getTelegramBotTokenMigrationRef(
28+
"notification-1",
29+
"bot-id:opaque-token-value",
30+
refsByPlaintextToken,
31+
);
32+
const second = getTelegramBotTokenMigrationRef(
33+
"notification-2",
34+
"bot-id:opaque-token-value",
35+
refsByPlaintextToken,
36+
);
37+
38+
expect(first).toEqual({
39+
ref: "keychain://loopndroll/telegram-bot-token/notification-1",
40+
shouldStore: true,
41+
});
42+
expect(second).toEqual({
43+
ref: first.ref,
44+
shouldStore: false,
45+
});
46+
});
2247
});
2348

2449
describe("slack webhook URL keychain refs", () => {

src/bun/secret-store.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,28 @@ export function createSlackWebhookUrlKeychainRef(notificationId: string) {
4545
return `${SLACK_WEBHOOK_URL_REF_PREFIX}${encodeURIComponent(encodeKeychainAccount(notificationId))}`;
4646
}
4747

48+
export function getTelegramBotTokenMigrationRef(
49+
notificationId: string,
50+
botToken: string,
51+
refsByPlaintextToken: Map<string, string>,
52+
) {
53+
const normalizedBotToken = botToken.trim();
54+
const existingRef = refsByPlaintextToken.get(normalizedBotToken);
55+
if (existingRef) {
56+
return {
57+
ref: existingRef,
58+
shouldStore: false,
59+
};
60+
}
61+
62+
const ref = createTelegramBotTokenKeychainRef(notificationId);
63+
refsByPlaintextToken.set(normalizedBotToken, ref);
64+
return {
65+
ref,
66+
shouldStore: true,
67+
};
68+
}
69+
4870
function runSecurityCommand(args: string[]) {
4971
const result = spawnSync("/usr/bin/security", args, {
5072
encoding: "utf8",

0 commit comments

Comments
 (0)