Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
ee33c54
feat(cli): add provider-agnostic channel bridge core
cdenneen Mar 6, 2026
396a3b6
feat(cli): add telegram channel adapter, scoped bridge config, and di…
cdenneen Mar 6, 2026
d74e2a3
Harden bridge runtime, relay validation, and diagnostics
cdenneen Mar 6, 2026
c3426ef
Decouple bridge bootstrap from machine registration
cdenneen Mar 6, 2026
7b9ef94
fix bridge hardening edge cases from PR review
cdenneen Mar 6, 2026
9e46e58
address latest PR115 bot findings across bridge runtime
cdenneen Mar 6, 2026
33c9c74
harden webhook diagnostics and loopback validation
cdenneen Mar 6, 2026
6445031
scope bridge KV by account and harden webhook relay
cdenneen Mar 6, 2026
d993368
harden bridge config persistence and diagnostics
cdenneen Mar 6, 2026
82b6ab3
optimize session listing and tighten bridge docs/tests
cdenneen Mar 6, 2026
a30a9ed
stabilize cache-fallback regression test
cdenneen Mar 6, 2026
e9d41ff
clarify ack-on-failure bridge warning
cdenneen Mar 6, 2026
22479d6
Harden telegram bridge config, worker timeouts, and KV rollback
cdenneen Mar 6, 2026
377c170
Address PR #115 review feedback on config precedence and rollback safety
cdenneen Mar 6, 2026
bc77848
fix(cli): resolve PR115 review findings for telegram bridge
cdenneen Mar 6, 2026
48d2a6e
fix(cli): harden telegram adapter ack and kv validation
cdenneen Mar 6, 2026
da20c82
chore(cli): address follow-up reviewer nitpicks on bridge runtime
cdenneen Mar 6, 2026
f291e28
fix(cli): close remaining review gaps in bridge and doctor flows
cdenneen Mar 7, 2026
6afcaff
fix(cli): harden scoped fallbacks and inbound dedupe retry semantics
cdenneen Mar 7, 2026
10a7e49
fix(cli): align bridge tick deduper type with runtime API
cdenneen Mar 7, 2026
da28bfa
fix(cli): advance bridge cursor across non-agent transcript windows
cdenneen Mar 7, 2026
49ed762
fix(cli): harden command acking and startup cleanup for bridge worker
cdenneen Mar 7, 2026
d047b77
fix(cli): harden inbound ack paths and stable local ids for channel b…
cdenneen Mar 7, 2026
b5c7431
fix(cli): guard binding-failure reply path and preserve ack progression
cdenneen Mar 7, 2026
9b48d5d
fix(cli): guard session-forward failure replies to preserve ack seman…
cdenneen Mar 7, 2026
fc7fc16
fix(cli): validate tickMs writes and warn on unextractable agent rows
cdenneen Mar 7, 2026
06ebe6d
fix(cli): match telegram permanent-failure detection to typed descrip…
cdenneen Mar 7, 2026
20684f5
fix(cli): align bridge config parsing, idempotency, and key normaliza…
cdenneen Mar 7, 2026
b40be4d
fix(cli): validate env webhook secret token at config load
cdenneen Mar 7, 2026
38dcc37
test(cli): harden bridge startup tests and enforce message ids
cdenneen Mar 7, 2026
a3dc2d9
fix(cli): tighten telegram KV chat-id validation and add module header
cdenneen Mar 7, 2026
7d1de59
feat(protocol): add channel bridge feature ids
leeroybrun Mar 26, 2026
cdd4f5f
feat(protocol): add channel bridges gates payload
leeroybrun Mar 26, 2026
2f464a7
feat(server): add channel bridges feature gates
leeroybrun Mar 26, 2026
ba4004a
feat(ui): add channel bridges experimental toggle
leeroybrun Mar 26, 2026
c72f70f
chore(cli): migrate channel bridge session transport imports
leeroybrun Mar 26, 2026
bbdfd7c
feat(cli): gate channel bridge daemon startup
leeroybrun Mar 26, 2026
c3a1bf5
refactor(cli): unify channel bridges runtime and local bindings
leeroybrun Mar 26, 2026
bb4774c
feat(ui): add channel bridges settings screen
leeroybrun Mar 26, 2026
f52c880
test(e2e): verify channel bridges feature gates
leeroybrun Mar 26, 2026
b4b56ab
fix(cli): tighten channel bridges types and redact Telegram tokens
leeroybrun Mar 26, 2026
5a31845
fix(ui): keep typecheck green for channel bridges
leeroybrun Mar 26, 2026
4242f48
fix(cli): remove conflict markers from daemon automation test
leeroybrun Mar 27, 2026
9e7c26a
test(cli): include bridge in command surface manifest coverage
leeroybrun Mar 27, 2026
77a03e0
test(server): cover channel bridges env toggles
leeroybrun Mar 27, 2026
635f52f
fix(telegram): redact webhook secret path from logs
leeroybrun Mar 27, 2026
0abf6b1
fix(cli): validate Telegram webhook secrets from settings
leeroybrun Mar 27, 2026
9204708
fix(cli): scope inbound idempotency to session
leeroybrun Mar 27, 2026
efbc3a0
fix(ui): gate channel bridges config on provider decision
leeroybrun Mar 27, 2026
9fdc9d0
chore(ui): remove unused channel bridges translation key
leeroybrun Mar 27, 2026
12397b1
fix(cli): reject empty bridge flag values
leeroybrun Mar 27, 2026
3fd6e58
fix(cli): validate webhook secret when persisting bridge config
leeroybrun Mar 27, 2026
67bbecb
test(e2e): cover channel bridges telegram env gate
leeroybrun Mar 27, 2026
c18d4c4
test(ui): harden channel bridges settings view states
leeroybrun Mar 27, 2026
768a4d5
docs(channel-bridges): align v1 config and UAT guidance
leeroybrun Mar 27, 2026
9233d0c
chore: drop yarn.lock churn
leeroybrun Mar 27, 2026
4870b45
fix(telegram): avoid exposing webhook secret path
leeroybrun Mar 27, 2026
e15abd7
docs(channel-bridges): align UAT checklist with v1 config
leeroybrun Mar 27, 2026
cb6fe0f
fix(channel-bridges): dedupe adapter pull failure warnings
leeroybrun Mar 27, 2026
4c68dc1
fix(channels): retry inbound forwards before acking
leeroybrun Mar 27, 2026
b399339
fix(channel-bridges): harden webhook tokens and env overrides
leeroybrun Mar 27, 2026
9df4e95
fix(ui): resolve channel bridges decisions in runtime scope
leeroybrun Mar 27, 2026
22b3e15
fix(ui): make feature diagnostics scope-aware
leeroybrun Mar 27, 2026
4b6d8eb
fix(channel-bridges): persist telegram polling cursor and harden work…
leeroybrun Mar 27, 2026
a99fe02
fix(channel-bridges): skip provider on runtime startup failure
leeroybrun Mar 27, 2026
2571a53
fix(channel-bridges): harden telegram relay and cursor scoping
leeroybrun Mar 27, 2026
d33abc3
refactor(channel-bridges): remove unused shared update helpers
leeroybrun Mar 27, 2026
1a5bd58
fix(ui): hide channel bridges entry until decision resolves
leeroybrun Mar 27, 2026
a0a0514
fix(channelBridges): preserve class adapter methods
leeroybrun Mar 27, 2026
dfa1f51
fix(ui): harden Channel Bridges navigation
leeroybrun Mar 27, 2026
2f440b4
fix(channel-bridges): harden config, worker authz, and Telegram provider
leeroybrun Mar 28, 2026
d086bcf
fix(ui): hide Channel Bridges unless enabled
leeroybrun Mar 28, 2026
49b04d2
fix(ui): show runtime-scoped experimental toggles
leeroybrun Mar 28, 2026
634f9ae
fix(cli): validate Telegram webhook secret length
leeroybrun Mar 28, 2026
5a5a531
fix(telegram): redact bot token from transport errors
leeroybrun Mar 28, 2026
e5e7de5
test(channelBridges): add core-e2e contract
leeroybrun Mar 28, 2026
e32aa6d
fix(ui): hide Channel Bridges until Telegram enabled
leeroybrun Mar 28, 2026
afc0ada
fix(telegram): accept captions and enforce requireTopics
leeroybrun Mar 28, 2026
5c520ea
refactor(cli): reuse Telegram webhook secret validator
leeroybrun Mar 28, 2026
a8f2b08
refactor(channelBridges): share filesystem-safe accountId validator
leeroybrun Mar 28, 2026
dcaeff1
feat(telegram): parse channel posts and sender_chat identity
leeroybrun Mar 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@ Happier acts as a secure bridge between your local development environment and y
- it communicates with the daemon through the relay server
- it receive daemon updates (sessions updates, messages, etc) through the relay server

