@justfortytwo/telegram 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Enrico Deleo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # @justfortytwo/telegram
2
+
3
+ The **Telegram channel adapter** for [fortytwo](https://github.com/justfortytwo) — a
4
+ standalone, always-on **bridge** that connects a Telegram bot to a headless
5
+ [`claude`](https://docs.claude.com/en/docs/claude-code) turn loop.
6
+
7
+ It does three jobs:
8
+
9
+ 1. **Turn loop.** Long-poll Telegram, and for each authorized inbound message
10
+ drive a headless `claude -p --output-format json` turn (resuming the per-chat
11
+ session), then transport the assistant's reply back.
12
+ 2. **Approval UX.** When the safety gate defers an external/irreversible tool
13
+ call, the turn ends with `stop_reason: tool_deferred`. The bridge surfaces an
14
+ **inline-keyboard approval card** (Approve / Deny, plus "Allow _N_h" for exact
15
+ Bash commands). The tap records the decision and resumes the turn so the
16
+ one-shot allow fires.
17
+ 3. **Proactive wakes.** A scheduler invokes `claude -p` at fixed cron-like slots
18
+ (daily briefing, open-thread sweeps, weekly learn-review).
19
+
20
+ ## Pairing / login binding model
21
+
22
+ The bot is **locked down**: an unbound sender that does not present a valid
23
+ `/login` gets **no response at all**. Access is earned through a
24
+ channel-agnostic challenge/verify pairing flow:
25
+
26
+ ```
27
+ owner side: adapter.issueChallenge(owner) -> { code, ttl }
28
+ channel side: /login <code> -> verify(chatId, code) -> binding
29
+ ```
30
+
31
+ - `issueChallenge(owner)` mints a **single-use, short-TTL** code (default 6
32
+ digits, 5 minutes). The owner relays it out-of-band to the chat they want to
33
+ bind.
34
+ - `/login <code>` verifies the code and, on success, **persists a binding**
35
+ `(channelType, channelUserId) -> owner`.
36
+ - `/logout` removes the binding.
37
+ - Every inbound message is authorized against **persisted bindings ∪ the
38
+ optional `ALLOWED_CHAT_IDS` bootstrap set**. The bootstrap set lets the first
39
+ owner chat reach the bridge before any binding exists; it is not required once
40
+ bindings exist.
41
+
42
+ The contract is intentionally **channel-agnostic** (`ChannelAdapter` in
43
+ `src/adapter.ts`) so a future Slack / email / SMS adapter implements the same
44
+ `issueChallenge` / `verify` shape.
45
+
46
+ ### Binding store
47
+
48
+ Authorization must work **even when the memory package is absent**, so the
49
+ binding store is **self-owned** and does not assume the `@justfortytwo/memory`
50
+ sqlite db exists:
51
+
52
+ - `SqliteBindingStore` — default; a tiny `better-sqlite3` table in its own db
53
+ file (`state/telegram-bindings.db` by default).
54
+ - `MemoryBindingStore` — in-process Map, for tests / ephemeral use.
55
+ - `BindingStore` — the injectable interface. Supply a memory-backed
56
+ implementation later without touching the login flow.
57
+
58
+ > Cross-repo reconciliation: if fortytwo standardizes a shared identity
59
+ > table, swap the default store for a memory-backed `BindingStore`. The login
60
+ > flow is unaffected.
61
+
62
+ ## Peer dependencies
63
+
64
+ This package orchestrates two **peer** packages — they are referenced, not
65
+ bundled:
66
+
67
+ | Peer | Used for | Contract |
68
+ | --- | --- | --- |
69
+ | `@justfortytwo/memory` | Memory store, jobs, pending-decision records, embeddings | `MEMORY_TOOL_CONTRACT_VERSION` (tools `mcp__fortytwo-memory__*`) |
70
+ | `@justfortytwo/gate` | Bash exact-command allowlist behind "Allow _N_h"; provenance envelopes | `POLICY_SCHEMA_VERSION` |
71
+
72
+ Install them alongside this package:
73
+
74
+ ```sh
75
+ npm install @justfortytwo/telegram @justfortytwo/memory @justfortytwo/gate
76
+ ```
77
+
78
+ The seams where these peers are wired are marked with `// TODO(wire):` in
79
+ `src/bridge.ts`, `src/attachments.ts`, and `src/adapters-config.ts`. Until they
80
+ are wired, the bridge runs **channel-only** (no memory/jobs) via no-op shims.
81
+
82
+ ## Environment
83
+
84
+ | Var | Required | Meaning |
85
+ | --- | --- | --- |
86
+ | `TELEGRAM_BOT_TOKEN` | yes | Bot token. The bridge is the **only** long-poller on this bot. |
87
+ | `ALLOWED_CHAT_IDS` | bootstrap | Comma-separated chat IDs allowed before any binding exists. Optional once bindings exist. |
88
+ | `CLAUDE_BIN` | no | Path to the `claude` binary (default `claude`). |
89
+ | `FORD_TURN_TIMEOUT` | no | Hard cap (seconds) on a single turn so a stalled model can't wedge the bridge (default 300). |
90
+ | `TELEGRAM_BINDINGS_DB` | no | Path to the self-owned bindings db (default `state/telegram-bindings.db`). |
91
+ | `ASSISTANT_NAME` / `ASSISTANT_ACTOR` / `OWNER_ACTOR` | no | Display name + journal actor labels. |
92
+ | `FORD_ROOT` | no | Working root for the headless `claude` process + state files. |
93
+
94
+ > Proxy auth for the headless `claude` session is **not** read here — the
95
+ > `claude` CLI reads `ANTHROPIC_*` from its own config.
96
+
97
+ ## Usage
98
+
99
+ ```sh
100
+ npm run build
101
+ node dist/bridge.js
102
+ ```
103
+
104
+ Run it under a restart loop (tmux/systemd) so it survives transient errors and
105
+ crashes. Example launcher (adapted from the monolith's `wakeup.sh`):
106
+
107
+ ```sh
108
+ set -a; source .env; set +a
109
+ while true; do
110
+ node node_modules/@justfortytwo/telegram/dist/bridge.js
111
+ echo "[telegram] bridge exited (rc=$?) — restarting in 3s"; sleep 3
112
+ done
113
+ ```
114
+
115
+ ## License
116
+
117
+ MIT © 2026 Enrico Deleo
118
+
119
+ ---
120
+
121
+ Created and maintained by [**Enrico Deleo**](https://enricodeleo.com).
@@ -0,0 +1,47 @@
1
+ import type { Binding, BindingStore, ChannelType } from './bindings.js';
2
+ export interface Challenge {
3
+ /** The single-use pairing code the owner relays to the channel. */
4
+ code: string;
5
+ /** Seconds until the code expires. */
6
+ ttl: number;
7
+ }
8
+ export interface ChannelAdapter {
9
+ /** The channel this adapter binds (e.g. 'telegram'). */
10
+ readonly channelType: ChannelType;
11
+ /** Mint a single-use, short-TTL pairing code for `owner`. */
12
+ issueChallenge(owner: string): Challenge;
13
+ /**
14
+ * Verify `proof` (a presented code) for `channelUserId`. On success, persist
15
+ * and return the Binding; on failure (bad/expired/used code) return null.
16
+ */
17
+ verify(channelUserId: string, proof: string): Binding | null;
18
+ }
19
+ export interface ChallengeOptions {
20
+ /** Code lifetime in seconds. Default 300 (5 min). Short by design. */
21
+ ttlSeconds?: number;
22
+ /** Number of digits in the generated code. Default 6. */
23
+ digits?: number;
24
+ /** Injectable clock for tests. */
25
+ now?: () => number;
26
+ }
27
+ /**
28
+ * Telegram implementation of ChannelAdapter. Codes live in-process (a wedged
29
+ * bridge restart drops outstanding codes — acceptable for a short-TTL,
30
+ * owner-initiated flow). Verified bindings persist via the injected BindingStore.
31
+ */
32
+ export declare class TelegramAdapter implements ChannelAdapter {
33
+ private readonly store;
34
+ readonly channelType: ChannelType;
35
+ private readonly pending;
36
+ private readonly ttlSeconds;
37
+ private readonly digits;
38
+ private readonly now;
39
+ constructor(store: BindingStore, opts?: ChallengeOptions);
40
+ issueChallenge(owner: string): Challenge;
41
+ verify(channelUserId: string, proof: string): Binding | null;
42
+ /** Look up the persisted binding for a channel id (no proof needed). */
43
+ bindingFor(channelUserId: string): Binding | null;
44
+ /** Remove a persisted binding (logout). */
45
+ unbind(channelUserId: string): void;
46
+ private sweepExpired;
47
+ }
@@ -0,0 +1,79 @@
1
+ // Channel-agnostic binding contract.
2
+ //
3
+ // A ChannelAdapter knows how to pair a channel identity (e.g. a Telegram chat
4
+ // id) with a logical `owner`. Pairing is a two-step challenge/verify:
5
+ //
6
+ // 1. issueChallenge(owner) -> { code, ttl }
7
+ // Owner-side: mint a single-use, short-TTL code. The owner relays this code
8
+ // out-of-band to the channel they want to bind (e.g. "DM the bot
9
+ // `/login 482913`").
10
+ // 2. verify(channelUserId, proof) -> binding | null
11
+ // Channel-side: when an inbound message presents the code, confirm it is
12
+ // valid + unexpired + unused, then mint and persist a Binding.
13
+ //
14
+ // This keeps authorization independent of any single channel: a future Slack /
15
+ // email / SMS adapter implements the same contract.
16
+ import { randomInt } from 'node:crypto';
17
+ /**
18
+ * Telegram implementation of ChannelAdapter. Codes live in-process (a wedged
19
+ * bridge restart drops outstanding codes — acceptable for a short-TTL,
20
+ * owner-initiated flow). Verified bindings persist via the injected BindingStore.
21
+ */
22
+ export class TelegramAdapter {
23
+ store;
24
+ channelType = 'telegram';
25
+ pending = new Map(); // code -> challenge
26
+ ttlSeconds;
27
+ digits;
28
+ now;
29
+ constructor(store, opts = {}) {
30
+ this.store = store;
31
+ this.ttlSeconds = opts.ttlSeconds ?? 300;
32
+ this.digits = opts.digits ?? 6;
33
+ this.now = opts.now ?? (() => Date.now());
34
+ }
35
+ issueChallenge(owner) {
36
+ this.sweepExpired();
37
+ const max = 10 ** this.digits;
38
+ const code = String(randomInt(0, max)).padStart(this.digits, '0');
39
+ this.pending.set(code, { code, owner, expiresAt: this.now() + this.ttlSeconds * 1000 });
40
+ return { code, ttl: this.ttlSeconds };
41
+ }
42
+ verify(channelUserId, proof) {
43
+ this.sweepExpired();
44
+ const code = proof.trim();
45
+ const challenge = this.pending.get(code);
46
+ if (!challenge)
47
+ return null;
48
+ if (challenge.expiresAt <= this.now()) {
49
+ this.pending.delete(code);
50
+ return null;
51
+ }
52
+ // Single-use: consume the code regardless of downstream outcome.
53
+ this.pending.delete(code);
54
+ const binding = {
55
+ channelType: this.channelType,
56
+ channelUserId,
57
+ owner: challenge.owner,
58
+ boundAt: new Date(this.now()).toISOString(),
59
+ };
60
+ this.store.put(binding);
61
+ return binding;
62
+ }
63
+ /** Look up the persisted binding for a channel id (no proof needed). */
64
+ bindingFor(channelUserId) {
65
+ return this.store.get(this.channelType, channelUserId);
66
+ }
67
+ /** Remove a persisted binding (logout). */
68
+ unbind(channelUserId) {
69
+ this.store.remove(this.channelType, channelUserId);
70
+ }
71
+ sweepExpired() {
72
+ const t = this.now();
73
+ for (const [code, c] of this.pending) {
74
+ if (c.expiresAt <= t)
75
+ this.pending.delete(code);
76
+ }
77
+ }
78
+ }
79
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ export interface AdaptersConfig {
2
+ channelTelegramEnabled: boolean;
3
+ attachments: {
4
+ maxBytes: number;
5
+ storeDir: string;
6
+ };
7
+ }
8
+ /** Type of the gate's `parseToml`: every scalar comes back as a string. */
9
+ export type TomlParser = (text: string) => Record<string, unknown>;
10
+ /** Load the drop-in adapter manifest. Secrets stay in env; this is selection + non-secret settings only. */
11
+ export declare function loadAdaptersConfig(path: string, parseToml?: TomlParser): AdaptersConfig;
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ const DEFAULTS = {
3
+ channelTelegramEnabled: true,
4
+ attachments: { maxBytes: 26214400, storeDir: 'inbox' }, // 25 MiB
5
+ };
6
+ function asRecord(v) {
7
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
8
+ }
9
+ /**
10
+ * Minimal flat-table TOML reader (fallback only). Understands `[section]`
11
+ * headers and `key = value` pairs, emitting dotted keys ("section.key") with
12
+ * string values — matching the surface the original `parseToml` exposed for
13
+ * adapters.toml. Not a general TOML implementation; replace with the gate parser.
14
+ */
15
+ function fallbackParseToml(text) {
16
+ const out = {};
17
+ let section = '';
18
+ for (const raw of text.split(/\r?\n/)) {
19
+ const line = raw.replace(/#.*$/, '').trim();
20
+ if (!line)
21
+ continue;
22
+ const header = line.match(/^\[(.+)\]$/);
23
+ if (header) {
24
+ section = header[1].trim();
25
+ continue;
26
+ }
27
+ const kv = line.match(/^([^=]+)=(.*)$/);
28
+ if (!kv)
29
+ continue;
30
+ const key = kv[1].trim();
31
+ let value = kv[2].trim();
32
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
33
+ value = value.slice(1, -1);
34
+ }
35
+ const table = (out[section] ??= {});
36
+ table[key] = value;
37
+ }
38
+ return out;
39
+ }
40
+ /** Load the drop-in adapter manifest. Secrets stay in env; this is selection + non-secret settings only. */
41
+ export function loadAdaptersConfig(path, parseToml = fallbackParseToml) {
42
+ if (!existsSync(path))
43
+ return DEFAULTS;
44
+ let o;
45
+ try {
46
+ o = parseToml(readFileSync(path, 'utf8'));
47
+ }
48
+ catch {
49
+ return DEFAULTS;
50
+ }
51
+ // parseToml returns every scalar as a string — coerce here.
52
+ const channel = asRecord(o['channel.telegram'] ?? asRecord(o['channel'])['telegram']);
53
+ const att = asRecord(o['attachments']);
54
+ const maxBytes = Number(att['max_bytes']);
55
+ return {
56
+ channelTelegramEnabled: channel['enabled'] === undefined ? true : channel['enabled'] === 'true',
57
+ attachments: {
58
+ maxBytes: Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : DEFAULTS.attachments.maxBytes,
59
+ storeDir: typeof att['store_dir'] === 'string' && att['store_dir'] ? att['store_dir'] : DEFAULTS.attachments.storeDir,
60
+ },
61
+ };
62
+ }
63
+ //# sourceMappingURL=adapters-config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapters-config.js","sourceRoot":"","sources":["../src/adapters-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAenD,MAAM,QAAQ,GAAmB;IAC/B,sBAAsB,EAAE,IAAI;IAC5B,WAAW,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,SAAS;CAClE,CAAC;AAKF,SAAS,QAAQ,CAAC,CAAU;IAC1B,OAAO,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAA4B,CAAC,CAAC,CAAC,EAAE,CAAC;AAC7F,CAAC;AAED;;;;;GAKG;AACH,SAAS,iBAAiB,CAAC,IAAY;IACrC,MAAM,GAAG,GAA4B,EAAE,CAAC;IACxC,IAAI,OAAO,GAAG,EAAE,CAAC;IACjB,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;QACtC,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAC5C,IAAI,CAAC,IAAI;YAAE,SAAS;QACpB,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;QACxC,IAAI,MAAM,EAAE,CAAC;YAAC,OAAO,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;YAAC,SAAS;QAAC,CAAC;QACrD,MAAM,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC;QACxC,IAAI,CAAC,EAAE;YAAE,SAAS;QAClB,MAAM,GAAG,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QACzB,IAAI,KAAK,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QACzB,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;YACrG,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QAC7B,CAAC;QACD,MAAM,KAAK,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,KAAK,EAAE,CAA4B,CAAC;QAC/D,KAAK,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IACrB,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,4GAA4G;AAC5G,MAAM,UAAU,kBAAkB,CAAC,IAAY,EAAE,YAAwB,iBAAiB;IACxF,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,QAAQ,CAAC;IACvC,IAAI,CAA0B,CAAC;IAC/B,IAAI,CAAC;QAAC,CAAC,GAAG,SAAS,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC;QAAC,OAAO,QAAQ,CAAC;IAAC,CAAC;IAE7E,4DAA4D;IAC5D,MAAM,OAAO,GAAG,QAAQ,CAAC,CAAC,CAAC,kBAAkB,CAAC,IAAI,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IACtF,MAAM,GAAG,GAAG,QAAQ,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC;IACvC,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC;IAC1C,OAAO;QACL,sBAAsB,EAAE,OAAO,CAAC,SAAS,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,KAAK,MAAM;QAC/F,WAAW,EAAE;YACX,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,QAAQ;YAC9F,QAAQ,EAAE,OAAO,GAAG,CAAC,WAAW,CAAC,KAAK,QAAQ,IAAI,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,CAAW,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,QAAQ;SAChI;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,26 @@
1
+ import { type SourceEnvelope, type SourceKind } from '@justfortytwo/gate';
2
+ export type AttachmentKind = 'image' | 'document' | 'audio' | 'other';
3
+ export interface Attachment {
4
+ attachment_id: string;
5
+ kind: AttachmentKind;
6
+ mime: string;
7
+ storage_ref: string;
8
+ byte_size: number;
9
+ content_hash: string;
10
+ caption?: string;
11
+ source_envelope: SourceEnvelope;
12
+ }
13
+ export declare function kindFromMime(mime: string): AttachmentKind;
14
+ export declare function extensionFor(mime: string, fileName?: string): string;
15
+ export declare function inboxRelPath(storeDir: string, chatId: number, messageId: number, index: number, ext: string): string;
16
+ export declare function withinSizeLimit(byteSize: number, maxBytes: number): boolean;
17
+ export declare function buildAttachment(args: {
18
+ bytes: Uint8Array;
19
+ mime: string;
20
+ sourceKind: SourceKind | string;
21
+ storageRef: string;
22
+ channel: string;
23
+ actor?: string;
24
+ caption?: string;
25
+ fileName?: string;
26
+ }): Attachment;
@@ -0,0 +1,60 @@
1
+ import { createHash } from 'node:crypto';
2
+ // TODO(wire): SourceKind / SourceEnvelope / createSourceEnvelope are the
3
+ // provenance-envelope primitives owned by the safety gate (@justfortytwo/gate),
4
+ // a peer package — NOT this one. gate's SourceKind is a host-agnostic union that
5
+ // allows a channel to map its own kinds in as plain strings, which is how the
6
+ // telegram_* kinds below flow through.
7
+ import { createSourceEnvelope } from '@justfortytwo/gate';
8
+ const EXT_BY_MIME = {
9
+ 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp',
10
+ 'application/pdf': 'pdf', 'text/plain': 'txt',
11
+ };
12
+ export function kindFromMime(mime) {
13
+ if (mime.startsWith('image/'))
14
+ return 'image';
15
+ if (mime.startsWith('audio/'))
16
+ return 'audio';
17
+ if (mime === 'application/pdf' || mime.startsWith('text/') || mime.startsWith('application/'))
18
+ return 'document';
19
+ return 'other';
20
+ }
21
+ export function extensionFor(mime, fileName) {
22
+ if (EXT_BY_MIME[mime])
23
+ return EXT_BY_MIME[mime];
24
+ const dot = fileName ? fileName.lastIndexOf('.') : -1;
25
+ if (fileName && dot >= 0 && dot < fileName.length - 1) {
26
+ return fileName.slice(dot + 1).toLowerCase().replace(/[^a-z0-9]/g, '') || 'bin';
27
+ }
28
+ return 'bin';
29
+ }
30
+ export function inboxRelPath(storeDir, chatId, messageId, index, ext) {
31
+ const suffix = index > 0 ? `-${index}` : '';
32
+ return `${storeDir}/${chatId}/${messageId}${suffix}.${ext}`;
33
+ }
34
+ export function withinSizeLimit(byteSize, maxBytes) {
35
+ return byteSize > 0 && byteSize <= maxBytes;
36
+ }
37
+ function sha256Bytes(bytes) {
38
+ return createHash('sha256').update(bytes).digest('hex');
39
+ }
40
+ export function buildAttachment(args) {
41
+ const contentHash = sha256Bytes(args.bytes);
42
+ const envelope = createSourceEnvelope({
43
+ source_kind: args.sourceKind,
44
+ content: contentHash,
45
+ actor: args.actor,
46
+ channel: args.channel,
47
+ metadata: { mime: args.mime, byte_size: args.bytes.length, storage_ref: args.storageRef, file_name: args.fileName },
48
+ });
49
+ return {
50
+ attachment_id: envelope.source_id,
51
+ kind: kindFromMime(args.mime),
52
+ mime: args.mime,
53
+ storage_ref: args.storageRef,
54
+ byte_size: args.bytes.length,
55
+ content_hash: contentHash,
56
+ caption: args.caption,
57
+ source_envelope: envelope,
58
+ };
59
+ }
60
+ //# sourceMappingURL=attachments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachments.js","sourceRoot":"","sources":["../src/attachments.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ export type ChannelType = 'telegram' | string;
2
+ export interface Binding {
3
+ channelType: ChannelType;
4
+ channelUserId: string;
5
+ owner: string;
6
+ boundAt: string;
7
+ }
8
+ /** The seam. Persists channel-id -> owner bindings; everything else injects this. */
9
+ export interface BindingStore {
10
+ get(channelType: ChannelType, channelUserId: string): Binding | null;
11
+ put(binding: Binding): void;
12
+ remove(channelType: ChannelType, channelUserId: string): void;
13
+ list(channelType?: ChannelType): Binding[];
14
+ }
15
+ /** In-memory store — ideal for tests and ephemeral deployments. */
16
+ export declare class MemoryBindingStore implements BindingStore {
17
+ private readonly map;
18
+ private key;
19
+ get(channelType: ChannelType, channelUserId: string): Binding | null;
20
+ put(binding: Binding): void;
21
+ remove(channelType: ChannelType, channelUserId: string): void;
22
+ list(channelType?: ChannelType): Binding[];
23
+ }
24
+ /**
25
+ * Self-owned sqlite-backed binding store. Uses its own small db file so it does
26
+ * NOT depend on the memory package's schema or db existence. Lazily creates the
27
+ * table on construction.
28
+ */
29
+ export declare class SqliteBindingStore implements BindingStore {
30
+ private readonly db;
31
+ constructor(dbPath?: string);
32
+ get(channelType: ChannelType, channelUserId: string): Binding | null;
33
+ put(binding: Binding): void;
34
+ remove(channelType: ChannelType, channelUserId: string): void;
35
+ list(channelType?: ChannelType): Binding[];
36
+ }
@@ -0,0 +1,87 @@
1
+ // Binding store: persists (channelType, channelUserId) -> owner.
2
+ //
3
+ // DESIGN DECISION (cross-repo reconciliation point): the telegram adapter must
4
+ // NOT assume the @justfortytwo/memory sqlite db exists. Authorization is the
5
+ // channel's own concern and must work even when memory is absent. So this file
6
+ // defines:
7
+ // 1. `BindingStore` — an injectable interface (the seam). Anyone can supply a
8
+ // memory-backed implementation later without changing the login flow.
9
+ // 2. `SqliteBindingStore` — a small SELF-OWNED better-sqlite3 store (its own
10
+ // tiny db file, default ./telegram-bindings.db) used by default.
11
+ // 3. `MemoryBindingStore` — an in-process Map store for tests / ephemeral use.
12
+ //
13
+ // If/when fortytwo standardizes a shared identity table, swap the default
14
+ // for a memory-backed BindingStore — the login flow is unaffected.
15
+ import { createRequire } from 'node:module';
16
+ /** In-memory store — ideal for tests and ephemeral deployments. */
17
+ export class MemoryBindingStore {
18
+ map = new Map();
19
+ key(t, u) { return `${t} ${u}`; }
20
+ get(channelType, channelUserId) {
21
+ return this.map.get(this.key(channelType, channelUserId)) ?? null;
22
+ }
23
+ put(binding) {
24
+ this.map.set(this.key(binding.channelType, binding.channelUserId), binding);
25
+ }
26
+ remove(channelType, channelUserId) {
27
+ this.map.delete(this.key(channelType, channelUserId));
28
+ }
29
+ list(channelType) {
30
+ const all = [...this.map.values()];
31
+ return channelType ? all.filter((b) => b.channelType === channelType) : all;
32
+ }
33
+ }
34
+ // Load the native sqlite driver via createRequire so this ESM module can pull a
35
+ // CommonJS addon without a top-level static import (which would force the native
36
+ // dependency on type-only consumers).
37
+ const nodeRequire = createRequire(import.meta.url);
38
+ /**
39
+ * Self-owned sqlite-backed binding store. Uses its own small db file so it does
40
+ * NOT depend on the memory package's schema or db existence. Lazily creates the
41
+ * table on construction.
42
+ */
43
+ export class SqliteBindingStore {
44
+ db;
45
+ constructor(dbPath = 'telegram-bindings.db') {
46
+ const Database = nodeRequire('better-sqlite3');
47
+ this.db = new Database(dbPath);
48
+ this.db.pragma('journal_mode = WAL');
49
+ this.db.exec(`
50
+ CREATE TABLE IF NOT EXISTS channel_bindings (
51
+ channel_type TEXT NOT NULL,
52
+ channel_user_id TEXT NOT NULL,
53
+ owner TEXT NOT NULL,
54
+ bound_at TEXT NOT NULL,
55
+ PRIMARY KEY (channel_type, channel_user_id)
56
+ );
57
+ `);
58
+ }
59
+ get(channelType, channelUserId) {
60
+ const row = this.db
61
+ .prepare('SELECT channel_type, channel_user_id, owner, bound_at FROM channel_bindings WHERE channel_type = ? AND channel_user_id = ?')
62
+ .get(channelType, channelUserId);
63
+ return row ? { channelType: row.channel_type, channelUserId: row.channel_user_id, owner: row.owner, boundAt: row.bound_at } : null;
64
+ }
65
+ put(binding) {
66
+ this.db
67
+ .prepare(`
68
+ INSERT INTO channel_bindings (channel_type, channel_user_id, owner, bound_at)
69
+ VALUES (@channelType, @channelUserId, @owner, @boundAt)
70
+ ON CONFLICT(channel_type, channel_user_id)
71
+ DO UPDATE SET owner = excluded.owner, bound_at = excluded.bound_at
72
+ `)
73
+ .run(binding);
74
+ }
75
+ remove(channelType, channelUserId) {
76
+ this.db
77
+ .prepare('DELETE FROM channel_bindings WHERE channel_type = ? AND channel_user_id = ?')
78
+ .run(channelType, channelUserId);
79
+ }
80
+ list(channelType) {
81
+ const rows = (channelType
82
+ ? this.db.prepare('SELECT channel_type, channel_user_id, owner, bound_at FROM channel_bindings WHERE channel_type = ?').all(channelType)
83
+ : this.db.prepare('SELECT channel_type, channel_user_id, owner, bound_at FROM channel_bindings').all());
84
+ return rows.map((r) => ({ channelType: r.channel_type, channelUserId: r.channel_user_id, owner: r.owner, boundAt: r.bound_at }));
85
+ }
86
+ }
87
+ //# sourceMappingURL=bindings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bindings.js","sourceRoot":"","sources":["../src/bindings.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ import { type TgMessage, type AttachmentSpec } from './telegram.js';
3
+ import { type Attachment } from './attachments.js';
4
+ import type { SourceKind } from '@justfortytwo/gate';
5
+ import type { MemoryInput } from '@justfortytwo/memory';
6
+ interface ChannelEvent {
7
+ channel: string;
8
+ direction: string;
9
+ actor: string;
10
+ kind: string;
11
+ content: string;
12
+ meta?: Record<string, unknown>;
13
+ thread_id?: string | null;
14
+ approval_status?: string | null;
15
+ }
16
+ /** Map a channel event onto memory's generic MemoryInput (content + provenance). */
17
+ export declare function mapChannelEventToMemoryInput(entry: ChannelEvent): MemoryInput;
18
+ export interface DeferredTool {
19
+ id: string;
20
+ name: string;
21
+ input: Record<string, unknown>;
22
+ }
23
+ export interface Interpreted {
24
+ reply?: string;
25
+ deferred?: DeferredTool;
26
+ sessionId?: string;
27
+ }
28
+ /** Read a `claude -p --output-format json` result into a reply OR a deferred tool. */
29
+ export declare function interpretResult(r: any): Interpreted;
30
+ export declare function formatApprovalCard(d: DeferredTool, bashTtlHours?: number): string;
31
+ export interface Schedule {
32
+ name: string;
33
+ minute: number;
34
+ hour: number;
35
+ dow?: number;
36
+ trigger: string;
37
+ }
38
+ export declare function telegramSourceKind(message: TgMessage): SourceKind;
39
+ export declare function hasHandleableContent(message: TgMessage): boolean;
40
+ export declare function telegramCommandReply(text: string): string | undefined;
41
+ export declare function buildInboundPrompt(caption: string, attachments: Attachment[]): string;
42
+ export interface AttachmentIo {
43
+ download(fileId: string): Promise<Buffer>;
44
+ write(relPath: string, bytes: Buffer): void;
45
+ }
46
+ /**
47
+ * Download + store each attachment in a message, returning provenance-tagged
48
+ * Attachments. Pure given an injected io (no network/fs in tests). Two-stage
49
+ * size check: declared file_size for early reject, actual length for hard enforcement.
50
+ */
51
+ export declare function ingestMessageAttachments(io: AttachmentIo, message: TgMessage, opts: {
52
+ maxBytes: number;
53
+ storeDir: string;
54
+ channel: string;
55
+ actor?: string;
56
+ }): Promise<{
57
+ attachments: Attachment[];
58
+ skipped: AttachmentSpec[];
59
+ }>;
60
+ export declare const SCHEDULES: Schedule[];
61
+ /** Schedules whose cron slot matches `now` and haven't fired in this slot yet. */
62
+ export declare function dueSchedules(now: Date, fired: Record<string, string>): Schedule[];
63
+ export {};