## Channel Integrations

- Telegram bi-directional bridge setup (BotFather, topics/DM mapping, optional webhook relay):
- [Telegram channel bridge guide](docs/telegram-channel-bridge.md)

## Self-Hosting the Server Relay

Happier is 100% self-hostable. It's even the most recommended way to run it, even if we also offer an end-to-end encrypted cloud server (app.happier.dev / api.happier.dev).
Expand Down
14 changes: 13 additions & 1 deletion apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,17 @@ describe('serializeAxiosErrorForLog', () => {
expect(serialized).not.toHaveProperty('headers');
expect(serialized).not.toHaveProperty('data');
});
});

it('redacts Telegram bot tokens embedded in URL paths', () => {
const err = new AxiosError('boom', 'ECONNABORTED', {
method: 'get',
url: 'https://api.telegram.org/bot123456789:AAABBBccc___-123/getUpdates?offset=123',
headers: { Authorization: 'Bearer SECRET', 'Content-Type': 'application/json' },
} as any);

const serialized = serializeAxiosErrorForLog(err);
expect(serialized).toEqual(expect.objectContaining({
url: 'https://api.telegram.org/botREDACTED/getUpdates',
}));
});
});
19 changes: 17 additions & 2 deletions apps/cli/src/api/client/serializeAxiosErrorForLog.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
import axios from 'axios';

function redactTelegramBotTokenPathname(pathname: string): string {
const raw = String(pathname ?? '');
if (!raw) return raw;
const parts = raw.split('/');
const redacted = parts.map((part) => {
const segment = String(part ?? '');
if (segment.startsWith('bot') && segment.length > 3) {
return 'botREDACTED';
}
return segment;
});
return redacted.join('/');
}

function redactUrlForLog(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined;
const value = raw.trim();
if (!value) return undefined;
try {
const parsed = new URL(value);
parsed.pathname = redactTelegramBotTokenPathname(parsed.pathname);
parsed.search = '';
parsed.hash = '';
return parsed.toString();
} catch {
// Best-effort: strip query/hash to avoid leaking secrets in URLs.
return value.split('?')[0]?.split('#')[0];
const withoutQuery = value.split('?')[0]?.split('#')[0] ?? value;
return withoutQuery.replace(/\/bot[^/]+\//g, '/botREDACTED/');
}
}

Expand All @@ -34,4 +50,3 @@ export function serializeAxiosErrorForLog(error: unknown): Record<string, unknow

return { message: String(error) };
}

200 changes: 200 additions & 0 deletions apps/cli/src/channels/channelBridgeAccountConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest';

import {
readScopedTelegramBridgeConfig,
removeScopedTelegramBridgeConfig,
upsertScopedTelegramBridgeConfig,
} from './channelBridgeAccountConfig';

describe('channelBridgeAccountConfig', () => {
it('rejects webhook secrets that do not match Telegram-safe token charset', () => {
expect(() =>
upsertScopedTelegramBridgeConfig({
settings: {},
serverId: 'local-3005',
accountId: 'acct-1',
update: {
webhookSecret: 'bad token!',
},
})
).toThrow('Invalid webhookSecret: must match [A-Za-z0-9_-]');
});

it('rejects webhook secrets that exceed Telegram maximum length', () => {
expect(() =>
upsertScopedTelegramBridgeConfig({
settings: {},
serverId: 'local-3005',
accountId: 'acct-1',
update: {
webhookSecret: 'x'.repeat(257),
},
})
).toThrow('Webhook secret token is too long');
});

it('writes scoped telegram config under server/account with secrets in local-only block', () => {
const next = upsertScopedTelegramBridgeConfig({
settings: {},
serverId: 'local-3005',
accountId: 'acct-1',
update: {
tickMs: 2_200,
botToken: 'bot-token',
allowedChatIds: ['-100111'],
requireTopics: true,
webhookEnabled: true,
webhookSecret: 'secret-1',
webhookHost: '127.0.0.1',
webhookPort: 9_000,
},
});

const telegram = readScopedTelegramBridgeConfig({
settings: next,
serverId: 'local-3005',
accountId: 'acct-1',
});

expect(telegram).toEqual({
tickMs: 2_200,
botToken: 'bot-token',
allowedChatIds: ['-100111'],
requireTopics: true,
webhook: {
enabled: true,
secret: 'secret-1',
host: '127.0.0.1',
port: 9_000,
},
});

expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers.telegram.secrets).toEqual({
botToken: 'bot-token',
webhookSecret: 'secret-1',
});
expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers.telegram.botToken).toBeUndefined();
expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers.telegram.webhook.secret).toBeUndefined();
});

it('normalizes allowedChatIds when writing scoped config', () => {
const next = upsertScopedTelegramBridgeConfig({
settings: {},
serverId: 'local-3005',
accountId: 'acct-1',
update: {
allowedChatIds: [' -100111 ', '', ' ', '-100222'],
},
});

const telegram = readScopedTelegramBridgeConfig({
settings: next,
serverId: 'local-3005',
accountId: 'acct-1',
});

expect(telegram?.allowedChatIds).toEqual(['-100111', '-100222']);
});

it('does not materialize providers.telegram for tick-only scoped updates', () => {
const next = upsertScopedTelegramBridgeConfig({
settings: {},
serverId: 'local-3005',
accountId: 'acct-1',
update: {
tickMs: 2_500,
},
});

expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers).toBeUndefined();

const telegram = readScopedTelegramBridgeConfig({
settings: next,
serverId: 'local-3005',
accountId: 'acct-1',
});

expect(telegram).toBeNull();
});

it('preserves webhook secret when only webhook host/port settings are updated', () => {
const configured = upsertScopedTelegramBridgeConfig({
settings: {},
serverId: 'local-3005',
accountId: 'acct-1',
update: {
webhookSecret: 'secret-1',
},
});

const updated = upsertScopedTelegramBridgeConfig({
settings: configured,
serverId: 'local-3005',
accountId: 'acct-1',
update: {
webhookHost: '127.0.0.1',
webhookPort: 8080,
},
});

const telegram = readScopedTelegramBridgeConfig({
settings: updated,
serverId: 'local-3005',
accountId: 'acct-1',
});
const webhook = telegram?.webhook as { secret?: string; host?: string; port?: number } | undefined;

expect(webhook?.secret).toBe('secret-1');
expect(webhook?.host).toBe('127.0.0.1');
expect(webhook?.port).toBe(8080);
});

it('removes scoped telegram config and prunes empty nesting', () => {
const configured = upsertScopedTelegramBridgeConfig({
settings: {},
serverId: 'local-3005',
accountId: 'acct-1',
update: {
tickMs: 2_200,
botToken: 'bot-token',
},
});

const cleared = removeScopedTelegramBridgeConfig({
settings: configured,
serverId: 'local-3005',
accountId: 'acct-1',
});

const telegram = readScopedTelegramBridgeConfig({
settings: cleared,
serverId: 'local-3005',
accountId: 'acct-1',
});

expect(telegram).toBeNull();
expect((cleared as any).channelBridge).toBeUndefined();
});

it('removes stale scoped tickMs even when providers.telegram is already missing', () => {
const cleared = removeScopedTelegramBridgeConfig({
settings: {
channelBridge: {
byServerId: {
'local-3005': {
byAccountId: {
'acct-1': {
tickMs: 2_500,
},
},
},
},
},
},
serverId: 'local-3005',
accountId: 'acct-1',
});

expect((cleared as any).channelBridge).toBeUndefined();
});
});
Loading