can2cup 0.10.3 → 0.10.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 -0
- package/SKILL.md +3 -0
- package/dist/cli/index.js +78 -1
- package/docs/SELF-HOST.md +121 -0
- package/package.json +7 -2
- package/src/protocol/canon.ts +17 -0
- package/src/protocol/crypto.ts +38 -0
- package/src/protocol/display.ts +11 -0
- package/src/protocol/e2e.ts +57 -0
- package/src/protocol/envelope.ts +127 -0
- package/src/protocol/index.ts +9 -0
- package/src/protocol/mandate.ts +61 -0
- package/src/protocol/principal.ts +90 -0
- package/src/protocol/release.ts +35 -0
- package/src/protocol/room.ts +115 -0
- package/src/protocol/semver.ts +14 -0
- package/src/relay/a2a.ts +212 -0
- package/src/relay/anchor.ts +159 -0
- package/src/relay/bridge.ts +1710 -0
- package/src/relay/index.ts +806 -0
- package/src/relay/join-page.ts +79 -0
- package/src/relay/mcp-http.ts +627 -0
- package/tsconfig.relay.json +12 -0
- package/wrangler.toml +106 -0
|
@@ -0,0 +1,1710 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BridgeDO — the principal-side bridge between can2cup identities and a chat app
|
|
3
|
+
* (LINE today, via the lilinene bot). One instance ("bridge") for the relay.
|
|
4
|
+
*
|
|
5
|
+
* What it holds:
|
|
6
|
+
* link codes agent → code (can2cup_link) → principal sends "/link CODE" to the bot → bound
|
|
7
|
+
* bindings chat userId ↔ agent pubkey (+ agentMode: are 1:1 texts instructions?)
|
|
8
|
+
* inbox per pubkey principal instructions written by the bot, read by the agent's can2cup_wait
|
|
9
|
+
* remote pause principal says "/pause" in chat → agent's mandate check refuses to send
|
|
10
|
+
* room knowledge learned from RoomDO events: which rooms each pubkey is in, recent messages
|
|
11
|
+
* mirrors chat groupId → room: verified events are pushed into the group
|
|
12
|
+
* push budget monthly counter so a free LINE plan is not exceeded
|
|
13
|
+
* presence (v0.3.1) lastSeen (any /p/* call, incl. a 60 s heartbeat from a live MCP process), lastRead
|
|
14
|
+
* (the agent actually drained its inbox), offlineAt (the MCP said goodbye on shutdown).
|
|
15
|
+
* Transitions push "agent offline / back online" to the bound principal; /bridge/inbox
|
|
16
|
+
* and room pushes say so when the agent is not there to act.
|
|
17
|
+
* v0.4.7: presence is keyed on the agent's pubkey, but every Claude Code session runs
|
|
18
|
+
* its own MCP process under that one key — so closing one window used to fire 🔴 and
|
|
19
|
+
* then 🟢 as soon as a sibling session's heartbeat landed. The goodbye is now held for
|
|
20
|
+
* PRESENCE_GRACE_SEC (90 s > the 60 s heartbeat): any /p/* call in that window cancels
|
|
21
|
+
* it silently, and "back online" is only sent if the principal was actually told
|
|
22
|
+
* (offtold:) that the agent was gone — by the 🔴 push, an /a they typed while it was
|
|
23
|
+
* away, or a room push carrying the offline warning.
|
|
24
|
+
*
|
|
25
|
+
* Auth:
|
|
26
|
+
* /p/* signed by the agent's ed25519 key (x-parley-pub / x-parley-ts / x-parley-sig over
|
|
27
|
+
* "METHOD\nPATH\nTS\nBODY"); ts must be within 5 minutes.
|
|
28
|
+
* /bridge/* the bot's shared key (x-parley-bridge-key == BRIDGE_KEY secret).
|
|
29
|
+
* /principal/* a principal-signed message in the body (see protocol/principal.ts), checked
|
|
30
|
+
* against the principal pubkey the agent registered via POST /p/principal. v0.3:
|
|
31
|
+
* these are the ONLY inbox items / pauses the MCP server will ever call verified —
|
|
32
|
+
* the bridge verifies too (so the inbox cannot be spammed), but the agent's own
|
|
33
|
+
* check is the trust anchor, not this one.
|
|
34
|
+
* /internal/* only reachable from RoomDO (the worker never routes it).
|
|
35
|
+
*
|
|
36
|
+
* Delivery: LINE_FORWARD_URL (the bot's /parley/push, keyed with BRIDGE_KEY) is preferred so the
|
|
37
|
+
* channel token stays in one place; LINE_CHANNEL_ACCESS_TOKEN set on the worker pushes directly.
|
|
38
|
+
* Neither set → pushes are only recorded (smoke tests read them back via /bridge/debug/pushes).
|
|
39
|
+
*
|
|
40
|
+
* Pushes are queued in storage and delivered from alarm(): request handlers stay storage-only
|
|
41
|
+
* (so RoomDO can await /internal/event cheaply and atomically), and slow/failed deliveries never
|
|
42
|
+
* block a room append. Dangling promises after a response are not reliable in DOs — the queue is.
|
|
43
|
+
*/
|
|
44
|
+
import { Hono } from "hono";
|
|
45
|
+
import { DurableObject } from "cloudflare:workers";
|
|
46
|
+
import { type Envelope, type SignedPrincipalMsg, lineDeepLink, randomHex, short, verifyRequestHeaders, verifyPrincipal } from "../protocol/index.js";
|
|
47
|
+
import { NO_VERSION, cmpSemver } from "../protocol/semver.js";
|
|
48
|
+
import {
|
|
49
|
+
type McpDeps, handleMcp, authorizeGet, authorizePost,
|
|
50
|
+
register as mcpRegister, token as mcpToken, hostedKeyOf,
|
|
51
|
+
} from "./mcp-http.js";
|
|
52
|
+
|
|
53
|
+
export interface BridgeEnv {
|
|
54
|
+
BRIDGE_KEY?: string;
|
|
55
|
+
LINE_FORWARD_URL?: string;
|
|
56
|
+
LINE_CHANNEL_ACCESS_TOKEN?: string;
|
|
57
|
+
PUSH_BUDGET?: string; // per month; default 180 (LINE free plan is 200)
|
|
58
|
+
LINE_OA_ID?: string; // "@xxxx" — the bot's public basic ID, for line.me deep links
|
|
59
|
+
PRESENCE_GRACE_SEC?: string; // hold an MCP's goodbye this long before telling the principal; default 90
|
|
60
|
+
OPERATOR_LINE_USER_ID?: string; // v0.8.1: where `can2cup report` lands (the person who runs this relay)
|
|
61
|
+
INBOX_LEASE_SEC?: string; // v0.8.0: override the 15-min unanswered lease (dev/smoke only)
|
|
62
|
+
PUSH_USER_BUDGET?: string; // monthly pushes per target (user or group); default 60 — one noisy stranger must not drain the shared LINE budget
|
|
63
|
+
ROOMS_PER_DAY?: string; // rooms a hosted identity may open per day; default 10
|
|
64
|
+
GROUP_ROOM_TTL_DAYS?: string; // v0.9.2: how long a room wired to a LINE group lives after its last message; default 30
|
|
65
|
+
MIN_CLIENT?: string; // v0.9.0: least client version allowed to open rooms / wire groups / invite; default 0.0.0 (gate off)
|
|
66
|
+
LATEST_CLIENT?: string; // v0.9.0: what `x-can2cup-latest` says when the ASSETS binding (relay-assets/dl/VERSION) is absent
|
|
67
|
+
ASSETS?: { fetch(req: Request): Promise<Response> }; // wrangler [assets] binding — /dl/VERSION is the source of "latest"
|
|
68
|
+
IMG_BYTES_PER_DAY?: string; // base64 bytes of images one key may host per day; default 5 MB
|
|
69
|
+
IDLE_DAYS?: string; // v0.9.12: a 1:1 binding expires after the AGENT has been absent this long; default 90 (the boss's call, 2026-09-05)
|
|
70
|
+
IDLE_DAYS_SEC?: string; // dev/smoke only: the same clock in seconds (per-user /keep values become seconds too)
|
|
71
|
+
IDLE_WARN_SEC?: string; // how long before expiry the warning goes out; default 14 days
|
|
72
|
+
IDLE_GRACE_SEC?: string; // after the first sweep on a relay, expiry waits this long (warnings still go); default 30 days
|
|
73
|
+
GROUP_WARN_SEC?: string; // a wired group is warned this long before its room expires; default 3 days
|
|
74
|
+
IDLE_SWEEP_SEC?: string; // how often the alarm sweeps; default 1 day
|
|
75
|
+
DEBUG_ROUTES?: string; // v0.9.13: "1" enables /bridge/debug/* (smoke only). Absent in production → those routes are 404.
|
|
76
|
+
// Hosted agents act on rooms through this binding, not over the network. Typed
|
|
77
|
+
// structurally rather than as DurableObjectNamespace<RoomDO>: importing RoomDO
|
|
78
|
+
// here would be circular (index.ts already imports this module), and fetch is
|
|
79
|
+
// all this side needs.
|
|
80
|
+
ROOMS: {
|
|
81
|
+
idFromName(name: string): DurableObjectId;
|
|
82
|
+
get(id: DurableObjectId): { fetch(req: Request): Promise<Response> };
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Binding { pub: string; name: string; userId: string; agentMode: boolean; boundAt: string }
|
|
87
|
+
/** v0.8.0 delivery ledger: deliveredAt = handed to a client (lease starts); ackedAt = the agent acted (tell) or
|
|
88
|
+
* explicitly acked; remindedAt = the principal was told nobody answered. Unacked past the lease → redelivered. */
|
|
89
|
+
interface InboxItem { seq: number; at: string; text: string; deliveredAt?: string; deliveredTo?: string; ackedAt?: string; remindedAt?: string; redelivered?: boolean; signed?: SignedPrincipalMsg; via?: string; group?: string /* LINE group the /a came from */; groupAlias?: string; groupName?: string; invite?: string /* a can2cup invite the principal accepted on LINE — the agent auto-joins */; roomRequest?: { name?: string; group: string } /* v0.5.1: /room typed in that group — the agent creates the room and answers on /p/room-created */; guest?: { name: string; group: string; groupName?: string } /* v0.9.4: someone in the group who is NOT this agent's principal — data, never an instruction */ }
|
|
90
|
+
interface KnownGroup { id: string; alias: string; name?: string; lastAt: string } // v0.4.5: every group the principal has spoken from, addressable as "group:<alias>"
|
|
91
|
+
interface StoredInvite { invite: string; room: string; name: string; fromPub: string; fromName: string; at: number }
|
|
92
|
+
const INVITE_TTL_MS = 24 * 3600 * 1000;
|
|
93
|
+
interface RoomKnown { name: string; state: string; lastSeq: number; participants: Record<string, string> }
|
|
94
|
+
interface Mirror { room: string; all: boolean; by: string; at?: string } // at: v0.9.9 (wired since); older mirrors have none
|
|
95
|
+
/** v0.9.12: a principal's override of the idle clock. days 0 = never expires (only an explicit "永久" writes 0). */
|
|
96
|
+
interface IdlePolicy { days: number; by: "line" | "agent"; at: string }
|
|
97
|
+
interface IdleState { days: number; forever: boolean; by: string | null; lastSeen: string | null; idleMs: number; ttlMs: number; expiresAt: string | null }
|
|
98
|
+
export interface RoomEvent { room: string; name: string; state: string; participants: Record<string, { name: string }>; envelope: Envelope }
|
|
99
|
+
/** A quick-reply button. `fill` (v0.7.4) opens the phone keyboard pre-filled instead of sending a postback —
|
|
100
|
+
* "回話" after a question drops the principal straight into `/a `. */
|
|
101
|
+
type Quick = { label: string; data: string; fill?: string };
|
|
102
|
+
interface Pushed { at: string; to: string; kind: string; text: string; delivered: string; image?: string; sender?: string; attempts?: number }
|
|
103
|
+
/** attempts/nextAt (review R2): a push that fails transiently (bot cold start, 5xx, rate limit) stays queued and is
|
|
104
|
+
* retried with backoff instead of being deleted — an escalate the human never saw is the worst failure we have. */
|
|
105
|
+
interface Queued { at: string; to: string; kind: string; text: string; quick?: Quick[]; room?: string; image?: string; sender?: string; attempts?: number; nextAt?: number }
|
|
106
|
+
interface ImgMeta { mime: string; expires: number; chunks: number; by: string } // v0.4.5 ephemeral image store
|
|
107
|
+
interface OffPend { at: string; due: number } // v0.4.7: a goodbye waiting out its grace before the principal hears about it
|
|
108
|
+
|
|
109
|
+
/** Types worth a phone buzz. `text` is not — read those in the transcript. */
|
|
110
|
+
const DECISION_TYPES = new Set(["question", "proposal", "counter", "accept", "reject", "grant", "revoke", "escalate", "attachment", "close"]);
|
|
111
|
+
/** Message-type tags as a LINE reader sees them (fmtEnvelope). */
|
|
112
|
+
const TYPE_LABEL: Record<string, string> = {
|
|
113
|
+
question: "❓ 問", proposal: "📝 提案", counter: "↩️ 還價", accept: "✅ 接受", reject: "❌ 拒絕", withdraw: "↪️ 撤回",
|
|
114
|
+
escalate: "🙋 交回老闆", grant: "🔑 授權", revoke: "🚫 撤銷授權", attachment: "📎 附件", close: "🔒 結束",
|
|
115
|
+
};
|
|
116
|
+
const SYSTEM_LABEL: Record<string, string> = { join: "接上了", leave: "離開了", eject: "被請出", rotate: "邀請碼已換", close: "結束了", "mirror-attached": "群接上了" };
|
|
117
|
+
const CODE_TTL_MS = 10 * 60 * 1000;
|
|
118
|
+
const ROOMREQ_TTL_MS = 60 * 60 * 1000; // a /room request waits this long for the agent (it may be asleep)
|
|
119
|
+
const ROOM_HOURLY_PUSHES = 40; // one room's share of the shared push budget per hour
|
|
120
|
+
const CODE_MISS_PER_HOUR = 10; // v0.10.3: wrong /link, /setup or /join codes one caller may try per hour before 429
|
|
121
|
+
const PRESENCE_STALE_MS = 3 * 60 * 1000; // no /p/* call (heartbeat is every 60 s) for this long = offline
|
|
122
|
+
const PUSH_MAX_ATTEMPTS = 6; // review R2: ~30s,1m,2m,4m,8m,16m of backoff before a push is given up
|
|
123
|
+
const REPORT_TTL_MS = 30 * 24 * 3600 * 1000; // review R4: what /privacy promises
|
|
124
|
+
const REPORT_PUSHES_PER_DAY = 20; // review R4: reports must never eat the user-facing push budget
|
|
125
|
+
const INBOX_LEASE_MS_DEFAULT = 15 * 60 * 1000; // v0.8.0: delivered but not acked for this long = nobody was listening → remind + redeliver
|
|
126
|
+
const OFFLINE_GRACE_SEC = 90; // > the 60 s heartbeat, so a sibling session cancels the goodbye before it is announced
|
|
127
|
+
const IMG_TTL_DEFAULT = 3600; // LINE clients fetch the URL when each viewer first opens the chat — too short and late viewers see a broken image
|
|
128
|
+
const IMG_TTL_MIN = 2; // floor exists for smoke tests; humans should stay >= 50
|
|
129
|
+
const IMG_TTL_MAX = 86400;
|
|
130
|
+
const IMG_CHUNK = 100_000; // DO storage values are capped at 128 KiB; base64 chunks stay under it
|
|
131
|
+
|
|
132
|
+
export class BridgeDO extends DurableObject<BridgeEnv> {
|
|
133
|
+
private app = new Hono();
|
|
134
|
+
|
|
135
|
+
constructor(ctx: DurableObjectState, env: BridgeEnv) {
|
|
136
|
+
super(ctx, env);
|
|
137
|
+
this.routes();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
override async fetch(req: Request): Promise<Response> {
|
|
141
|
+
return this.app.fetch(req);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ------------------------------------------------------------ helpers ---
|
|
145
|
+
|
|
146
|
+
private get<T>(k: string): Promise<T | undefined> { return this.ctx.storage.get<T>(k); }
|
|
147
|
+
private put(k: string, v: unknown): Promise<void> { return this.ctx.storage.put(k, v); }
|
|
148
|
+
|
|
149
|
+
private async bindingByPub(pub: string): Promise<Binding | undefined> { return this.get<Binding>(`pub:${pub}`); }
|
|
150
|
+
private async bindingByUser(userId: string): Promise<Binding | undefined> { return this.get<Binding>(`user:${userId}`); }
|
|
151
|
+
|
|
152
|
+
/** What the remote MCP connector may see and do. Two tiers live behind this:
|
|
153
|
+
* an agent whose key is on its owner's machine is read-only here (we cannot sign
|
|
154
|
+
* for it), while a hosted agent's key IS in this DO under `hosted:<pub>` and can
|
|
155
|
+
* be signed with — but only after the same mandate check the local client runs,
|
|
156
|
+
* so nothing binding leaves unless its owner widened the rules. Custody is
|
|
157
|
+
* disclosed publicly at GET /hosted/:pub. */
|
|
158
|
+
private mcpDeps(): McpDeps {
|
|
159
|
+
return {
|
|
160
|
+
get: (k) => this.get(k),
|
|
161
|
+
put: (k, v) => this.put(k, v),
|
|
162
|
+
del: (k) => this.ctx.storage.delete(k).then(() => undefined),
|
|
163
|
+
bindingByUser: (u) => this.bindingByUser(u),
|
|
164
|
+
bindingByPub: (p) => this.bindingByPub(p),
|
|
165
|
+
roomsFor: async (p) => (await this.get<Record<string, RoomKnown>>(`rooms:${p}`)) ?? {},
|
|
166
|
+
pendingInbox: async (p) => {
|
|
167
|
+
const lastRead = await this.get<string>(`read:${p}`);
|
|
168
|
+
const items = (await this.get<InboxItem[]>(`inbox:${p}`)) ?? [];
|
|
169
|
+
return items.filter((i) => !lastRead || i.at > lastRead).length;
|
|
170
|
+
},
|
|
171
|
+
awayAt: (p) => this.get<string>(`offline:${p}`),
|
|
172
|
+
isPaused: async (p) => (await this.get<boolean>(`paused:${p}`)) ?? false,
|
|
173
|
+
lineOa: () => this.env.LINE_OA_ID,
|
|
174
|
+
limits: () => ({ roomsPerDay: Math.max(1, Number(this.env.ROOMS_PER_DAY ?? 10) || 10) }),
|
|
175
|
+
newRoomId: () => randomHex(6),
|
|
176
|
+
roomCall: (roomId, subpath, init) =>
|
|
177
|
+
this.env.ROOMS.get(this.env.ROOMS.idFromName(roomId))
|
|
178
|
+
.fetch(new Request(`https://do/rooms/${roomId}${subpath}`, init)),
|
|
179
|
+
// Same both-directions replacement as /bridge/link: one user ↔ one agent.
|
|
180
|
+
bind: async (userId, pub, name) => {
|
|
181
|
+
const prevByUser = await this.bindingByUser(userId);
|
|
182
|
+
if (prevByUser) await this.ctx.storage.delete(`pub:${prevByUser.pub}`);
|
|
183
|
+
const prevByPub = await this.bindingByPub(pub);
|
|
184
|
+
if (prevByPub) await this.ctx.storage.delete(`user:${prevByPub.userId}`);
|
|
185
|
+
const b: Binding = { pub, name, userId, agentMode: false, boundAt: new Date().toISOString() };
|
|
186
|
+
await this.put(`user:${userId}`, b);
|
|
187
|
+
await this.put(`pub:${pub}`, b);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async verifyAgent(c: { req: { header: (n: string) => string | undefined; method: string; path: string; text: () => Promise<string> } }): Promise<{ pub: string; body: string } | Response> {
|
|
193
|
+
const body = await c.req.text();
|
|
194
|
+
const v = verifyRequestHeaders((n) => c.req.header(n), c.req.method, c.req.path, body);
|
|
195
|
+
if (!v.ok) return Response.json({ error: `agent signature: ${v.error}` }, { status: 401 });
|
|
196
|
+
return { pub: v.pub, body };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---- presence --------------------------------------------------------------------------
|
|
200
|
+
private async presence(pub: string): Promise<{ online: boolean; lastSeen: string | null; lastRead: string | null; offlineAt: string | null; sinceMin: number | null }> {
|
|
201
|
+
const lastSeen = (await this.get<string>(`seen:${pub}`)) ?? null;
|
|
202
|
+
const lastRead = (await this.get<string>(`read:${pub}`)) ?? null;
|
|
203
|
+
const offlineAt = (await this.get<string>(`offline:${pub}`)) ?? null;
|
|
204
|
+
const seenMs = lastSeen ? Date.parse(lastSeen) : 0;
|
|
205
|
+
// A goodbye inside its grace is not yet an absence: it may be one of several sessions closing,
|
|
206
|
+
// and the next heartbeat (<= 60 s) would take it back. Report "still here" until the grace runs
|
|
207
|
+
// out — otherwise the bot tells the principal "away" for a minute and then has to take it back.
|
|
208
|
+
const pend = await this.get<OffPend>(`offpend:${pub}`);
|
|
209
|
+
const held = !!pend && Date.now() < pend.due;
|
|
210
|
+
const online = !!lastSeen && (held || !(offlineAt && Date.parse(offlineAt) >= seenMs)) && Date.now() - seenMs < PRESENCE_STALE_MS;
|
|
211
|
+
const sinceMin = online ? null : seenMs ? Math.max(0, Math.round((Date.now() - seenMs) / 60000)) : null;
|
|
212
|
+
return { online, lastSeen, lastRead, offlineAt, sinceMin };
|
|
213
|
+
}
|
|
214
|
+
private graceMs(): number {
|
|
215
|
+
const s = Number(this.env.PRESENCE_GRACE_SEC ?? OFFLINE_GRACE_SEC);
|
|
216
|
+
return (Number.isFinite(s) && s >= 0 ? s : OFFLINE_GRACE_SEC) * 1000;
|
|
217
|
+
}
|
|
218
|
+
/** Move the alarm earlier if needed; never later (the push queue owns the near end of it). */
|
|
219
|
+
/** v0.8.0: mark delivered items ≤ seq as acked. Returns how many changed. */
|
|
220
|
+
private async ackInbox(pub: string, seq: number): Promise<number> {
|
|
221
|
+
const all = (await this.get<InboxItem[]>(`inbox:${pub}`)) ?? [];
|
|
222
|
+
let n = 0;
|
|
223
|
+
const now = new Date().toISOString();
|
|
224
|
+
for (const i of all) if (i.deliveredAt && !i.ackedAt && i.seq <= seq) { i.ackedAt = now; n++; }
|
|
225
|
+
if (n) await this.put(`inbox:${pub}`, all);
|
|
226
|
+
return n;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** v0.8.0: items delivered, never acked, lease expired, principal not yet told → one LINE reminder each.
|
|
230
|
+
* Returns the next time something will be due, so the alarm keeps itself armed. */
|
|
231
|
+
private async sweepUnacked(): Promise<number | null> {
|
|
232
|
+
let next: number | null = null;
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
for (const [key, all] of await this.ctx.storage.list<InboxItem[]>({ prefix: "inbox:" })) {
|
|
235
|
+
const pub = key.slice("inbox:".length);
|
|
236
|
+
let changed = false;
|
|
237
|
+
const overdue: InboxItem[] = [];
|
|
238
|
+
for (const i of all) {
|
|
239
|
+
if (!i.deliveredAt || i.ackedAt || i.remindedAt) continue;
|
|
240
|
+
const due = Date.parse(i.deliveredAt) + this.leaseMs();
|
|
241
|
+
if (now >= due) { overdue.push(i); i.remindedAt = new Date(now).toISOString(); changed = true; }
|
|
242
|
+
else next = next == null ? due : Math.min(next, due);
|
|
243
|
+
}
|
|
244
|
+
if (!changed) continue;
|
|
245
|
+
await this.put(key, all);
|
|
246
|
+
const b = await this.bindingByPub(pub);
|
|
247
|
+
if (!b) continue;
|
|
248
|
+
// review R17: one reminder per place they asked from (DM, group A, group B), not one lump to the first.
|
|
249
|
+
const byDest = new Map<string, InboxItem[]>();
|
|
250
|
+
for (const i of overdue) { const to = i.group || b.userId; byDest.set(to, [...(byDest.get(to) ?? []), i]); }
|
|
251
|
+
for (const [to, list] of byDest) {
|
|
252
|
+
const first = list[0];
|
|
253
|
+
await this.push(to, "inbox:unanswered",
|
|
254
|
+
`⚠️ 你 ${Math.round((now - Date.parse(first.at)) / 60000)} 分鐘前交代的事(${list.length > 1 ? `#${first.seq} 等 ${list.length} 則` : `#${first.seq}`})agent 收到了但沒有回應。\n` +
|
|
255
|
+
`它下次值班會再拿到一次;一直沒回就看看那台電腦的 Claude Code 是否開著、值班(can2cup watch)是否在跑。`,
|
|
256
|
+
undefined, undefined, undefined, b.name || short(pub));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return next;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** review R4/R10/R25: storage that must not grow forever. */
|
|
263
|
+
// ---------------------------------------------------------- binding lifetime (v0.9.12) ---
|
|
264
|
+
// Security review "binding lifetime" (2026-09-05; the boss set 90 days). Principle: a 1:1 binding expires on
|
|
265
|
+
// the AGENT's absence, never on the principal's silence — the binding is the principal's brake and their
|
|
266
|
+
// notification path, and a quiet principal may have an agent busy on their behalf. seen:<pub> is written only
|
|
267
|
+
// by signed /p/* calls, so nobody can age a binding from outside. Warned first (T-14 d: one LINE push + one inbox
|
|
268
|
+
// item; reading that item is itself a renewal). On expiry, erase(binding) KEEPS principal:<pub> and spause:<pub>:
|
|
269
|
+
// the signed layer is independent of the LINE binding, so a signed pause never lifts because a binding lapsed.
|
|
270
|
+
// Hosted agents (key in this DO, no heartbeat) are excluded. Group wires follow their room's life instead.
|
|
271
|
+
private idleUnitMs(): number { return Number(this.env.IDLE_DAYS_SEC) > 0 ? 1000 : 86400_000; }
|
|
272
|
+
private idleSpec(): { ttlMs: number; warnMs: number; graceMs: number; groupWarnMs: number; sweepMs: number } {
|
|
273
|
+
const devSec = Number(this.env.IDLE_DAYS_SEC);
|
|
274
|
+
const ttlMs = devSec > 0 ? devSec * 1000 : Math.max(7, Number(this.env.IDLE_DAYS ?? 90) || 90) * 86400_000;
|
|
275
|
+
const warnSec = Number(this.env.IDLE_WARN_SEC);
|
|
276
|
+
const warnMs = Math.min(warnSec > 0 ? warnSec * 1000 : 14 * 86400_000, ttlMs / 2);
|
|
277
|
+
const graceMs = this.env.IDLE_GRACE_SEC != null && Number.isFinite(Number(this.env.IDLE_GRACE_SEC)) ? Number(this.env.IDLE_GRACE_SEC) * 1000 : 30 * 86400_000;
|
|
278
|
+
const gw = Number(this.env.GROUP_WARN_SEC);
|
|
279
|
+
const sw = Number(this.env.IDLE_SWEEP_SEC);
|
|
280
|
+
return { ttlMs, warnMs, graceMs, groupWarnMs: gw > 0 ? gw * 1000 : 3 * 86400_000, sweepMs: sw > 0 ? sw * 1000 : 86400_000 };
|
|
281
|
+
}
|
|
282
|
+
private fmtIdle(ms: number): string { return this.idleUnitMs() === 1000 ? `${Math.max(0, Math.round(ms / 1000))} 秒` : `${Math.max(0, Math.round(ms / 86400_000))} 天`; }
|
|
283
|
+
/** The clock for one binding: how long the agent has been gone, when it expires, and any /keep override. */
|
|
284
|
+
private async idleOf(pub: string, b: Binding): Promise<IdleState> {
|
|
285
|
+
const spec = this.idleSpec();
|
|
286
|
+
const pol = await this.get<IdlePolicy>(`idle:${pub}`);
|
|
287
|
+
const forever = pol?.days === 0;
|
|
288
|
+
const ttlMs = pol && pol.days > 0 ? pol.days * this.idleUnitMs() : spec.ttlMs;
|
|
289
|
+
const lastSeen = (await this.get<string>(`seen:${pub}`)) ?? null;
|
|
290
|
+
const base = Date.parse(lastSeen ?? b.boundAt ?? "") || Date.now();
|
|
291
|
+
const idleMs = Date.now() - base;
|
|
292
|
+
return { days: forever ? 0 : Math.round(ttlMs / this.idleUnitMs()), forever, by: pol?.by ?? null, lastSeen, idleMs, ttlMs, expiresAt: forever ? null : new Date(base + ttlMs).toISOString() };
|
|
293
|
+
}
|
|
294
|
+
/** Ask the RoomDO (in-process, not over the network) whether a wired room is still alive and when it ends. */
|
|
295
|
+
private async roomMeta(room: string): Promise<{ state: string; expiresAt: string; keepAliveSec: number } | null> {
|
|
296
|
+
try {
|
|
297
|
+
const res = await this.env.ROOMS.get(this.env.ROOMS.idFromName(room)).fetch(new Request(`https://do/rooms/${room}/internal/meta`));
|
|
298
|
+
return res.ok ? (await res.json()) as { state: string; expiresAt: string; keepAliveSec: number } : null;
|
|
299
|
+
} catch { return null; }
|
|
300
|
+
}
|
|
301
|
+
/** The principal spoke to the agent from a wired group: that is use, so the room's sliding life moves forward. */
|
|
302
|
+
private async touchRoom(room: string): Promise<void> {
|
|
303
|
+
try { await this.env.ROOMS.get(this.env.ROOMS.idFromName(room)).fetch(new Request(`https://do/rooms/${room}/internal/keepalive`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ touch: true }) })); }
|
|
304
|
+
catch { /* best effort */ }
|
|
305
|
+
}
|
|
306
|
+
private async unwireGroup(gid: string, m: Mirror): Promise<void> {
|
|
307
|
+
await this.ctx.storage.delete(`mirror:${gid}`);
|
|
308
|
+
await this.ctx.storage.delete(`ctx:${gid}`);
|
|
309
|
+
await this.ctx.storage.delete(`quiet:${gid}`);
|
|
310
|
+
await this.ctx.storage.delete(`mirrorwarn:${gid}`);
|
|
311
|
+
const list = ((await this.get<string[]>(`mirrors:${m.room}`)) ?? []).filter((g) => g !== gid);
|
|
312
|
+
if (list.length) await this.put(`mirrors:${m.room}`, list); else await this.ctx.storage.delete(`mirrors:${m.room}`);
|
|
313
|
+
}
|
|
314
|
+
/** One pass over bindings and group wires. `only` narrows it to one pub / one group (smoke uses that so the
|
|
315
|
+
* other agents in the run are never touched). Returns what it did. */
|
|
316
|
+
private async sweepIdle(only: { pub?: string; gid?: string } = {}): Promise<{ warned: string[]; expired: string[]; groupsWarned: string[]; groupsEnded: string[] }> {
|
|
317
|
+
const out = { warned: [] as string[], expired: [] as string[], groupsWarned: [] as string[], groupsEnded: [] as string[] };
|
|
318
|
+
const spec = this.idleSpec();
|
|
319
|
+
let since = await this.get<string>("idle:since"); // the first sweep on this relay starts the grace period
|
|
320
|
+
if (!since) { since = new Date().toISOString(); await this.put("idle:since", since); }
|
|
321
|
+
const graceOver = Date.now() - Date.parse(since) >= spec.graceMs;
|
|
322
|
+
const targets: Array<[string, Binding]> = only.gid && !only.pub ? []
|
|
323
|
+
: only.pub ? ((await this.get<Binding>(`pub:${only.pub}`)) ? [[only.pub, (await this.get<Binding>(`pub:${only.pub}`))!]] : [])
|
|
324
|
+
: [...(await this.ctx.storage.list<Binding>({ prefix: "pub:" }))].map(([k, b]) => [k.slice(4), b] as [string, Binding]);
|
|
325
|
+
for (const [pub, b] of targets) {
|
|
326
|
+
// v0.9.14: hosted agents are covered too — the connector marks them seen on every call (mcp-http.ts).
|
|
327
|
+
const st = await this.idleOf(pub, b);
|
|
328
|
+
if (st.forever) continue;
|
|
329
|
+
const name = b.name || short(pub);
|
|
330
|
+
if (st.idleMs >= st.ttlMs && graceOver) {
|
|
331
|
+
await this.erase(pub, "binding", { keepSigned: true });
|
|
332
|
+
await this.ctx.storage.delete(`idlewarn:${pub}`);
|
|
333
|
+
await this.push(b.userId, "idle:expired", `已自動解除跟「${name}」的綁定:那台電腦上的 agent 已經 ${this.fmtIdle(st.idleMs)} 沒出現。電腦上的檔案還在;要重接就打 /setup。你簽過的煞車和金鑰登記都沒有動。`);
|
|
334
|
+
out.expired.push(pub);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (st.idleMs >= st.ttlMs - spec.warnMs) {
|
|
338
|
+
if (await this.get(`idlewarn:${pub}`)) continue;
|
|
339
|
+
await this.put(`idlewarn:${pub}`, new Date().toISOString());
|
|
340
|
+
const left = this.fmtIdle(Math.max(0, st.ttlMs - st.idleMs));
|
|
341
|
+
await this.push(b.userId, "idle:warn", `你的 agent「${name}」已經 ${this.fmtIdle(st.idleMs)} 沒出現。再 ${left} 這個綁定會自動解除;它只要開一次(Claude Code 打開)就會續。要一直留著就打 /keep 永久。`);
|
|
342
|
+
await this.appendInbox(pub, { at: new Date().toISOString(), via: "relay", text: `BINDING EXPIRES in ${left} — this agent has not been seen for ${this.fmtIdle(st.idleMs)}. Any signed call renews it; reading this is one. Nothing else to do.` });
|
|
343
|
+
out.warned.push(pub);
|
|
344
|
+
} else if (await this.get(`idlewarn:${pub}`)) await this.ctx.storage.delete(`idlewarn:${pub}`); // renewed after a warning
|
|
345
|
+
}
|
|
346
|
+
// group wires follow their room: dead room → wire goes, group is told; T-3 d → group is warned once
|
|
347
|
+
const wires: Array<[string, Mirror]> = only.pub && !only.gid ? []
|
|
348
|
+
: only.gid ? ((await this.get<Mirror>(`mirror:${only.gid}`)) ? [[only.gid, (await this.get<Mirror>(`mirror:${only.gid}`))!]] : [])
|
|
349
|
+
: [...(await this.ctx.storage.list<Mirror>({ prefix: "mirror:" }))].map(([k, m]) => [k.slice(7), m] as [string, Mirror]);
|
|
350
|
+
for (const [gid, m] of wires) {
|
|
351
|
+
const meta = await this.roomMeta(m.room);
|
|
352
|
+
const known = await this.get<RoomKnown>(`room:${m.room}`);
|
|
353
|
+
const dead = !meta ? known?.state === "closed" : meta.state !== "open" || Date.now() >= Date.parse(meta.expiresAt);
|
|
354
|
+
if (dead) {
|
|
355
|
+
await this.unwireGroup(gid, m);
|
|
356
|
+
await this.push(gid, "group:ended", `這個群跟 agent 的連線已結束(那段對話${meta && meta.state === "open" ? `已到期,${this.fmtIdle(meta.keepAliveSec * 1000)}沒有人說話` : "已關閉"})。要重接,群裡有綁定的人打 /room。`);
|
|
357
|
+
out.groupsEnded.push(gid);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (!meta) continue;
|
|
361
|
+
const left = Date.parse(meta.expiresAt) - Date.now();
|
|
362
|
+
if (left <= spec.groupWarnMs) {
|
|
363
|
+
if (await this.get(`mirrorwarn:${gid}`)) continue;
|
|
364
|
+
await this.put(`mirrorwarn:${gid}`, new Date().toISOString());
|
|
365
|
+
await this.push(gid, "group:warn", `這個群跟 agent 的對話已經很久沒動了,再 ${this.fmtIdle(left)} 會自動斷開。任何人打 /a 說一句、或 agent 在這裡講話,就會續。`);
|
|
366
|
+
out.groupsWarned.push(gid);
|
|
367
|
+
} else if (await this.get(`mirrorwarn:${gid}`)) await this.ctx.storage.delete(`mirrorwarn:${gid}`);
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
/** /keep from either side. days 7..365, or forever:true → 0. Neither given = no change (the caller just wants to see). */
|
|
372
|
+
private async setIdle(pub: string, body: { days?: unknown; forever?: unknown }, by: "line" | "agent"): Promise<{ ok: true } | { error: string }> {
|
|
373
|
+
if (body.forever === true) { await this.put(`idle:${pub}`, { days: 0, by, at: new Date().toISOString() } satisfies IdlePolicy); return { ok: true }; }
|
|
374
|
+
if (body.days === undefined || body.days === null) return { ok: true };
|
|
375
|
+
const d = Math.round(Number(body.days));
|
|
376
|
+
if (!Number.isFinite(d) || d < 7 || d > 365) return { error: "days must be between 7 and 365 (or forever: true)" };
|
|
377
|
+
await this.put(`idle:${pub}`, { days: d, by, at: new Date().toISOString() } satisfies IdlePolicy);
|
|
378
|
+
return { ok: true };
|
|
379
|
+
}
|
|
380
|
+
/** Runs from the alarm at most once per sweep interval; returns when to look again. */
|
|
381
|
+
private async maybeSweepIdle(): Promise<number> {
|
|
382
|
+
const spec = this.idleSpec();
|
|
383
|
+
const last = Date.parse((await this.get<string>("idle:sweptAt")) ?? "") || 0;
|
|
384
|
+
if (Date.now() - last >= spec.sweepMs) {
|
|
385
|
+
await this.put("idle:sweptAt", new Date().toISOString());
|
|
386
|
+
try { await this.sweepIdle(); } catch (e) { console.error(`sweepIdle: ${e instanceof Error ? e.message : String(e)}`); }
|
|
387
|
+
return Date.now() + spec.sweepMs;
|
|
388
|
+
}
|
|
389
|
+
return last + spec.sweepMs;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private async purgeExpired(): Promise<void> {
|
|
393
|
+
const now = Date.now();
|
|
394
|
+
for (const [k, v] of await this.ctx.storage.list<{ at: string }>({ prefix: "report:" })) if (now - Date.parse(v.at) > REPORT_TTL_MS) await this.ctx.storage.delete(k);
|
|
395
|
+
for (const [k, v] of await this.ctx.storage.list<StoredInvite>({ prefix: "inv:" })) if (now - v.at > INVITE_TTL_MS) await this.ctx.storage.delete(k);
|
|
396
|
+
for (const [k, v] of await this.ctx.storage.list<{ at: number }>({ prefix: "evt:" })) if (now - v.at > 24 * 3600 * 1000) await this.ctx.storage.delete(k);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** review R2: one operator warning per key per month (the operator push shares the budget, so it must be rare). */
|
|
400
|
+
private async warnOperatorOnce(what: string, text: string): Promise<void> {
|
|
401
|
+
const op = this.env.OPERATOR_LINE_USER_ID;
|
|
402
|
+
if (!op) return;
|
|
403
|
+
const k = `opwarn:${what}:${new Date().toISOString().slice(0, 7)}`;
|
|
404
|
+
if (await this.get(k)) return;
|
|
405
|
+
await this.put(k, true);
|
|
406
|
+
// straight to the forwarder, bypassing the quota gate that just tripped
|
|
407
|
+
if (this.env.LINE_FORWARD_URL) { try { await fetch(this.env.LINE_FORWARD_URL, { method: "POST", headers: { "content-type": "application/json", "x-parley-bridge-key": this.env.BRIDGE_KEY ?? "" }, body: JSON.stringify({ to: op, text, quick: [] }), signal: AbortSignal.timeout(20000) }); } catch { /* best effort */ } }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** review R1/R3: is this agent a current participant of the room, as far as the bridge knows? */
|
|
411
|
+
/** v0.9.5 — everything this relay holds about one agent, and about the LINE account bound to it.
|
|
412
|
+
* `binding` unbinds the phone: the inbox, the groups it could address, the pause state, the
|
|
413
|
+
* presence bookkeeping. The agent keeps its keys and its rooms — a room belongs to the people in
|
|
414
|
+
* it, not to the bot. `all` additionally forgets the rooms and unwires every group it wired.
|
|
415
|
+
*
|
|
416
|
+
* Deliberately NOT deleted: `ban:*`. Otherwise unbinding would launder a ban.
|
|
417
|
+
* Deliberately NOT claimed: the other participants' copies. A room transcript is a signed chain
|
|
418
|
+
* that the other side already holds; deleting our copy does not retract it, and saying otherwise
|
|
419
|
+
* would be a lie. The caller reports that in words. */
|
|
420
|
+
/** opts.keepSigned (v0.9.12, idle expiry only): leave principal:<pub> and spause:<pub> — the signed layer outlives the LINE binding. */
|
|
421
|
+
private async erase(pub: string, scope: "binding" | "all", opts: { keepSigned?: boolean } = {}): Promise<Record<string, number>> {
|
|
422
|
+
const gone: Record<string, number> = {};
|
|
423
|
+
const tally = (k: string) => { const g = k.split(":")[0]; gone[g] = (gone[g] ?? 0) + 1; };
|
|
424
|
+
const drop = async (k: string) => {
|
|
425
|
+
if ((await this.ctx.storage.get(k)) === undefined) return;
|
|
426
|
+
await this.ctx.storage.delete(k);
|
|
427
|
+
tally(k);
|
|
428
|
+
};
|
|
429
|
+
const dropPrefix = async (p: string) => { for (const k of (await this.ctx.storage.list({ prefix: p })).keys()) await drop(k); };
|
|
430
|
+
|
|
431
|
+
const b = await this.bindingByPub(pub);
|
|
432
|
+
const groups = (await this.get<KnownGroup[]>(`groups:${pub}`)) ?? [];
|
|
433
|
+
const rooms = (await this.get<Record<string, RoomKnown>>(`rooms:${pub}`)) ?? {};
|
|
434
|
+
|
|
435
|
+
// Unwire the LINE groups this agent wired, so no group is left pointing at a room nobody reads.
|
|
436
|
+
// Only the ones it is actually in — another agent may have wired the same group since.
|
|
437
|
+
for (const g of groups) {
|
|
438
|
+
const m = await this.get<Mirror>(`mirror:${g.id}`);
|
|
439
|
+
if (m && (await this.inRoom(pub, m.room))) {
|
|
440
|
+
await drop(`mirror:${g.id}`);
|
|
441
|
+
const list = ((await this.get<string[]>(`mirrors:${m.room}`)) ?? []).filter((x) => x !== g.id);
|
|
442
|
+
if (list.length) await this.put(`mirrors:${m.room}`, list);
|
|
443
|
+
else { await drop(`mirrors:${m.room}`); await this.keepAlive(m.room, false); }
|
|
444
|
+
}
|
|
445
|
+
await drop(`quiet:${g.id}`);
|
|
446
|
+
await drop(`ctx:${g.id}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (const k of [`inbox:${pub}`, `inboxSeq:${pub}`, `read:${pub}`, `seen:${pub}`, `groups:${pub}`,
|
|
450
|
+
`galias:${pub}`, ...(opts.keepSigned ? [] : [`principal:${pub}`, `spause:${pub}`]), `paused:${pub}`,
|
|
451
|
+
`lastGroup:${pub}`, `offline:${pub}`, `offpend:${pub}`, `offtold:${pub}`,
|
|
452
|
+
`oldnag:${pub}`, `stale:${pub}`, `ver:${pub}`, `tier:${pub}`, `idle:${pub}`, `idlewarn:${pub}`]) await drop(k);
|
|
453
|
+
await dropPrefix(`roomreq:${pub}:`);
|
|
454
|
+
|
|
455
|
+
if (b) {
|
|
456
|
+
await drop(`pub:${pub}`);
|
|
457
|
+
await drop(`user:${b.userId}`);
|
|
458
|
+
// Anything still queued for that phone, and the delivery log naming it. A push that has already
|
|
459
|
+
// left for LINE is on LINE's servers and out of our hands — the caller says so.
|
|
460
|
+
for (const [k, q] of await this.ctx.storage.list<Queued>({ prefix: "pq:" })) if (q.to === b.userId) await drop(k);
|
|
461
|
+
const log = (await this.get<Pushed[]>("pushes")) ?? [];
|
|
462
|
+
const kept = log.filter((p) => p.to !== b.userId);
|
|
463
|
+
if (kept.length !== log.length) { await this.put("pushes", kept); tally(`push-log:${log.length - kept.length}`); gone["push-log"] = log.length - kept.length; }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (scope === "all") {
|
|
467
|
+
for (const id of Object.keys(rooms)) {
|
|
468
|
+
const known = await this.get<RoomKnown>(`room:${id}`);
|
|
469
|
+
// The registry entry is shared by everyone in the room; drop it only when nobody else is left.
|
|
470
|
+
if (known && Object.keys(known.participants).filter((p) => p !== pub).length === 0) {
|
|
471
|
+
await drop(`room:${id}`);
|
|
472
|
+
await drop(`recent:${id}`);
|
|
473
|
+
await drop(`mirrors:${id}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
await drop(`rooms:${pub}`);
|
|
477
|
+
}
|
|
478
|
+
return gone;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private async inRoom(pub: string, room: string): Promise<boolean> {
|
|
482
|
+
const known = await this.get<RoomKnown>(`room:${room}`);
|
|
483
|
+
return !!known && known.state === "open" && !!known.participants[pub];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** v0.9.9 (security G-2 §6.1, T4): a connected group may only be re-pointed by the person who connected it,
|
|
487
|
+
* while they are still bound and the room is still open. Before this, any bound member of the group could
|
|
488
|
+
* /room or /mirror it onto their own agent — and everything the group said from then on went to a different
|
|
489
|
+
* computer, with no one asked. 0.9.7 closed /context on the same way; this closes the wire itself.
|
|
490
|
+
* Returns the holder's name when somebody ELSE holds the wire; undefined when the caller may proceed.
|
|
491
|
+
* /unmirror stays open to everyone: the protective direction never needs permission. */
|
|
492
|
+
private async wiredByOther(gid: string, userId: string | undefined): Promise<{ by: string | null } | undefined> {
|
|
493
|
+
const cur = await this.get<Mirror>(`mirror:${gid}`);
|
|
494
|
+
if (!cur || !cur.by || cur.by === userId) return undefined;
|
|
495
|
+
const holder = await this.bindingByUser(cur.by);
|
|
496
|
+
const room = await this.get<RoomKnown>(`room:${cur.room}`);
|
|
497
|
+
if (!holder || !room || room.state !== "open") return undefined; // wirer gone or room dead: the wire is free
|
|
498
|
+
return { by: holder.name || null };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** v0.10.3 (review item left over from 0.9.4): a link / setup / invite code is 32 bits of hex or 40 bits of alphanumerics,
|
|
502
|
+
* alive for minutes to a day. Guessing one would take a very long time — unless nothing counts the misses. Now
|
|
503
|
+
* something does: CODE_MISS_PER_HOUR wrong codes per caller (LINE userId, or agent pub) per hour, then 429. Right
|
|
504
|
+
* codes are never counted, so a person who typos once is not punished; a script that hammers is. */
|
|
505
|
+
private async codeBlocked(kind: string, who: string): Promise<boolean> {
|
|
506
|
+
return ((await this.get<number>(`q:code:${kind}:${who}:${new Date().toISOString().slice(0, 13)}`)) ?? 0) >= CODE_MISS_PER_HOUR;
|
|
507
|
+
}
|
|
508
|
+
private async codeMiss(kind: string, who: string): Promise<void> {
|
|
509
|
+
const k = `q:code:${kind}:${who}:${new Date().toISOString().slice(0, 13)}`;
|
|
510
|
+
await this.put(k, ((await this.get<number>(k)) ?? 0) + 1);
|
|
511
|
+
}
|
|
512
|
+
private static readonly TOO_MANY_CODES = "too many wrong codes this hour — wait, then ask for a fresh code";
|
|
513
|
+
|
|
514
|
+
/** review R3: a LINE group mirrors exactly one room. Re-pointing it detaches it from the old room first. */
|
|
515
|
+
private async setMirror(gid: string, m: Mirror): Promise<void> {
|
|
516
|
+
const old = await this.get<Mirror>(`mirror:${gid}`);
|
|
517
|
+
if (old && old.room !== m.room) {
|
|
518
|
+
const prev = ((await this.get<string[]>(`mirrors:${old.room}`)) ?? []).filter((g) => g !== gid);
|
|
519
|
+
if (prev.length) await this.put(`mirrors:${old.room}`, prev); else await this.ctx.storage.delete(`mirrors:${old.room}`);
|
|
520
|
+
}
|
|
521
|
+
await this.put(`mirror:${gid}`, m);
|
|
522
|
+
const list = new Set((await this.get<string[]>(`mirrors:${m.room}`)) ?? []);
|
|
523
|
+
list.add(gid);
|
|
524
|
+
await this.put(`mirrors:${m.room}`, [...list]);
|
|
525
|
+
// v0.9.2: a room that IS a LINE group must not die on the 6 h default TTL. Wiring turns on the
|
|
526
|
+
// room's sliding keep-alive; the room then expires that long after its LAST message, not after
|
|
527
|
+
// its creation. A room nothing points at any more goes back to the ordinary TTL.
|
|
528
|
+
await this.keepAlive(m.room, true);
|
|
529
|
+
if (old && old.room !== m.room && !((await this.get<string[]>(`mirrors:${old.room}`)) ?? []).length) await this.keepAlive(old.room, false);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Turn a room's sliding keep-alive on or off. Best-effort: a room that has moved away or was
|
|
533
|
+
* never created here must not break wiring, which is the caller's actual job. */
|
|
534
|
+
private async keepAlive(room: string, on: boolean): Promise<void> {
|
|
535
|
+
const days = Math.max(1, Number(this.env.GROUP_ROOM_TTL_DAYS ?? 30) || 30);
|
|
536
|
+
try {
|
|
537
|
+
const res = await this.env.ROOMS.get(this.env.ROOMS.idFromName(room)).fetch(new Request(`https://do/rooms/${room}/internal/keepalive`, {
|
|
538
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
539
|
+
body: JSON.stringify({ on, keepAliveSec: days * 24 * 3600 }),
|
|
540
|
+
}));
|
|
541
|
+
if (!res.ok) console.error(`keepalive ${on ? "on" : "off"} for room ${room}: ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
542
|
+
} catch (e) { console.error(`keepalive ${on ? "on" : "off"} for room ${room} threw: ${e instanceof Error ? e.message : String(e)}`); }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private leaseMs(): number { const s = Number(this.env.INBOX_LEASE_SEC); return s > 0 ? s * 1000 : INBOX_LEASE_MS_DEFAULT; }
|
|
546
|
+
|
|
547
|
+
private async armAlarm(at: number): Promise<void> {
|
|
548
|
+
const cur = await this.ctx.storage.getAlarm();
|
|
549
|
+
if (cur == null || cur > at) await this.ctx.storage.setAlarm(at);
|
|
550
|
+
}
|
|
551
|
+
/** Remember that the principal has heard the agent is away — only then is a "back online" push worth sending. */
|
|
552
|
+
private async markToldOffline(pub: string, p: { online: boolean }): Promise<void> {
|
|
553
|
+
if (p.online) return;
|
|
554
|
+
if (!(await this.get<string>(`offtold:${pub}`))) await this.put(`offtold:${pub}`, new Date().toISOString());
|
|
555
|
+
}
|
|
556
|
+
/** Every authenticated agent call lands here. Detects offline→online and tells the principal once. */
|
|
557
|
+
// ---- v0.9.0 upgrade protocol -------------------------------------------------------------
|
|
558
|
+
// Every /p/* call carries `x-can2cup-client`; every reply carries `x-can2cup-latest` (what /dl/VERSION serves)
|
|
559
|
+
// and `x-can2cup-min` (below it, the A2A routes answer 426). The agent reads those and decides; a client
|
|
560
|
+
// too old to send the header at all gets one LINE nudge a month to its principal (see /p/online).
|
|
561
|
+
private latestCache: { v: string | null; at: number } = { v: null, at: 0 };
|
|
562
|
+
private async latestVersion(): Promise<string | null> {
|
|
563
|
+
if (Date.now() - this.latestCache.at < 5 * 60_000) return this.latestCache.v;
|
|
564
|
+
let v: string | null = null;
|
|
565
|
+
try {
|
|
566
|
+
const r = await this.env.ASSETS?.fetch(new Request("https://assets.local/dl/VERSION"));
|
|
567
|
+
if (r?.ok) v = (await r.text()).trim() || null;
|
|
568
|
+
} catch { /* no assets binding (dev) */ }
|
|
569
|
+
v = v ?? this.env.LATEST_CLIENT?.trim() ?? this.latestCache.v;
|
|
570
|
+
this.latestCache = { v, at: Date.now() };
|
|
571
|
+
return v;
|
|
572
|
+
}
|
|
573
|
+
private minClient(): string { return (this.env.MIN_CLIENT ?? "").trim() || NO_VERSION; }
|
|
574
|
+
private async upgradeRequired(c: { req: { url: string }; json: (o: unknown, s: number) => Response }, ver: string): Promise<Response> {
|
|
575
|
+
const origin = new URL(c.req.url).origin;
|
|
576
|
+
const latest = await this.latestVersion();
|
|
577
|
+
const min = this.minClient();
|
|
578
|
+
const who = ver === NO_VERSION ? "(pre-0.9, version unknown)" : ver;
|
|
579
|
+
return c.json({ error: `upgrade required: can2cup ${who} is below this relay's minimum ${min} — run \`can2cup upgrade\` on that computer (= npm i -g ${origin}/dl/can2cup.tgz), then restart Claude Code once`, min, latest, cmd: "can2cup upgrade" }, 426);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private async touch(pub: string, path: string, host?: string): Promise<void> {
|
|
583
|
+
const before = await this.presence(pub);
|
|
584
|
+
const now = new Date().toISOString();
|
|
585
|
+
await this.put(`seen:${pub}`, now);
|
|
586
|
+
// v0.9.14 (G-4 R8): which of this relay's names the agent last used — so an old name is retired on numbers, not guesses.
|
|
587
|
+
if (host && (await this.get<string>(`host:${pub}`)) !== host) await this.put(`host:${pub}`, host);
|
|
588
|
+
if (path.startsWith("/p/inbox")) await this.put(`read:${pub}`, now);
|
|
589
|
+
// review R20: a session that dies without a goodbye (power loss, kill -9) never posts /p/offline. Arm a
|
|
590
|
+
// check for when it would count as stale; sweepStale() announces it if nothing touched us by then.
|
|
591
|
+
await this.put(`stale:${pub}`, Date.now() + PRESENCE_STALE_MS + 1000);
|
|
592
|
+
await this.armAlarm(Date.now() + PRESENCE_STALE_MS + 2000);
|
|
593
|
+
if (path === "/p/offline") return; // handled by the route itself
|
|
594
|
+
// Any call from the agent means it is here. A goodbye still inside its grace was never announced
|
|
595
|
+
// (another session of the same agent is alive, or this one restarted at once) — drop it silently;
|
|
596
|
+
// `before.online` is true while it is held, so the "back" push below is correctly skipped.
|
|
597
|
+
if (await this.get<OffPend>(`offpend:${pub}`)) {
|
|
598
|
+
await this.ctx.storage.delete(`offpend:${pub}`);
|
|
599
|
+
await this.ctx.storage.delete(`offline:${pub}`);
|
|
600
|
+
}
|
|
601
|
+
if (!before.online) {
|
|
602
|
+
await this.ctx.storage.delete(`offline:${pub}`);
|
|
603
|
+
const told = await this.get<string>(`offtold:${pub}`);
|
|
604
|
+
if (told) await this.ctx.storage.delete(`offtold:${pub}`);
|
|
605
|
+
const b = await this.bindingByPub(pub);
|
|
606
|
+
if (b && before.lastSeen && told) { // never-seen agents, and absences nobody was told about, get no "back" push
|
|
607
|
+
const queued = ((await this.get<InboxItem[]>(`inbox:${pub}`)) ?? []).filter((i) => !before.lastRead || i.at > before.lastRead).length;
|
|
608
|
+
const gone = before.sinceMin != null ? `離線 ${before.sinceMin} 分鐘後` : "";
|
|
609
|
+
await this.push(b.userId, "presence:online", `🟢 你的 agent(${b.name || short(pub)})回來了${gone ? `(${gone})` : ""}。${queued ? `排隊中的 ${queued} 則指令會在它下次讀取時送達。` : ""}`.trim());
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
private offlineNote(p: { online: boolean; sinceMin: number | null; lastSeen: string | null }): string {
|
|
614
|
+
if (p.online) return "";
|
|
615
|
+
if (!p.lastSeen) return "(你的 agent 還沒上線過)";
|
|
616
|
+
return `(你的 agent 目前離線${p.sinceMin != null ? `,最後在線 ${p.sinceMin} 分鐘前` : ""})`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private async appendInbox(pub: string, item: Omit<InboxItem, "seq">): Promise<number> {
|
|
620
|
+
const seq = ((await this.get<number>(`inboxSeq:${pub}`)) ?? 0) + 1;
|
|
621
|
+
const items = (await this.get<InboxItem[]>(`inbox:${pub}`)) ?? [];
|
|
622
|
+
items.push({ seq, ...item });
|
|
623
|
+
// review R19: never silently drop an unanswered instruction — shed acked ones first.
|
|
624
|
+
let kept = items;
|
|
625
|
+
if (kept.length > 100) {
|
|
626
|
+
const unacked = kept.filter((i) => !i.ackedAt);
|
|
627
|
+
const acked = kept.filter((i) => !!i.ackedAt);
|
|
628
|
+
kept = [...acked.slice(-Math.max(0, 100 - unacked.length)), ...unacked].sort((x, y) => x.seq - y.seq).slice(-200);
|
|
629
|
+
}
|
|
630
|
+
await this.put(`inbox:${pub}`, kept);
|
|
631
|
+
await this.put(`inboxSeq:${pub}`, seq);
|
|
632
|
+
return seq;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** v0.4.5: remember every group the principal speaks from, with a stable short alias (g1, g2, …). */
|
|
636
|
+
private async noteGroup(pub: string, groupId: string, groupName?: string): Promise<KnownGroup> {
|
|
637
|
+
const list = (await this.get<KnownGroup[]>(`groups:${pub}`)) ?? [];
|
|
638
|
+
let g = list.find((x) => x.id === groupId);
|
|
639
|
+
if (!g) { const n = ((await this.get<number>(`galias:${pub}`)) ?? list.length) + 1; await this.put(`galias:${pub}`, n); g = { id: groupId, alias: `g${n}`, ...(groupName ? { name: groupName } : {}), lastAt: new Date().toISOString() }; list.push(g); }
|
|
640
|
+
else { g.lastAt = new Date().toISOString(); if (groupName) g.name = groupName; }
|
|
641
|
+
await this.put(`groups:${pub}`, list.slice(-30));
|
|
642
|
+
return g;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private async pushCount(): Promise<{ key: string; n: number; budget: number }> {
|
|
646
|
+
const key = `quota:${new Date().toISOString().slice(0, 7)}`;
|
|
647
|
+
const n = (await this.get<number>(key)) ?? 0;
|
|
648
|
+
const budget = Number(this.env.PUSH_BUDGET ?? 180) || 180;
|
|
649
|
+
return { key, n, budget };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Queue a push (with optional quick-reply buttons); alarm() delivers. Storage-only, so callers stay atomic. */
|
|
653
|
+
/** `sender` (v0.7.4) is the LINE bubble's author name — an agent speaking in a mirrored group reads as that
|
|
654
|
+
* agent, not as the bot. Name only (LINE caps it at 20 chars); the icon stays the OA's. */
|
|
655
|
+
private async push(to: string, kind: string, text: string, quick?: Quick[], room?: string, image?: string, sender?: string): Promise<void> {
|
|
656
|
+
const n = ((await this.get<number>("pqSeq")) ?? 0) + 1;
|
|
657
|
+
await this.put("pqSeq", n);
|
|
658
|
+
await this.put(`pq:${String(n).padStart(10, "0")}`, { at: new Date().toISOString(), to, kind, text, quick, room, image, ...(sender ? { sender: sender.slice(0, 20) } : {}) } as Queued);
|
|
659
|
+
await this.armAlarm(Date.now() + 10);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** A goodbye whose grace has expired: announce it only if the agent really is still gone. */
|
|
663
|
+
/** review R20: agents that stopped heartbeating without a goodbye. */
|
|
664
|
+
private async sweepStale(): Promise<number | null> {
|
|
665
|
+
let next: number | null = null;
|
|
666
|
+
for (const [key, due] of await this.ctx.storage.list<number>({ prefix: "stale:" })) {
|
|
667
|
+
if (Date.now() < due) { next = next == null ? due : Math.min(next, due); continue; }
|
|
668
|
+
const pub = key.slice("stale:".length);
|
|
669
|
+
await this.ctx.storage.delete(key);
|
|
670
|
+
const p = await this.presence(pub);
|
|
671
|
+
if (p.online) continue; // touched again meanwhile (the key was re-armed and we will see the new one)
|
|
672
|
+
if (await this.get<string>(`offtold:${pub}`)) continue; // already announced by the goodbye path
|
|
673
|
+
const b = await this.bindingByPub(pub);
|
|
674
|
+
if (!b) continue;
|
|
675
|
+
await this.put(`offtold:${pub}`, new Date().toISOString());
|
|
676
|
+
const rooms = (await this.get<Record<string, RoomKnown>>(`rooms:${pub}`)) ?? {};
|
|
677
|
+
const open = Object.values(rooms).filter((r) => r.state === "open").length;
|
|
678
|
+
await this.push(b.userId, "presence:offline", `🔴 你的 agent(${b.name || short(pub)})沒有回應了(可能電腦睡眠或 Claude Code 被關掉)。${open ? `還有 ${open} 個對話開著;` : ""}你在這裡打的 /a 會排隊,等它回來再送達。`);
|
|
679
|
+
}
|
|
680
|
+
return next;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private async sweepOffline(): Promise<number | null> {
|
|
684
|
+
let next: number | null = null;
|
|
685
|
+
for (const [key, p] of await this.ctx.storage.list<OffPend>({ prefix: "offpend:" })) {
|
|
686
|
+
if (Date.now() < p.due) { next = next == null ? p.due : Math.min(next, p.due); continue; }
|
|
687
|
+
const pub = key.slice("offpend:".length);
|
|
688
|
+
await this.ctx.storage.delete(key);
|
|
689
|
+
if (!(await this.get<string>(`offline:${pub}`))) continue; // a /p/* call cleared it: the agent is still around
|
|
690
|
+
const seen = await this.get<string>(`seen:${pub}`);
|
|
691
|
+
if (seen && seen > p.at) continue; // heartbeat after the goodbye: a sibling session is holding the fort
|
|
692
|
+
const b = await this.bindingByPub(pub);
|
|
693
|
+
if (!b) continue;
|
|
694
|
+
await this.put(`offtold:${pub}`, p.at);
|
|
695
|
+
const rooms = (await this.get<Record<string, RoomKnown>>(`rooms:${pub}`)) ?? {};
|
|
696
|
+
const open = Object.values(rooms).filter((r) => r.state === "open").length;
|
|
697
|
+
await this.push(b.userId, "presence:offline", `🔴 你的 agent(${b.name || short(pub)})已離線(Claude Code 關閉)。${open ? `還有 ${open} 個對話開著;` : ""}你在這裡打的 /a 會排隊,等它回來再送達。`);
|
|
698
|
+
}
|
|
699
|
+
return next;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
override async alarm(): Promise<void> {
|
|
703
|
+
const nextOffline = await this.sweepOffline(); // may queue a push, so run it before the queue drains
|
|
704
|
+
const nextStale = await this.sweepStale(); // review R20
|
|
705
|
+
const nextUnacked = await this.sweepUnacked(); // v0.8.0: may queue a reminder push, same reason
|
|
706
|
+
await this.purgeExpired(); // review R4/R10: reports (30 d), invites (24 h), webhook dedup (1 d)
|
|
707
|
+
const nextIdle = await this.maybeSweepIdle(); // v0.9.12: bindings whose agent is gone, wires whose room is dead
|
|
708
|
+
let nextRetry: number | null = null;
|
|
709
|
+
const items = await this.ctx.storage.list<Queued>({ prefix: "pq:" });
|
|
710
|
+
for (const [key, item] of items) {
|
|
711
|
+
if (item.nextAt && Date.now() < item.nextAt) { nextRetry = nextRetry == null ? item.nextAt : Math.min(nextRetry, item.nextAt); continue; }
|
|
712
|
+
const delivered = await this.deliver(item);
|
|
713
|
+
const attempts = (item.attempts ?? 0) + 1;
|
|
714
|
+
// review R2: transient failures are retried with backoff; only a final verdict removes the push.
|
|
715
|
+
const transient = /^forward (5\d\d|error)|^line (5\d\d|429|error)|^room-rate-limited$/.test(delivered);
|
|
716
|
+
if (transient && attempts < PUSH_MAX_ATTEMPTS) {
|
|
717
|
+
const nextAt = Date.now() + 30_000 * 2 ** (attempts - 1);
|
|
718
|
+
await this.put(key, { ...item, attempts, nextAt } as Queued);
|
|
719
|
+
nextRetry = nextRetry == null ? nextAt : Math.min(nextRetry, nextAt);
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
const log = (await this.get<Pushed[]>("pushes")) ?? [];
|
|
723
|
+
log.push({ at: item.at, to: item.to, kind: item.kind, text: item.text, delivered, ...(item.image ? { image: item.image } : {}), ...(item.sender ? { sender: item.sender } : {}), ...(attempts > 1 ? { attempts } : {}) });
|
|
724
|
+
await this.put("pushes", log.slice(-50));
|
|
725
|
+
await this.ctx.storage.delete(key);
|
|
726
|
+
if (delivered === "over-budget") await this.warnOperatorOnce("budget", `⚠️ relay 這個月的 LINE 推播額度用完了(${item.kind} 給 ${short(item.to)} 沒送出)。之後到月底的推播都會消失。`);
|
|
727
|
+
}
|
|
728
|
+
if (nextRetry != null) await this.armAlarm(nextRetry);
|
|
729
|
+
// v0.4.5: purge expired ephemeral images; re-arm the alarm for the next expiry.
|
|
730
|
+
const metas = await this.ctx.storage.list<ImgMeta>({ prefix: "img:" });
|
|
731
|
+
let next: number | null = null;
|
|
732
|
+
for (const t of [nextOffline, nextStale, nextUnacked, nextIdle]) if (t != null) next = next == null ? t : Math.min(next, t);
|
|
733
|
+
for (const [k, m] of metas) {
|
|
734
|
+
if (Date.now() >= m.expires) await this.purgeImage(k.slice(4), m.chunks);
|
|
735
|
+
else next = next == null ? m.expires : Math.min(next, m.expires);
|
|
736
|
+
}
|
|
737
|
+
if (next != null) await this.armAlarm(next + 1000);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private async purgeImage(id: string, chunks: number): Promise<void> {
|
|
741
|
+
await this.ctx.storage.delete(`img:${id}`);
|
|
742
|
+
for (let i = 0; i < chunks; i++) await this.ctx.storage.delete(`imgc:${id}:${i}`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private async deliver({ to, text, quick, room, image, sender }: Queued): Promise<string> {
|
|
746
|
+
const q = await this.pushCount();
|
|
747
|
+
const tKey = `quota:u:${to}:${new Date().toISOString().slice(0, 7)}`;
|
|
748
|
+
const tN = (await this.get<number>(tKey)) ?? 0;
|
|
749
|
+
const tBudget = Math.max(1, Number(this.env.PUSH_USER_BUDGET ?? 60) || 60);
|
|
750
|
+
const rlKey = room ? `rl:${room}:${Math.floor(Date.now() / 3600000)}` : "";
|
|
751
|
+
const rlN = rlKey ? ((await this.get<number>(rlKey)) ?? 0) : 0;
|
|
752
|
+
let delivered = "recorded";
|
|
753
|
+
if (q.n >= q.budget) delivered = "over-budget";
|
|
754
|
+
else if (tN >= tBudget) delivered = "target-over-budget";
|
|
755
|
+
else if (await this.get(`ban:user:${to}`)) delivered = "target-banned";
|
|
756
|
+
else if (rlKey && rlN >= ROOM_HOURLY_PUSHES) delivered = "room-rate-limited";
|
|
757
|
+
else if (this.env.LINE_FORWARD_URL) {
|
|
758
|
+
try {
|
|
759
|
+
const r = await fetch(this.env.LINE_FORWARD_URL, { method: "POST", headers: { "content-type": "application/json", "x-parley-bridge-key": this.env.BRIDGE_KEY ?? "" }, body: JSON.stringify({ to, text, quick: quick ?? [], ...(image ? { image } : {}), ...(sender ? { sender } : {}) }), signal: AbortSignal.timeout(20000) });
|
|
760
|
+
delivered = r.ok ? "forwarded" : `forward ${r.status}`;
|
|
761
|
+
} catch (e) { delivered = `forward error: ${e instanceof Error ? e.message : e}`; }
|
|
762
|
+
} else if (this.env.LINE_CHANNEL_ACCESS_TOKEN) {
|
|
763
|
+
const message: Record<string, unknown> = { type: "text", text: text.slice(0, 4900), ...(sender ? { sender: { name: sender } } : {}) };
|
|
764
|
+
if (quick?.length) message.quickReply = { items: quick.map((b) => ({ type: "action", action: b.fill
|
|
765
|
+
? { type: "postback", label: b.label.slice(0, 20), data: b.data, inputOption: "openKeyboard", fillInText: b.fill }
|
|
766
|
+
: { type: "postback", label: b.label.slice(0, 20), data: b.data, displayText: b.label } })) };
|
|
767
|
+
const messages: unknown[] = image ? [{ type: "image", originalContentUrl: image, previewImageUrl: image }, message] : [message];
|
|
768
|
+
try {
|
|
769
|
+
const r = await fetch("https://api.line.me/v2/bot/message/push", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${this.env.LINE_CHANNEL_ACCESS_TOKEN}` }, body: JSON.stringify({ to, messages }) });
|
|
770
|
+
delivered = r.ok ? "line" : `line ${r.status}`;
|
|
771
|
+
} catch (e) { delivered = `line error: ${e instanceof Error ? e.message : e}`; }
|
|
772
|
+
}
|
|
773
|
+
if (delivered === "forwarded" || delivered === "line") {
|
|
774
|
+
await this.put(q.key, q.n + 1);
|
|
775
|
+
await this.put(tKey, tN + 1);
|
|
776
|
+
if (rlKey) await this.put(rlKey, rlN + 1);
|
|
777
|
+
}
|
|
778
|
+
return delivered;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** One envelope as a LINE bubble. Phone width is ~16 CJK chars, so: a short header line
|
|
782
|
+
* (who + what kind + #seq — the seq is what APPROVE/REJECT buttons refer to), then the body.
|
|
783
|
+
* Plain text drops the type tag entirely: in a mirrored group it should read like chat. */
|
|
784
|
+
private fmtEnvelope(e: Envelope, names: Record<string, string>): string {
|
|
785
|
+
const { sender, text } = this.fmtBubble(e, names);
|
|
786
|
+
if (!sender) return text;
|
|
787
|
+
return e.type === "text" ? `${sender}:\n${text}` : `${sender} ${text}`;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** (author, body) for one envelope. On LINE the author goes into the bubble's `sender` name, so a
|
|
791
|
+
* mirrored room reads like a chat between the agents; text form (/show, history) re-joins the two. */
|
|
792
|
+
private fmtBubble(e: Envelope, names: Record<string, string>): { sender?: string; text: string } {
|
|
793
|
+
const b = (e.body ?? {}) as Record<string, unknown>;
|
|
794
|
+
const who = e.from === "relay" ? "relay" : (names[e.from] ?? short(e.from));
|
|
795
|
+
const bits: string[] = [];
|
|
796
|
+
if (e.type === "system") { const s = b as { event?: string; name?: string }; return { text: `· ${SYSTEM_LABEL[s.event ?? ""] ?? s.event ?? "system"} ${s.name ?? ""}`.trim() }; }
|
|
797
|
+
const label = e.type === "text" ? "" : `${TYPE_LABEL[e.type] ?? e.type} #${e.seq}\n`;
|
|
798
|
+
if (b.e2e === 1) return { sender: who, text: `${label}🔒 端對端加密,relay 讀不到\n請在電腦上看` };
|
|
799
|
+
if (typeof b.text === "string") bits.push(b.text);
|
|
800
|
+
if (b.amount != null) bits.push(`金額 ${b.amount}`);
|
|
801
|
+
if (e.type === "grant") bits.push(`範圍 ${b.scope}\n到期 ${b.expires}`);
|
|
802
|
+
if (e.type === "revoke") bits.push(`撤銷 #${b.ref}`);
|
|
803
|
+
if (e.type === "attachment") bits.push(`📎 ${b.name ?? ""}\n${b.url ?? ""}`.trim());
|
|
804
|
+
return { sender: who, text: `${label}${bits.join("\n")}`.trim() || "(無內容)" };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// -------------------------------------------------------------- routes ---
|
|
808
|
+
|
|
809
|
+
private routes() {
|
|
810
|
+
const app = this.app;
|
|
811
|
+
|
|
812
|
+
// ---- remote MCP connector (zero-install surface) -----------------------
|
|
813
|
+
// Lives here because this DO already holds the LINE binding the OAuth step
|
|
814
|
+
// authenticates against, and the room knowledge the tools read.
|
|
815
|
+
app.post("/mcp", async (c) => handleMcp(this.mcpDeps(), c.req.raw, new URL(c.req.url).origin));
|
|
816
|
+
app.get("/mcp", async (c) => handleMcp(this.mcpDeps(), c.req.raw, new URL(c.req.url).origin));
|
|
817
|
+
app.post("/oauth/register", async (c) => mcpRegister(this.mcpDeps(), await c.req.json().catch(() => ({}))));
|
|
818
|
+
app.get("/oauth/authorize", async (c) => authorizeGet(this.mcpDeps(), new URL(c.req.url), new URL(c.req.url).origin));
|
|
819
|
+
app.post("/oauth/authorize", async (c) => authorizePost(this.mcpDeps(), new URL(c.req.url), await c.req.formData(), new URL(c.req.url).origin));
|
|
820
|
+
app.post("/oauth/token", async (c) => mcpToken(this.mcpDeps(), new URLSearchParams(await c.req.text())));
|
|
821
|
+
|
|
822
|
+
// Public custody disclosure. Anyone reading a transcript can ask whether a
|
|
823
|
+
// participant's key is held by this relay or by its owner — otherwise the two
|
|
824
|
+
// tiers are indistinguishable from the signature alone and the strong one is
|
|
825
|
+
// silently devalued. No auth: the whole point is that a third party can check.
|
|
826
|
+
app.get("/hosted/:pub", async (c) => {
|
|
827
|
+
const pub = c.req.param("pub");
|
|
828
|
+
if (!/^[0-9a-f]{64}$/.test(pub)) return c.json({ error: "pub must be a 64-hex ed25519 key" }, 400);
|
|
829
|
+
const h = await hostedKeyOf(this.mcpDeps(), pub);
|
|
830
|
+
// Custody only. What a hosted agent may say is its owner's mandate, not ours to publish.
|
|
831
|
+
return c.json({ pub, hosted: !!h, ...(h ? { since: h.createdAt } : {}) });
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// ---- operator administration (the worker checks RELAY_KEY before routing /admin/* here) ----
|
|
835
|
+
// A ban means "this person", not "this key": banning either side of a LINE binding bans both.
|
|
836
|
+
const banTargets = async (b: { pub?: string; userId?: string }) => {
|
|
837
|
+
const pubs = new Set<string>(); const users = new Set<string>();
|
|
838
|
+
if (b.pub) { pubs.add(b.pub); const bd = await this.bindingByPub(b.pub); if (bd) users.add(bd.userId); }
|
|
839
|
+
if (b.userId) { users.add(b.userId); const bd = await this.bindingByUser(b.userId); if (bd) pubs.add(bd.pub); }
|
|
840
|
+
return { pubs, users };
|
|
841
|
+
};
|
|
842
|
+
app.post("/admin/ban", async (c) => {
|
|
843
|
+
const b = (await c.req.json().catch(() => ({}))) as { pub?: string; userId?: string; reason?: string };
|
|
844
|
+
if (!b.pub && !b.userId) return c.json({ error: "pub or userId required" }, 400);
|
|
845
|
+
if (b.pub && !/^[0-9a-f]{64}$/.test(b.pub)) return c.json({ error: "pub must be 64-hex" }, 400);
|
|
846
|
+
const rec = { at: new Date().toISOString(), reason: b.reason ?? "" };
|
|
847
|
+
const { pubs, users } = await banTargets(b);
|
|
848
|
+
for (const p of pubs) await this.put(`ban:pub:${p}`, rec);
|
|
849
|
+
for (const u of users) await this.put(`ban:user:${u}`, rec);
|
|
850
|
+
return c.json({ ok: true, banned: { pubs: [...pubs], users: [...users].map(short) } });
|
|
851
|
+
});
|
|
852
|
+
app.post("/admin/unban", async (c) => {
|
|
853
|
+
const b = (await c.req.json().catch(() => ({}))) as { pub?: string; userId?: string };
|
|
854
|
+
if (!b.pub && !b.userId) return c.json({ error: "pub or userId required" }, 400);
|
|
855
|
+
const { pubs, users } = await banTargets(b);
|
|
856
|
+
for (const p of pubs) await this.ctx.storage.delete(`ban:pub:${p}`);
|
|
857
|
+
for (const u of users) await this.ctx.storage.delete(`ban:user:${u}`);
|
|
858
|
+
return c.json({ ok: true });
|
|
859
|
+
});
|
|
860
|
+
// v0.9.14 (G-4 R8): how many agents still use each of this relay's names, and when they were last seen.
|
|
861
|
+
// Retire an old name only when it reads zero for 30 days — pulling a name kills its invite links and configs.
|
|
862
|
+
app.get("/admin/hosts", async (c) => {
|
|
863
|
+
const hosts: Record<string, { agents: number; bound: number; lastSeen: string | null }> = {};
|
|
864
|
+
for (const [k, host] of await this.ctx.storage.list<string>({ prefix: "host:" })) {
|
|
865
|
+
const pub = k.slice(5);
|
|
866
|
+
const h = (hosts[host] ??= { agents: 0, bound: 0, lastSeen: null });
|
|
867
|
+
h.agents++;
|
|
868
|
+
if (await this.get<Binding>(`pub:${pub}`)) h.bound++;
|
|
869
|
+
const seen = (await this.get<string>(`seen:${pub}`)) ?? null;
|
|
870
|
+
if (seen && (!h.lastSeen || seen > h.lastSeen)) h.lastSeen = seen;
|
|
871
|
+
}
|
|
872
|
+
return c.json({ hosts, note: "agents = distinct keys whose last call used that name; bound = of those, still LINE-bound. Retire a name at zero for 30 days, never sooner." });
|
|
873
|
+
});
|
|
874
|
+
app.get("/admin/bans", async (c) => {
|
|
875
|
+
const bans: Record<string, unknown> = {};
|
|
876
|
+
for (const [k, v] of await this.ctx.storage.list({ prefix: "ban:" })) bans[k] = v;
|
|
877
|
+
return c.json({ bans });
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// ---- agent-signed -----------------------------------------------------
|
|
881
|
+
app.use("/p/*", async (c, next) => {
|
|
882
|
+
const v = await this.verifyAgent(c);
|
|
883
|
+
if (v instanceof Response) return v;
|
|
884
|
+
if (await this.get(`ban:pub:${v.pub}`)) return c.json({ error: "this identity is banned by the relay operator" }, 403);
|
|
885
|
+
c.set("pub" as never, v.pub as never);
|
|
886
|
+
c.set("body" as never, v.body as never);
|
|
887
|
+
// v0.9.0 upgrade protocol: remember what this agent runs; refuse the A2A routes below the minimum.
|
|
888
|
+
const ver = (c.req.header("x-can2cup-client") ?? "").trim() || NO_VERSION;
|
|
889
|
+
c.set("ver" as never, ver as never);
|
|
890
|
+
if (ver !== NO_VERSION && (await this.get<string>(`ver:${v.pub}`)) !== ver) await this.put(`ver:${v.pub}`, ver);
|
|
891
|
+
if (/^\/p\/(rooms|room-created|invite)$/.test(c.req.path) && cmpSemver(ver, this.minClient()) < 0) {
|
|
892
|
+
c.res = await this.upgradeRequired(c, ver);
|
|
893
|
+
} else {
|
|
894
|
+
// review R20: only a live session touches presence. A one-off `doctor`, `report` or room create must not
|
|
895
|
+
// light the green dot for 3 minutes or trigger a "回來了" push.
|
|
896
|
+
if (/^\/p\/(online|heartbeat|inbox|notify|offline|state|ack)$/.test(c.req.path)) await this.touch(v.pub, c.req.path, c.req.header("host") ?? undefined);
|
|
897
|
+
await next();
|
|
898
|
+
}
|
|
899
|
+
// Version headers on every reply, whatever the route returned (a proxied DO response has immutable headers).
|
|
900
|
+
const latest = await this.latestVersion();
|
|
901
|
+
const set = (h: Headers) => { if (latest) h.set("x-can2cup-latest", latest); h.set("x-can2cup-min", this.minClient()); };
|
|
902
|
+
try { set(c.res.headers); } catch { const r = new Response(c.res.body, c.res); set(r.headers); c.res = r; }
|
|
903
|
+
return c.res;
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// Presence. `online` = the MCP process just started (or wants to be counted present); `heartbeat`
|
|
907
|
+
// = same, every 60 s while it runs; `offline` = it is shutting down (best effort from the process).
|
|
908
|
+
app.post("/p/online", async (c) => {
|
|
909
|
+
const pub = c.get("pub" as never) as string;
|
|
910
|
+
const b = await this.bindingByPub(pub);
|
|
911
|
+
// v0.9.0: a client from before the upgrade protocol never sees x-can2cup-latest. Its principal is the only
|
|
912
|
+
// one who can act — one LINE nudge a month, only for a bound agent.
|
|
913
|
+
if (b && (c.get("ver" as never) as string) === NO_VERSION) {
|
|
914
|
+
const last = await this.get<string>(`oldnag:${pub}`);
|
|
915
|
+
if (!last || Date.now() - Date.parse(last) > 30 * 86400_000) {
|
|
916
|
+
await this.put(`oldnag:${pub}`, new Date().toISOString());
|
|
917
|
+
const origin = new URL(c.req.url).origin;
|
|
918
|
+
await this.push(b.userId, "upgrade:old", `🆙 你的 agent(${b.name || short(pub)})跑的是舊版 can2cup,收不到升級通知。在那台電腦跑:\nnpm i -g ${origin}/dl/can2cup.tgz\n然後重開一次 Claude Code。之後它會自己知道有沒有新版。`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
// v0.9.10 B2: the client reports whether its mandate is widened / its commit gate is off, so LINE can label
|
|
922
|
+
// the 同意 button honestly. Stored per agent; a client from before this never sends it (→ "not reported").
|
|
923
|
+
await this.armAlarm(Date.now() + 5 * 60_000); // v0.9.12: make sure the daily idle sweep has an alarm to ride, even on a quiet relay
|
|
924
|
+
const ob = JSON.parse((c.get("body" as never) as string) || "{}") as { tier?: { widened?: unknown; unsigned_may_commit?: unknown } };
|
|
925
|
+
if (ob.tier && typeof ob.tier === "object") await this.put(`tier:${pub}`, { widened: ob.tier.widened === true, unsigned_may_commit: ob.tier.unsigned_may_commit === true, at: new Date().toISOString() });
|
|
926
|
+
const lastRead = await this.get<string>(`read:${pub}`);
|
|
927
|
+
const items = (await this.get<InboxItem[]>(`inbox:${pub}`)) ?? [];
|
|
928
|
+
const rooms = (await this.get<Record<string, RoomKnown>>(`rooms:${pub}`)) ?? {};
|
|
929
|
+
return c.json({ ok: true, bound: !!b, pendingInbox: items.filter((i) => !lastRead || i.at > lastRead).length, openRooms: Object.entries(rooms).filter(([, r]) => r.state === "open").map(([id, r]) => ({ id, name: r.name, lastSeq: r.lastSeq })) });
|
|
930
|
+
});
|
|
931
|
+
app.post("/p/heartbeat", async (c) => c.json({ ok: true }));
|
|
932
|
+
app.post("/p/offline", async (c) => {
|
|
933
|
+
const pub = c.get("pub" as never) as string;
|
|
934
|
+
const now = new Date().toISOString();
|
|
935
|
+
await this.put(`offline:${pub}`, now);
|
|
936
|
+
// Mark it away immediately (so /bridge/inbox and room pushes warn straight away), but hold the
|
|
937
|
+
// phone buzz: one closed Claude Code window is not the agent leaving when other sessions of the
|
|
938
|
+
// same key are still heartbeating. sweepOffline() announces it if it is still gone at `due`.
|
|
939
|
+
const due = Date.now() + this.graceMs();
|
|
940
|
+
await this.put(`offpend:${pub}`, { at: now, due } as OffPend);
|
|
941
|
+
await this.armAlarm(due);
|
|
942
|
+
return c.json({ ok: true, announceInSec: Math.round(this.graceMs() / 1000) });
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
app.post("/p/link", async (c) => {
|
|
946
|
+
const pub = c.get("pub" as never) as string;
|
|
947
|
+
const b = JSON.parse((c.get("body" as never) as string) || "{}") as { name?: string };
|
|
948
|
+
const code = `${randomHex(2).toUpperCase()}-${randomHex(2).toUpperCase()}`;
|
|
949
|
+
await this.put(`code:${code}`, { pub, name: b.name ?? "", at: Date.now() });
|
|
950
|
+
const existing = await this.bindingByPub(pub);
|
|
951
|
+
return c.json({ code, expiresInSec: CODE_TTL_MS / 1000, alreadyBound: !!existing, boundTo: existing?.userId ? short(existing.userId) : undefined });
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
// Reverse link (v0.4.3): the human typed "/link" to the bot first; the bot got a code bound to their
|
|
955
|
+
// userId; the agent claims it. Same binding, opposite direction — the human only ever copies what
|
|
956
|
+
// the bot hands them.
|
|
957
|
+
app.post("/p/claim", async (c) => {
|
|
958
|
+
const pub = c.get("pub" as never) as string;
|
|
959
|
+
const b = JSON.parse((c.get("body" as never) as string) || "{}") as { code?: string; name?: string };
|
|
960
|
+
const code = (b.code ?? "").trim().toUpperCase().replace(/\s+/g, "");
|
|
961
|
+
if (await this.codeBlocked("claim", pub)) return c.json({ error: BridgeDO.TOO_MANY_CODES }, 429);
|
|
962
|
+
const rec = code ? await this.get<{ userId: string; at: number; ttlMs?: number }>(`pcode:${code}`) : undefined;
|
|
963
|
+
if (!rec || Date.now() - rec.at > (rec.ttlMs ?? CODE_TTL_MS)) { await this.codeMiss("claim", pub); return c.json({ error: "unknown or expired code" }, 404); }
|
|
964
|
+
if (await this.get(`ban:user:${rec.userId}`)) { await this.put(`ban:pub:${pub}`, { at: new Date().toISOString(), why: "claimed by a banned LINE user" }); return c.json({ error: "banned" }, 403); } // review R9
|
|
965
|
+
await this.ctx.storage.delete(`pcode:${code}`);
|
|
966
|
+
const prevByUser = await this.bindingByUser(rec.userId);
|
|
967
|
+
if (prevByUser) await this.ctx.storage.delete(`pub:${prevByUser.pub}`);
|
|
968
|
+
const prevByPub = await this.bindingByPub(pub);
|
|
969
|
+
if (prevByPub) await this.ctx.storage.delete(`user:${prevByPub.userId}`);
|
|
970
|
+
const binding: Binding = { pub, name: b.name ?? "", userId: rec.userId, agentMode: false, boundAt: new Date().toISOString() };
|
|
971
|
+
await this.put(`user:${rec.userId}`, binding);
|
|
972
|
+
await this.put(`pub:${pub}`, binding);
|
|
973
|
+
await this.push(rec.userId, "link:claimed", `✅ 綁定完成:這個 LINE 現在是 agent「${b.name || short(pub)}」的遙控器 —— 也就是那台電腦上的 Claude Code,不分視窗。試試 /a 你好、/status。`);
|
|
974
|
+
return c.json({ ok: true, userId: short(rec.userId) });
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
// v0.9.12: the agent's side of /keep — how long this binding may sit idle before it lapses.
|
|
978
|
+
app.post("/p/keep", async (c) => {
|
|
979
|
+
const pub = c.get("pub" as never) as string;
|
|
980
|
+
const b = await this.bindingByPub(pub);
|
|
981
|
+
if (!b) return c.json({ error: "not bound" }, 404);
|
|
982
|
+
const body = JSON.parse((c.get("body" as never) as string) || "{}") as { days?: unknown; forever?: unknown };
|
|
983
|
+
const r = await this.setIdle(pub, body, "agent");
|
|
984
|
+
if ("error" in r) return c.json(r, 400);
|
|
985
|
+
return c.json({ ok: true, idle: await this.idleOf(pub, b) });
|
|
986
|
+
});
|
|
987
|
+
app.get("/p/state", async (c) => {
|
|
988
|
+
const pub = c.get("pub" as never) as string;
|
|
989
|
+
const b = await this.bindingByPub(pub);
|
|
990
|
+
return c.json({
|
|
991
|
+
bound: !!b, boundAt: b?.boundAt ?? null, idle: b ? await this.idleOf(pub, b) : null, paused: (await this.get<boolean>(`paused:${pub}`)) ?? false, agentMode: b?.agentMode ?? false,
|
|
992
|
+
inboxSeq: (await this.get<number>(`inboxSeq:${pub}`)) ?? 0,
|
|
993
|
+
principalPub: (await this.get<string>(`principal:${pub}`)) ?? null,
|
|
994
|
+
signedPause: (await this.get<SignedPrincipalMsg>(`spause:${pub}`)) ?? null,
|
|
995
|
+
});
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
// The agent pins its principal's pubkey here (read from the principal's own ~/.parley).
|
|
999
|
+
// Signed by the agent key, so only the agent — whose home IS the principal's dir — can set it.
|
|
1000
|
+
app.post("/p/principal", async (c) => {
|
|
1001
|
+
const pub = c.get("pub" as never) as string;
|
|
1002
|
+
const b = JSON.parse((c.get("body" as never) as string) || "{}") as { principalPub?: string };
|
|
1003
|
+
if (!b.principalPub || !/^[0-9a-f]{64}$/.test(b.principalPub)) return c.json({ error: "principalPub (hex ed25519) required" }, 400);
|
|
1004
|
+
const prev = await this.get<string>(`principal:${pub}`);
|
|
1005
|
+
await this.put(`principal:${pub}`, b.principalPub);
|
|
1006
|
+
if (prev && prev !== b.principalPub) await this.ctx.storage.delete(`spause:${pub}`); // a new principal key: old signed pause no longer applies
|
|
1007
|
+
return c.json({ ok: true, principalPub: b.principalPub, changed: prev !== b.principalPub });
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// v0.8.0: reading is not receiving. Every item handed out starts a lease; the agent acks by acting
|
|
1011
|
+
// (tell_principal) or by POST /p/ack. An item whose lease ran out with no ack comes back on the next
|
|
1012
|
+
// read (flagged `redelivered`) — the case where `can2cup watch` printed to a terminal nobody watched.
|
|
1013
|
+
app.get("/p/inbox", async (c) => {
|
|
1014
|
+
const pub = c.get("pub" as never) as string;
|
|
1015
|
+
const since = Number(c.req.query("since") ?? 0) || 0;
|
|
1016
|
+
const peek = c.req.query("peek") === "1"; // review R14: look without starting a lease
|
|
1017
|
+
const instance = c.req.query("instance") ?? ""; // review R5: who is claiming
|
|
1018
|
+
const all = (await this.get<InboxItem[]>(`inbox:${pub}`)) ?? [];
|
|
1019
|
+
const now = Date.now();
|
|
1020
|
+
const out: InboxItem[] = [];
|
|
1021
|
+
let touched = false;
|
|
1022
|
+
for (const i of all) {
|
|
1023
|
+
const leased = !i.ackedAt && !!i.deliveredAt && now - Date.parse(i.deliveredAt) <= this.leaseMs();
|
|
1024
|
+
const fresh = i.seq > since;
|
|
1025
|
+
const stale = !fresh && !i.ackedAt && !!i.deliveredAt && !leased;
|
|
1026
|
+
if (!fresh && !stale) continue;
|
|
1027
|
+
// review R5: a lease is a claim — another instance holding it inside the lease means "not yours yet".
|
|
1028
|
+
if (!peek && leased && instance && i.deliveredTo && i.deliveredTo !== instance) continue;
|
|
1029
|
+
if (peek) { out.push(i); continue; }
|
|
1030
|
+
if (stale) i.redelivered = true;
|
|
1031
|
+
i.deliveredAt = new Date(now).toISOString();
|
|
1032
|
+
if (instance) i.deliveredTo = instance;
|
|
1033
|
+
touched = true;
|
|
1034
|
+
out.push(i);
|
|
1035
|
+
}
|
|
1036
|
+
if (touched) { await this.put(`inbox:${pub}`, all); await this.armAlarm(now + this.leaseMs() + 1000); }
|
|
1037
|
+
const b = await this.bindingByPub(pub);
|
|
1038
|
+
return c.json({ messages: out, paused: (await this.get<boolean>(`paused:${pub}`)) ?? false, bound: !!b, lastSeq: (await this.get<number>(`inboxSeq:${pub}`)) ?? 0 });
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
// v0.8.1: an agent that could not fix its own install/run problem files a diagnostic report. Stored 30 days,
|
|
1042
|
+
// pushed to the operator's LINE (OPERATOR_LINE_USER_ID) so it can be fixed for everyone. No room content:
|
|
1043
|
+
// the client sends only `can2cup doctor` output, versions and error lines. Rate: 5 per agent per day.
|
|
1044
|
+
app.post("/p/report", async (c) => {
|
|
1045
|
+
const pub = c.get("pub" as never) as string;
|
|
1046
|
+
const bind0 = await this.bindingByPub(pub);
|
|
1047
|
+
if (!bind0) return c.json({ ok: false, reason: "only an agent linked to a LINE user can file a report — show your human the `can2cup doctor` output instead" }, 403); // review R4
|
|
1048
|
+
const b = JSON.parse((c.get("body" as never) as string) || "{}") as { note?: string; doctor?: string; version?: string; platform?: string; errors?: string[] };
|
|
1049
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
1050
|
+
const qk = `reports:${pub}:${day}`;
|
|
1051
|
+
const n = (await this.get<number>(qk)) ?? 0;
|
|
1052
|
+
if (n >= 5) return c.json({ ok: false, reason: "report limit reached for today (5)" }, 429);
|
|
1053
|
+
await this.put(qk, n + 1);
|
|
1054
|
+
const id = `${day.replace(/-/g, "").slice(2)}-${randomHex(2).toUpperCase()}`;
|
|
1055
|
+
const bind = await this.bindingByPub(pub);
|
|
1056
|
+
const rec = { id, at: new Date().toISOString(), pub, agent: bind?.name ?? short(pub), lineUser: bind?.userId ?? null, version: (b.version ?? "").slice(0, 40), platform: (b.platform ?? "").slice(0, 120), note: (b.note ?? "").slice(0, 2000), doctor: (b.doctor ?? "").slice(0, 8000), errors: (b.errors ?? []).slice(0, 20).map((e) => String(e).slice(0, 500)) };
|
|
1057
|
+
await this.put(`report:${id}`, rec);
|
|
1058
|
+
const op = this.env.OPERATOR_LINE_USER_ID;
|
|
1059
|
+
const rpk = `q:reportpush:${day}`;
|
|
1060
|
+
const rpn = (await this.get<number>(rpk)) ?? 0;
|
|
1061
|
+
if (op && rpn < REPORT_PUSHES_PER_DAY) await this.put(rpk, rpn + 1); // review R4: reports have their own small push budget
|
|
1062
|
+
if (op && rpn < REPORT_PUSHES_PER_DAY) await this.push(op, "report", `🛠 回報 ${id}\n${rec.agent}(${rec.version || "?"},${rec.platform || "?"})\n${rec.note || "(沒寫原因)"}\n${rec.errors[0] ? `最近錯誤:${rec.errors[0].slice(0, 200)}\n` : ""}看全文:/bridge/report/${id}`, undefined, undefined, undefined, "can2cup 回報");
|
|
1063
|
+
return c.json({ ok: true, id, operatorNotified: !!op && rpn < REPORT_PUSHES_PER_DAY });
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// v0.8.0: the agent (or the session that read the instruction) confirms it is handling everything up to seq.
|
|
1067
|
+
app.post("/p/ack", async (c) => {
|
|
1068
|
+
const pub = c.get("pub" as never) as string;
|
|
1069
|
+
const b = JSON.parse((c.get("body" as never) as string) || "{}") as { seq?: number };
|
|
1070
|
+
const n = await this.ackInbox(pub, Number(b.seq) || Number.MAX_SAFE_INTEGER);
|
|
1071
|
+
return c.json({ ok: true, acked: n });
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
// Invite-by-LINE (v0.4.2): the inviting agent registers a room invite and gets a short code + a LINE deep
|
|
1075
|
+
// link. The invitee scans/taps it on their PHONE → the bot chat opens with "/join CODE" prefilled → the
|
|
1076
|
+
// bridge drops the invite into THEIR agent's inbox → their agent auto-joins. Nothing is pasted to a desktop.
|
|
1077
|
+
app.post("/p/invite", async (c) => {
|
|
1078
|
+
const pub = c.get("pub" as never) as string;
|
|
1079
|
+
const b = JSON.parse((c.get("body" as never) as string) || "{}") as { room?: string; invite?: string; name?: string; fromName?: string };
|
|
1080
|
+
if (!b.room || !b.invite || !/\/j\/[0-9a-f]{12}/.test(b.invite)) return c.json({ error: "room and invite (link) required" }, 400);
|
|
1081
|
+
const code = `${randomHex(2).toUpperCase()}-${randomHex(2).toUpperCase()}`;
|
|
1082
|
+
await this.put(`inv:${code}`, { invite: b.invite, room: b.room, name: b.name ?? "", fromPub: pub, fromName: b.fromName ?? short(pub), at: Date.now() } as StoredInvite);
|
|
1083
|
+
const oa = this.env.LINE_OA_ID;
|
|
1084
|
+
const url = oa ? lineDeepLink(oa, `/join ${code}`) : undefined;
|
|
1085
|
+
return c.json({ code, url, expiresInSec: INVITE_TTL_MS / 1000 });
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
// v0.5.1: the agent answers a /room request (see /bridge/room-request): it created the room on
|
|
1089
|
+
// its own machine — the relay never holds a room-creating key for a local agent — and hands back
|
|
1090
|
+
// the invite. The bridge turns that into a join code, posts it into the requesting group, and
|
|
1091
|
+
// mirrors the room there, so the humans watching the group see the room they asked for.
|
|
1092
|
+
// v0.8.2: opening a room no longer needs the operator's relay key. Any agent whose principal linked it on
|
|
1093
|
+
// LINE may open ROOMS_PER_DAY rooms a day (same quota as the hosted layer). Before this, only the operator's
|
|
1094
|
+
// own machine could answer a /room request — everyone else's auto-create failed with "key not set".
|
|
1095
|
+
app.post("/p/rooms", async (c) => {
|
|
1096
|
+
const pub = c.get("pub" as never) as string;
|
|
1097
|
+
const bind = await this.bindingByPub(pub);
|
|
1098
|
+
if (!bind) return c.json({ error: "only an agent linked to a LINE user can open rooms here (can2cup_link / LINE /setup first)" }, 403);
|
|
1099
|
+
if (await this.get(`ban:pub:${pub}`)) return c.json({ error: "banned" }, 403);
|
|
1100
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
1101
|
+
const qk = `q:rooms:${pub}:${day}`;
|
|
1102
|
+
const used = (await this.get<number>(qk)) ?? 0;
|
|
1103
|
+
const lim = Math.max(1, Number(this.env.ROOMS_PER_DAY ?? 10) || 10);
|
|
1104
|
+
if (used >= lim) return c.json({ error: `daily room quota reached (${lim}/day)` }, 429);
|
|
1105
|
+
await this.put(qk, used + 1); // review R12: reserve before the DO round-trip, so concurrent calls cannot all see 0
|
|
1106
|
+
const body = JSON.parse((c.get("body" as never) as string) || "{}") as { name?: string; policy?: Record<string, number>; e2e?: boolean };
|
|
1107
|
+
const id = randomHex(6);
|
|
1108
|
+
const res = await this.env.ROOMS.get(this.env.ROOMS.idFromName(id)).fetch(new Request(`https://do/rooms/${id}`, {
|
|
1109
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
1110
|
+
body: JSON.stringify({ name: (body.name ?? "").slice(0, 80), policy: body.policy ?? {}, creator: { pubkey: pub, name: bind.name }, ...(body.e2e ? { e2e: true } : {}) }),
|
|
1111
|
+
}));
|
|
1112
|
+
if (!res.ok) await this.put(qk, used); // give the slot back on failure
|
|
1113
|
+
return new Response(await res.text(), { status: res.status, headers: { "content-type": "application/json" } });
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
app.post("/p/room-created", async (c) => {
|
|
1117
|
+
const pub = c.get("pub" as never) as string;
|
|
1118
|
+
const b = JSON.parse((c.get("body" as never) as string) || "{}") as { room?: string; name?: string; invite?: string; group?: string };
|
|
1119
|
+
if (!b.room || !/^[0-9a-f]{12}$/.test(b.room) || !b.invite || !b.group) return c.json({ error: "room, invite and group required" }, 400);
|
|
1120
|
+
// review R7: an E2E invite fragment is "<secret>.<key>" — validate the secret part only.
|
|
1121
|
+
const invRoom = /\/j\/([0-9a-f]{12})/.exec(b.invite)?.[1];
|
|
1122
|
+
if (!invRoom || !/^[0-9a-f]{16,}$/.test((b.invite.split("#")[1] ?? "").split(".")[0])) return c.json({ error: "invite must be the full link including its #secret" }, 400);
|
|
1123
|
+
// review R1: the invite must be for THIS room, and the caller must actually be in it.
|
|
1124
|
+
if (invRoom !== b.room) return c.json({ error: "that invite is for a different room" }, 400);
|
|
1125
|
+
if (!(await this.inRoom(pub, b.room))) return c.json({ error: "you are not in that room" }, 403);
|
|
1126
|
+
let pend = await this.get<{ name?: string; by?: string; at: number }>(`roomreq:${pub}:${b.group}`);
|
|
1127
|
+
if (!pend || Date.now() - pend.at > ROOMREQ_TTL_MS) {
|
|
1128
|
+
// v0.8.2: no /room request pending — still fine if the principal has spoken from that group before
|
|
1129
|
+
// (`can2cup wire <room> <group>`: a room opened by hand gets attached to the group after the fact).
|
|
1130
|
+
const known = (await this.get<KnownGroup[]>(`groups:${pub}`)) ?? [];
|
|
1131
|
+
if (!known.some((g) => g.id === b.group)) return c.json({ error: "no pending /room request from that group, and your principal has never spoken from it (they can type /status → 接上這個群, or /a anything, there first)" }, 404);
|
|
1132
|
+
pend = { at: Date.now() };
|
|
1133
|
+
}
|
|
1134
|
+
const bind = await this.bindingByPub(pub);
|
|
1135
|
+
// v0.9.9 T4, defence in depth: two /room in the same second can race the request-time check.
|
|
1136
|
+
const held = await this.wiredByOther(b.group, bind?.userId);
|
|
1137
|
+
if (held) return c.json({ error: "that group is connected by someone else; ask them to /unmirror first", by: held.by }, 403);
|
|
1138
|
+
await this.ctx.storage.delete(`roomreq:${pub}:${b.group}`);
|
|
1139
|
+
const name = b.name || pend.name || "";
|
|
1140
|
+
const code = `${randomHex(2).toUpperCase()}-${randomHex(2).toUpperCase()}`;
|
|
1141
|
+
await this.put(`inv:${code}`, { invite: b.invite, room: b.room, name, fromPub: pub, fromName: bind?.name || short(pub), at: Date.now() } as StoredInvite);
|
|
1142
|
+
// v0.7.2: a /room-created room IS that group — mirror everything, not just decision points.
|
|
1143
|
+
await this.setMirror(b.group, { room: b.room, all: true, by: bind?.userId ?? "", at: new Date().toISOString() });
|
|
1144
|
+
// v0.9.2: /status is built from the rooms this bridge knows, and until now it only learned of a
|
|
1145
|
+
// room when its first message came through — so a group the principal had just wired was missing
|
|
1146
|
+
// from their own status card. Register it at wiring time; the first event fills in the rest.
|
|
1147
|
+
if (!(await this.get<RoomKnown>(`room:${b.room}`))) {
|
|
1148
|
+
const known: RoomKnown = { name, state: "open", lastSeq: 0, participants: { [pub]: bind?.name || short(pub) } };
|
|
1149
|
+
await this.put(`room:${b.room}`, known);
|
|
1150
|
+
const mine = (await this.get<Record<string, RoomKnown>>(`rooms:${pub}`)) ?? {};
|
|
1151
|
+
mine[b.room] = known;
|
|
1152
|
+
await this.put(`rooms:${pub}`, mine);
|
|
1153
|
+
}
|
|
1154
|
+
const oa = this.env.LINE_OA_ID;
|
|
1155
|
+
const url = oa ? lineDeepLink(oa, `/join ${code}`) : undefined;
|
|
1156
|
+
const who = pend.by || bind?.name || short(pub);
|
|
1157
|
+
await this.push(b.group, "room:created",
|
|
1158
|
+
`🔌 這個群接上了(${who} 的 agent)${name ? `:${name}` : ""}\n` +
|
|
1159
|
+
(url
|
|
1160
|
+
? `群裡其他人要讓自己的 agent 也進來 → 點連結、按送出:\n${url}\n(或對我打 /join ${code})`
|
|
1161
|
+
: `群裡其他人要讓自己的 agent 也進來:對我打 /join ${code}`) +
|
|
1162
|
+
`\nagent 在這裡說的每一句都會貼進來;打 /a 就是對自己的 agent 說話。/status 看誰接上了。`);
|
|
1163
|
+
return c.json({ ok: true, code, url, expiresInSec: INVITE_TTL_MS / 1000 });
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
// v0.4.5: which LINE groups can this agent address? (every group the principal has /a'd from)
|
|
1167
|
+
// v0.9.5: the way out, signed by the agent itself. `can2cup unbind` / `can2cup erase`.
|
|
1168
|
+
app.post("/p/erase", async (c) => {
|
|
1169
|
+
const pub = c.get("pub" as never) as string;
|
|
1170
|
+
const b = JSON.parse((c.get("body" as never) as string) || "{}") as { scope?: string };
|
|
1171
|
+
const scope = b.scope === "all" ? "all" : "binding";
|
|
1172
|
+
const bind = await this.bindingByPub(pub);
|
|
1173
|
+
const deleted = await this.erase(pub, scope);
|
|
1174
|
+
if (bind) {
|
|
1175
|
+
await this.push(bind.userId, "erase",
|
|
1176
|
+
scope === "all"
|
|
1177
|
+
? `👋 你電腦上的 agent(${bind.name || short(pub)})已經要求把資料從 can2cup 上刪掉,這個 LINE 帳號跟它的綁定也解除了。\n剩下的:別人房間裡已經收到的訊息在對方手上,我們刪不掉。`
|
|
1178
|
+
: `🔓 你電腦上的 agent(${bind.name || short(pub)})解除了跟這個 LINE 帳號的綁定。收件匣跟群組設定都刪了。要重新接上就再打 /setup。`);
|
|
1179
|
+
}
|
|
1180
|
+
return c.json({ ok: true, scope, wasBound: !!bind, deleted });
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
app.get("/p/groups", async (c) => {
|
|
1184
|
+
const pub = c.get("pub" as never) as string;
|
|
1185
|
+
return c.json({ groups: (await this.get<KnownGroup[]>(`groups:${pub}`)) ?? [], lastGroup: (await this.get<string>(`lastGroup:${pub}`)) ?? null });
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
// v0.4.5: ephemeral image hosting — LINE image messages need a public https URL; this stores one
|
|
1189
|
+
// for `ttl` seconds (default 1 h) and serves it at /f/:id. Auto-purged by alarm(); no third-party host.
|
|
1190
|
+
app.post("/p/image", async (c) => {
|
|
1191
|
+
const pub = c.get("pub" as never) as string;
|
|
1192
|
+
const b = JSON.parse((c.get("body" as never) as string) || "{}") as { data?: string; mime?: string; ttl?: number };
|
|
1193
|
+
const mime = b.mime === "image/jpeg" ? "image/jpeg" : b.mime === "image/png" ? "image/png" : undefined;
|
|
1194
|
+
if (!b.data || !mime) return c.json({ error: "data (base64) and mime (image/png|image/jpeg) required" }, 400);
|
|
1195
|
+
// Only an agent whose principal linked LINE gets to host bytes here: an anonymous key must
|
|
1196
|
+
// not turn this into a free image host, and the binding is what a ban can bite on.
|
|
1197
|
+
if (!(await this.bindingByPub(pub))) return c.json({ error: "image hosting requires a LINE-linked agent (can2cup_link first)" }, 403);
|
|
1198
|
+
if (!/^[A-Za-z0-9+/=]+$/.test(b.data)) return c.json({ error: "data must be base64" }, 400);
|
|
1199
|
+
if (b.data.length > 4_200_000) return c.json({ error: "image too large (max ~3 MB)" }, 413);
|
|
1200
|
+
const imgDay = new Date().toISOString().slice(0, 10);
|
|
1201
|
+
const imgQ = `q:img:${pub}:${imgDay}`;
|
|
1202
|
+
const imgUsed = (await this.get<number>(imgQ)) ?? 0;
|
|
1203
|
+
const imgLim = Math.max(1, Number(this.env.IMG_BYTES_PER_DAY ?? 5_000_000) || 5_000_000);
|
|
1204
|
+
if (imgUsed + b.data.length > imgLim) return c.json({ error: `daily image quota reached (${imgLim} base64 bytes/day for this key)` }, 429);
|
|
1205
|
+
const ttl = Math.min(IMG_TTL_MAX, Math.max(IMG_TTL_MIN, Number(b.ttl ?? IMG_TTL_DEFAULT) || IMG_TTL_DEFAULT));
|
|
1206
|
+
const id = randomHex(16);
|
|
1207
|
+
const expires = Date.now() + ttl * 1000;
|
|
1208
|
+
const chunks = Math.ceil(b.data.length / IMG_CHUNK);
|
|
1209
|
+
for (let i = 0; i < chunks; i++) await this.put(`imgc:${id}:${i}`, b.data.slice(i * IMG_CHUNK, (i + 1) * IMG_CHUNK));
|
|
1210
|
+
await this.put(`img:${id}`, { mime, expires, chunks, by: pub } as ImgMeta);
|
|
1211
|
+
await this.put(imgQ, imgUsed + b.data.length);
|
|
1212
|
+
const cur = await this.ctx.storage.getAlarm();
|
|
1213
|
+
if (cur == null || cur > expires + 1000) await this.ctx.storage.setAlarm(expires + 1000);
|
|
1214
|
+
return c.json({ ok: true, id, url: `${new URL(c.req.url).origin}/f/${id}`, expiresInSec: ttl });
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
// Public: serve an ephemeral image until it expires (lazy-purged here, alarm-purged otherwise).
|
|
1218
|
+
app.get("/f/:id", async (c) => {
|
|
1219
|
+
const id = c.req.param("id");
|
|
1220
|
+
if (!/^[0-9a-f]{32}$/.test(id)) return c.text("not found", 404);
|
|
1221
|
+
const meta = await this.get<ImgMeta>(`img:${id}`);
|
|
1222
|
+
if (!meta || Date.now() >= meta.expires) { if (meta) await this.purgeImage(id, meta.chunks); return c.text("gone", 404); }
|
|
1223
|
+
let b64 = "";
|
|
1224
|
+
for (let i = 0; i < meta.chunks; i++) b64 += (await this.get<string>(`imgc:${id}:${i}`)) ?? "";
|
|
1225
|
+
const bin = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0));
|
|
1226
|
+
return new Response(bin, { headers: { "content-type": meta.mime, "cache-control": "no-store" } });
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
// The agent's own client reports what the principal must know: blocked sends, escalations.
|
|
1230
|
+
app.post("/p/notify", async (c) => {
|
|
1231
|
+
const pub = c.get("pub" as never) as string;
|
|
1232
|
+
const b = await this.bindingByPub(pub);
|
|
1233
|
+
if (!b) return c.json({ ok: false, reason: "not bound" });
|
|
1234
|
+
const n0 = JSON.parse((c.get("body" as never) as string) || "{}") as { handled?: number };
|
|
1235
|
+
// review R16: answering acks what the agent has actually seen (the client sends its cursor), not everything.
|
|
1236
|
+
if (n0.handled != null) await this.ackInbox(pub, Number(n0.handled) || 0);
|
|
1237
|
+
const n = JSON.parse((c.get("body" as never) as string) || "{}") as { kind?: string; room?: string; seq?: number; text?: string; where?: string; image?: string };
|
|
1238
|
+
const roomName = n.room ? ((await this.get<RoomKnown>(`room:${n.room}`))?.name || n.room) : "";
|
|
1239
|
+
// `info` replies go back to where the principal last spoke from (their LINE group, if that is
|
|
1240
|
+
// where the /a came from) unless the agent says dm/group explicitly; blocked/escalate are
|
|
1241
|
+
// private and always go to the 1:1. v0.4.5: "group:<alias|id-prefix|name>" addresses ANY group
|
|
1242
|
+
// the principal has ever /a'd from (see /p/groups) — lastGroup stops being a single global slot.
|
|
1243
|
+
const lastGroup = await this.get<string>(`lastGroup:${pub}`);
|
|
1244
|
+
const where = n.where ?? "auto";
|
|
1245
|
+
let targetGroup: string | undefined;
|
|
1246
|
+
if (n.kind === "info") {
|
|
1247
|
+
if (where === "group" || where === "auto") targetGroup = lastGroup ?? undefined;
|
|
1248
|
+
else if (where.startsWith("group:")) {
|
|
1249
|
+
const q = where.slice(6).trim().toLowerCase();
|
|
1250
|
+
const list = (await this.get<KnownGroup[]>(`groups:${pub}`)) ?? [];
|
|
1251
|
+
const g = list.find((x) => x.alias === q) ?? list.find((x) => x.id.toLowerCase().startsWith(q)) ?? list.find((x) => !!x.name && x.name.toLowerCase().includes(q));
|
|
1252
|
+
if (!g) return c.json({ ok: false, reason: `unknown group "${q}" — known: ${list.map((x) => `${x.alias}${x.name ? `=${x.name}` : ""}`).join(", ") || "none (your principal has not /a'd from a group yet)"}` });
|
|
1253
|
+
targetGroup = g.id;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
const toGroup = !!targetGroup;
|
|
1257
|
+
if (n.kind === "info" && where === "group" && !lastGroup) return c.json({ ok: false, reason: "no group: your principal has not sent /a from a group yet" });
|
|
1258
|
+
// LINE needs a public https URL for external images. Our own /f/ store is exempt from the
|
|
1259
|
+
// scheme check: in production its origin is https anyway, and under `wrangler dev` with a
|
|
1260
|
+
// custom-domain route the rewritten origin is http://<domain> — which used to make the relay
|
|
1261
|
+
// reject the very URL its own /p/image had just handed out.
|
|
1262
|
+
const own = new URL(c.req.url).origin;
|
|
1263
|
+
if (n.image && !/^https:\/\//.test(n.image) && !n.image.startsWith(`${own}/f/`) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(n.image)) return c.json({ ok: false, reason: "image must be an https URL (use /p/image to host one)" });
|
|
1264
|
+
// v0.7.4: the bubble's author is the agent itself (LINE `sender`), so the header only carries what the name can't.
|
|
1265
|
+
const head = n.kind === "blocked" ? `⛔ 被 mandate 擋下` : n.kind === "escalate" ? `🙋 需要你決定` : "";
|
|
1266
|
+
const ref = n.seq != null ? `:${n.seq}` : "";
|
|
1267
|
+
const place = roomName ? `(${roomName})` : "";
|
|
1268
|
+
const headLine = head || place ? `${head}${place}\n` : "";
|
|
1269
|
+
await this.push(toGroup ? targetGroup! : b.userId, `notify:${n.kind}${toGroup ? ":group" : ""}`, `${headLine}${(n.text ?? "").slice(0, 4000)}`,
|
|
1270
|
+
n.kind === "escalate" && n.room ? [{ label: "同意", data: `parley:ok:${n.room}${ref}` }, { label: "拒絕", data: `parley:no:${n.room}${ref}` }, { label: "回話", data: "parley:fill", fill: "/a " }] : undefined,
|
|
1271
|
+
n.room, n.kind === "info" ? n.image : undefined, b.name || short(pub));
|
|
1272
|
+
return c.json({ ok: true, to: toGroup ? "group" : "dm" });
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// ---- bot (bridge key) -------------------------------------------------
|
|
1276
|
+
app.use("/bridge/*", async (c, next) => {
|
|
1277
|
+
const key = c.req.header("x-parley-bridge-key") ?? "";
|
|
1278
|
+
if (!this.env.BRIDGE_KEY || key !== this.env.BRIDGE_KEY) return c.json({ error: "bad bridge key" }, 401);
|
|
1279
|
+
await next();
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
app.post("/bridge/link", async (c) => {
|
|
1283
|
+
const b = (await c.req.json().catch(() => ({}))) as { code?: string; userId?: string; displayName?: string };
|
|
1284
|
+
const code = (b.code ?? "").trim().toUpperCase().replace(/\s+/g, "");
|
|
1285
|
+
if (!code || !b.userId) return c.json({ error: "code and userId required" }, 400);
|
|
1286
|
+
if (await this.codeBlocked("link", b.userId)) return c.json({ error: BridgeDO.TOO_MANY_CODES }, 429);
|
|
1287
|
+
const rec = await this.get<{ pub: string; name: string; at: number }>(`code:${code}`);
|
|
1288
|
+
if (!rec || Date.now() - rec.at > CODE_TTL_MS) { await this.codeMiss("link", b.userId); return c.json({ error: "unknown or expired code" }, 404); }
|
|
1289
|
+
if ((await this.get(`ban:user:${b.userId}`)) || (await this.get(`ban:pub:${rec.pub}`))) return c.json({ error: "banned" }, 403);
|
|
1290
|
+
await this.ctx.storage.delete(`code:${code}`);
|
|
1291
|
+
// one user ↔ one agent; re-linking replaces both directions
|
|
1292
|
+
const prevByUser = await this.bindingByUser(b.userId);
|
|
1293
|
+
if (prevByUser) await this.ctx.storage.delete(`pub:${prevByUser.pub}`);
|
|
1294
|
+
const prevByPub = await this.bindingByPub(rec.pub);
|
|
1295
|
+
if (prevByPub) await this.ctx.storage.delete(`user:${prevByPub.userId}`);
|
|
1296
|
+
const binding: Binding = { pub: rec.pub, name: rec.name, userId: b.userId, agentMode: false, boundAt: new Date().toISOString() };
|
|
1297
|
+
await this.put(`user:${b.userId}`, binding);
|
|
1298
|
+
await this.put(`pub:${rec.pub}`, binding);
|
|
1299
|
+
return c.json({ ok: true, name: rec.name, pub: rec.pub, displayName: b.displayName ?? "" });
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
// Bot: "/link" with no code → a code bound to this LINE user, for the agent to claim.
|
|
1303
|
+
// ttlSec (optional, 60 s .. 2 h): the bot's /setup embeds the code in the install command
|
|
1304
|
+
// (`can2cup setup --link CODE`), and an npm install + setup can easily outlive the 10-minute
|
|
1305
|
+
// default — an expired code there means a confusing "unknown code" at the very last step.
|
|
1306
|
+
app.post("/bridge/link-code", async (c) => {
|
|
1307
|
+
const b = (await c.req.json().catch(() => ({}))) as { userId?: string; ttlSec?: number };
|
|
1308
|
+
if (!b.userId) return c.json({ error: "userId required" }, 400);
|
|
1309
|
+
if (await this.get(`ban:user:${b.userId}`)) return c.json({ error: "banned" }, 403); // review R9
|
|
1310
|
+
const askedMs = Math.round(Number(b.ttlSec) || 0) * 1000;
|
|
1311
|
+
const ttlMs = askedMs ? Math.min(2 * 3600_000, Math.max(60_000, askedMs)) : CODE_TTL_MS;
|
|
1312
|
+
const code = `${randomHex(2).toUpperCase()}-${randomHex(2).toUpperCase()}`;
|
|
1313
|
+
await this.put(`pcode:${code}`, { userId: b.userId, at: Date.now(), ttlMs });
|
|
1314
|
+
return c.json({ code, expiresInSec: ttlMs / 1000 });
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
// v0.7.5: the principal's one screen. Humans never see "rooms": they see LINE groups their agent is
|
|
1318
|
+
// wired into, who else is in each, and whose agent is there. A room with no LINE group behind it
|
|
1319
|
+
// (opened from a desktop) is counted in `hidden`, never listed — that is the engine's business.
|
|
1320
|
+
app.get("/bridge/status/:userId", async (c) => {
|
|
1321
|
+
const b = await this.bindingByUser(c.req.param("userId"));
|
|
1322
|
+
if (!b) return c.json({ bound: false });
|
|
1323
|
+
const rooms = (await this.get<Record<string, RoomKnown>>(`rooms:${b.pub}`)) ?? {};
|
|
1324
|
+
const known = (await this.get<KnownGroup[]>(`groups:${b.pub}`)) ?? [];
|
|
1325
|
+
const presence = await this.presence(b.pub);
|
|
1326
|
+
const paused = (await this.get<boolean>(`paused:${b.pub}`)) ?? false;
|
|
1327
|
+
const members = async (r: RoomKnown) => {
|
|
1328
|
+
const out: Array<{ agent: string; userId: string | null; you: boolean; online: boolean; ver: string | null }> = [];
|
|
1329
|
+
for (const [pk, name] of Object.entries(r.participants)) {
|
|
1330
|
+
const mb = await this.bindingByPub(pk);
|
|
1331
|
+
const mp = pk === b.pub ? presence : await this.presence(pk);
|
|
1332
|
+
out.push({ agent: name, userId: mb?.userId ?? null, you: pk === b.pub, online: mp.online, ver: (await this.get<string>(`ver:${pk}`)) ?? null });
|
|
1333
|
+
}
|
|
1334
|
+
return out;
|
|
1335
|
+
};
|
|
1336
|
+
const ver = (await this.get<string>(`ver:${b.pub}`)) ?? null; // v0.9.0: null = a client from before the protocol
|
|
1337
|
+
const latest = await this.latestVersion();
|
|
1338
|
+
const groups: Array<Record<string, unknown>> = [];
|
|
1339
|
+
let hidden = 0;
|
|
1340
|
+
for (const [rid, r] of Object.entries(rooms)) {
|
|
1341
|
+
if (r.state !== "open") continue;
|
|
1342
|
+
const gids = (await this.get<string[]>(`mirrors:${rid}`)) ?? [];
|
|
1343
|
+
if (!gids.length) { hidden++; continue; }
|
|
1344
|
+
const ms = await members(r);
|
|
1345
|
+
for (const gid of gids) {
|
|
1346
|
+
const m = await this.get<Mirror>(`mirror:${gid}`);
|
|
1347
|
+
groups.push({ groupId: gid, groupName: known.find((g) => g.id === gid)?.name ?? null, room: rid, name: r.name, lastSeq: r.lastSeq, all: m?.all ?? false, wiredAt: m?.at ?? null, quiet: (await this.get<boolean>(`quiet:${gid}`)) ?? false, context: (await this.get<boolean>(`ctx:${gid}`)) ?? false, members: ms });
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
groups.sort((x, y) => Number(y.lastSeq) - Number(x.lastSeq));
|
|
1351
|
+
// ?group=<id>: the group the principal is typing in — wired or not, and whether THEIR agent is in it.
|
|
1352
|
+
const gid = c.req.query("group");
|
|
1353
|
+
let here: Record<string, unknown> | null = null;
|
|
1354
|
+
if (gid) {
|
|
1355
|
+
const m = await this.get<Mirror>(`mirror:${gid}`);
|
|
1356
|
+
const r = m ? await this.get<RoomKnown>(`room:${m.room}`) : null;
|
|
1357
|
+
const q = (await this.get<boolean>(`quiet:${gid}`)) ?? false;
|
|
1358
|
+
const cx = (await this.get<boolean>(`ctx:${gid}`)) ?? false;
|
|
1359
|
+
here = m && r && r.state === "open"
|
|
1360
|
+
? { wired: true, room: m.room, name: r.name, lastSeq: r.lastSeq, all: m.all, wiredAt: m.at ?? null, quiet: q, context: cx, youIn: !!r.participants[b.pub], members: await members(r) }
|
|
1361
|
+
: { wired: false, quiet: q, context: cx };
|
|
1362
|
+
}
|
|
1363
|
+
// v0.9.9: boundAt / wiredAt — the bot's /status shows "bound since"; null where an older record has no time.
|
|
1364
|
+
const tier = (await this.get<{ widened: boolean; unsigned_may_commit: boolean }>(`tier:${b.pub}`)) ?? null; // v0.9.10; null = client never reported
|
|
1365
|
+
const idle = await this.idleOf(b.pub, b); // v0.9.12
|
|
1366
|
+
return c.json({ bound: true, name: b.name, boundAt: b.boundAt ?? null, presence, paused, groups, hidden, here, ver, latest, min: this.minClient(), tier, idle });
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
app.get("/bridge/user/:userId", async (c) => {
|
|
1370
|
+
const b = await this.bindingByUser(c.req.param("userId"));
|
|
1371
|
+
if (!b) return c.json({ bound: false });
|
|
1372
|
+
const rooms = (await this.get<Record<string, RoomKnown>>(`rooms:${b.pub}`)) ?? {};
|
|
1373
|
+
return c.json({ bound: true, name: b.name, pub: b.pub, agentMode: b.agentMode, paused: (await this.get<boolean>(`paused:${b.pub}`)) ?? false, principalPub: (await this.get<string>(`principal:${b.pub}`)) ?? null, presence: await this.presence(b.pub), rooms, ver: (await this.get<string>(`ver:${b.pub}`)) ?? null, latest: await this.latestVersion() });
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
app.post("/bridge/user/:userId", async (c) => {
|
|
1377
|
+
const b = await this.bindingByUser(c.req.param("userId"));
|
|
1378
|
+
if (!b) return c.json({ error: "not bound" }, 404);
|
|
1379
|
+
const p = (await c.req.json().catch(() => ({}))) as { agentMode?: boolean; paused?: boolean };
|
|
1380
|
+
if (typeof p.agentMode === "boolean") { b.agentMode = p.agentMode; await this.put(`user:${b.userId}`, b); await this.put(`pub:${b.pub}`, b); }
|
|
1381
|
+
if (typeof p.paused === "boolean") await this.put(`paused:${b.pub}`, p.paused);
|
|
1382
|
+
return c.json({ ok: true, agentMode: b.agentMode, paused: (await this.get<boolean>(`paused:${b.pub}`)) ?? false });
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
app.post("/bridge/inbox", async (c) => {
|
|
1386
|
+
const p = (await c.req.json().catch(() => ({}))) as { userId?: string; text?: string; groupId?: string; groupName?: string; eventId?: string };
|
|
1387
|
+
if (!p.userId || !p.text) return c.json({ error: "userId and text required" }, 400);
|
|
1388
|
+
const b = await this.bindingByUser(p.userId);
|
|
1389
|
+
if (!b) return c.json({ error: "not bound" }, 404);
|
|
1390
|
+
// review R25: LINE redelivers webhooks after a bot restart; the bot's in-memory dedup is gone by then.
|
|
1391
|
+
if (p.eventId) { const prev = await this.get<{ seq: number; at: number }>(`evt:${p.eventId}`); if (prev) return c.json({ ok: true, seq: prev.seq, duplicate: true, presence: await this.presence(b.pub) }); }
|
|
1392
|
+
// Remember where the principal spoke from, so the agent's reply can go back there (v0.3.2).
|
|
1393
|
+
let g: KnownGroup | undefined;
|
|
1394
|
+
if (p.groupId) { await this.put(`lastGroup:${b.pub}`, p.groupId); g = await this.noteGroup(b.pub, p.groupId, p.groupName); }
|
|
1395
|
+
else await this.ctx.storage.delete(`lastGroup:${b.pub}`);
|
|
1396
|
+
// v0.9.10 B2: a tapped 同意/拒絕 button is its own tier — still unsigned, but the agent should know it was a button, not typed words.
|
|
1397
|
+
const via = /\(principal tapped the button\)$/.test(p.text) ? "line-button" : p.groupId ? "line-group" : "line";
|
|
1398
|
+
// v0.9.12: speaking to the agent from a wired group is use of that room — slide its life forward, or a
|
|
1399
|
+
// group in daily use could watch its room expire because only in-room messages counted.
|
|
1400
|
+
if (p.groupId) { const m = await this.get<Mirror>(`mirror:${p.groupId}`); if (m) await this.touchRoom(m.room); }
|
|
1401
|
+
const seq = await this.appendInbox(b.pub, { at: new Date().toISOString(), text: p.text.slice(0, 4000), via, ...(p.groupId ? { group: p.groupId, groupAlias: g!.alias, ...(g!.name ? { groupName: g!.name } : {}) } : {}) });
|
|
1402
|
+
if (p.eventId) await this.put(`evt:${p.eventId}`, { seq, at: Date.now() });
|
|
1403
|
+
const pres = await this.presence(b.pub);
|
|
1404
|
+
await this.markToldOffline(b.pub, pres); // the bot renders this as "your agent is away" → a "back" push is then worth sending
|
|
1405
|
+
// v0.9.3: a group set to quiet gets no "handed to your agent" receipt. Answered here so the bot
|
|
1406
|
+
// needs no second call, and stored on the relay so it survives the bot's restarts.
|
|
1407
|
+
const quiet = p.groupId ? ((await this.get<boolean>(`quiet:${p.groupId}`)) ?? false) : false;
|
|
1408
|
+
// v0.9.4: `context` says whether this group has opted into sending its recent chat along with an
|
|
1409
|
+
// instruction. The bot caches the answer and attaches the transcript from the NEXT /a onwards —
|
|
1410
|
+
// so a bot that has just restarted sends nothing until the relay has told it the group said yes.
|
|
1411
|
+
const context = p.groupId ? ((await this.get<boolean>(`ctx:${p.groupId}`)) ?? false) : false;
|
|
1412
|
+
return c.json({ ok: true, seq, presence: pres, quiet, context });
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
// v0.9.4: does this group send its recent chat with an instruction? Off unless the group turns it on,
|
|
1416
|
+
// because the words belong to everyone in the group, not just to whoever typed /a.
|
|
1417
|
+
// v0.9.7: turning this ON sends everyone else's words in the group to somebody's computer.
|
|
1418
|
+
// That is the wirer's call, not any passer-by's — announcing it afterwards is not consent.
|
|
1419
|
+
// Turning it OFF is protective, so it stays open to anyone in the group.
|
|
1420
|
+
app.post("/bridge/context", async (c) => {
|
|
1421
|
+
const p = (await c.req.json().catch(() => ({}))) as { groupId?: string; on?: boolean; userId?: string };
|
|
1422
|
+
if (!p.groupId) return c.json({ error: "groupId required" }, 400);
|
|
1423
|
+
if (p.on === false) { await this.ctx.storage.delete(`ctx:${p.groupId}`); return c.json({ ok: true, context: false }); }
|
|
1424
|
+
const m = await this.get<Mirror>(`mirror:${p.groupId}`);
|
|
1425
|
+
if (!m) return c.json({ error: "that group is not connected to any agent yet" }, 404);
|
|
1426
|
+
if (!p.userId || p.userId !== m.by) {
|
|
1427
|
+
const who = m.by ? await this.bindingByUser(m.by) : undefined;
|
|
1428
|
+
return c.json({ error: "only the person who connected this group can turn it on", by: who?.name ?? null }, 403);
|
|
1429
|
+
}
|
|
1430
|
+
await this.put(`ctx:${p.groupId}`, true);
|
|
1431
|
+
return c.json({ ok: true, context: true });
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
// v0.9.4: a question from someone in the group who has no agent of their own. It goes to the agent
|
|
1435
|
+
// that connected the group, marked as coming from a group member — data with no authority, never an
|
|
1436
|
+
// instruction. Only the principal's own words carry weight, and this is not them.
|
|
1437
|
+
app.post("/bridge/guest-ask", async (c) => {
|
|
1438
|
+
const p = (await c.req.json().catch(() => ({}))) as { groupId?: string; text?: string; displayName?: string; groupName?: string; eventId?: string };
|
|
1439
|
+
if (!p.groupId || !p.text) return c.json({ error: "groupId and text required" }, 400);
|
|
1440
|
+
const m = await this.get<Mirror>(`mirror:${p.groupId}`);
|
|
1441
|
+
if (!m) return c.json({ error: "that group is not connected to any agent yet" }, 404);
|
|
1442
|
+
const host = m.by ? await this.bindingByUser(m.by) : undefined;
|
|
1443
|
+
if (!host) return c.json({ error: "the agent that connected this group is no longer bound" }, 404);
|
|
1444
|
+
if (p.eventId) { const prev = await this.get<{ seq: number }>(`evt:${p.eventId}`); if (prev) return c.json({ ok: true, seq: prev.seq, duplicate: true, to: host.name }); }
|
|
1445
|
+
// A stranger in a group must not be able to fill someone's inbox: 10 an hour for the whole group.
|
|
1446
|
+
const hk = `q:guest:${p.groupId}:${new Date().toISOString().slice(0, 13)}`;
|
|
1447
|
+
const used = (await this.get<number>(hk)) ?? 0;
|
|
1448
|
+
if (used >= 10) return c.json({ error: "too many guest questions from this group this hour" }, 429);
|
|
1449
|
+
await this.put(hk, used + 1);
|
|
1450
|
+
const who = (p.displayName ?? "").slice(0, 40) || "群裡的某人";
|
|
1451
|
+
// v0.9.7: the provenance goes in the TEXT as well as the structured field. A client older than
|
|
1452
|
+
// 0.9.4 does not know `guest`, and an item with no signature sorts into its "claiming to come
|
|
1453
|
+
// from your principal" block — which labels a stranger's question in the one wrong direction.
|
|
1454
|
+
// The prefix means every client, at every version, sees who this actually came from.
|
|
1455
|
+
const seq = await this.appendInbox(host.pub, {
|
|
1456
|
+
at: new Date().toISOString(), via: "line-group-guest", group: p.groupId,
|
|
1457
|
+
guest: { name: who, group: p.groupId, ...(p.groupName ? { groupName: p.groupName } : {}) },
|
|
1458
|
+
text: `【群成員提問·不是你的老闆·無授權效力】${who}${p.groupName ? `(${p.groupName})` : ""}:${p.text.slice(0, 2000)}`,
|
|
1459
|
+
});
|
|
1460
|
+
if (p.eventId) await this.put(`evt:${p.eventId}`, { seq, at: Date.now() });
|
|
1461
|
+
return c.json({ ok: true, seq, to: host.name, presence: await this.presence(host.pub) });
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
// v0.9.3: "/quiet" in a LINE group — stop acknowledging every /a there. The instruction still
|
|
1465
|
+
// reaches the agent; only the bot's receipt goes away, so a family group is not narrated by a
|
|
1466
|
+
// robot every time someone speaks to their own agent.
|
|
1467
|
+
app.post("/bridge/quiet", async (c) => {
|
|
1468
|
+
const p = (await c.req.json().catch(() => ({}))) as { groupId?: string; on?: boolean };
|
|
1469
|
+
if (!p.groupId) return c.json({ error: "groupId required" }, 400);
|
|
1470
|
+
if (p.on === false) await this.ctx.storage.delete(`quiet:${p.groupId}`);
|
|
1471
|
+
else await this.put(`quiet:${p.groupId}`, true);
|
|
1472
|
+
return c.json({ ok: true, quiet: p.on !== false });
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
// /join <code | invite link> typed to the bot by a bound principal → their agent's inbox, flagged `invite`.
|
|
1476
|
+
app.post("/bridge/join", async (c) => {
|
|
1477
|
+
const p = (await c.req.json().catch(() => ({}))) as { userId?: string; text?: string; groupId?: string };
|
|
1478
|
+
if (!p.userId || !p.text) return c.json({ error: "userId and text required" }, 400);
|
|
1479
|
+
const b = await this.bindingByUser(p.userId);
|
|
1480
|
+
if (!b) return c.json({ error: "not bound" }, 404);
|
|
1481
|
+
const t = p.text.trim();
|
|
1482
|
+
let invite = /https?:\/\/[^\s"'<>]+\/j\/[0-9a-f]{12}[^\s"'<>]*/.exec(t)?.[0];
|
|
1483
|
+
let name = ""; let from = "";
|
|
1484
|
+
// v0.7.5: "group:<id>" — the principal tapped 接上 in a group that is already wired: reuse the
|
|
1485
|
+
// invite the room's opener posted there, so nobody has to find or retype a code.
|
|
1486
|
+
if (!invite && /^group:/.test(t)) {
|
|
1487
|
+
// review R10: only from inside that very group (the bot passes the group the tap came from).
|
|
1488
|
+
const gid = t.slice(6);
|
|
1489
|
+
if (!p.groupId || p.groupId !== gid) return c.json({ error: "join-by-group only works from inside that group" }, 403);
|
|
1490
|
+
const m = await this.get<Mirror>(`mirror:${gid}`);
|
|
1491
|
+
if (!m) return c.json({ error: "that group is not wired" }, 404);
|
|
1492
|
+
let best: StoredInvite | undefined;
|
|
1493
|
+
for (const [, rec] of await this.ctx.storage.list<StoredInvite>({ prefix: "inv:" })) {
|
|
1494
|
+
if (rec.room === m.room && Date.now() - rec.at <= INVITE_TTL_MS && (!best || rec.at > best.at)) best = rec;
|
|
1495
|
+
}
|
|
1496
|
+
if (!best) return c.json({ error: "unknown or expired invite code" }, 404);
|
|
1497
|
+
invite = best.invite; name = best.name; from = best.fromName;
|
|
1498
|
+
}
|
|
1499
|
+
if (!invite) {
|
|
1500
|
+
const code = t.toUpperCase().replace(/\s+/g, "").replace(/^\/?JOIN/, "");
|
|
1501
|
+
const norm = /^[A-Z0-9]{4}-?[A-Z0-9]{4}$/.test(code) ? (code.includes("-") ? code : `${code.slice(0, 4)}-${code.slice(4)}`) : "";
|
|
1502
|
+
if (await this.codeBlocked("join", p.userId)) return c.json({ error: BridgeDO.TOO_MANY_CODES }, 429);
|
|
1503
|
+
const rec = norm ? await this.get<StoredInvite>(`inv:${norm}`) : undefined;
|
|
1504
|
+
if (!rec || Date.now() - rec.at > INVITE_TTL_MS) { await this.codeMiss("join", p.userId); return c.json({ error: "unknown or expired invite code" }, 404); }
|
|
1505
|
+
invite = rec.invite; name = rec.name; from = rec.fromName;
|
|
1506
|
+
}
|
|
1507
|
+
if (!/^[0-9a-f]{16,}$/.test((invite.split("#")[1] ?? "").split(".")[0])) return c.json({ error: "that link is missing its secret (the part after #) — forward the whole link" }, 400);
|
|
1508
|
+
const room = /\/j\/([0-9a-f]{12})/.exec(invite)![1];
|
|
1509
|
+
if (p.groupId) await this.noteGroup(b.pub, p.groupId);
|
|
1510
|
+
const seq = await this.appendInbox(b.pub, {
|
|
1511
|
+
at: new Date().toISOString(), via: p.groupId ? "line-group" : "line", invite,
|
|
1512
|
+
text: `JOIN can2cup room ${room}${name ? ` "${name}"` : ""}${from ? ` (invited by ${from})` : ""} — your principal accepted this invite on LINE.`,
|
|
1513
|
+
});
|
|
1514
|
+
const presJ = await this.presence(b.pub);
|
|
1515
|
+
await this.markToldOffline(b.pub, presJ);
|
|
1516
|
+
return c.json({ ok: true, seq, room, name, from, presence: presJ });
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
// v0.5.1: "/room [名稱]" typed in a LINE group — ask the typer's agent to open a room for that
|
|
1520
|
+
// group. The bridge cannot create the room itself (a local agent's keys and mandate live on its
|
|
1521
|
+
// own machine), so this only queues the request; the agent answers on /p/room-created.
|
|
1522
|
+
app.post("/bridge/room-request", async (c) => {
|
|
1523
|
+
const p = (await c.req.json().catch(() => ({}))) as { userId?: string; groupId?: string; groupName?: string; name?: string; displayName?: string; eventId?: string };
|
|
1524
|
+
if (!p.userId || !p.groupId) return c.json({ error: "userId and groupId required" }, 400);
|
|
1525
|
+
const b = await this.bindingByUser(p.userId);
|
|
1526
|
+
if (!b) return c.json({ error: "not bound" }, 404);
|
|
1527
|
+
if (p.eventId) { const prev = await this.get<{ seq: number; at: number }>(`evt:${p.eventId}`); if (prev) return c.json({ ok: true, seq: prev.seq, duplicate: true, presence: await this.presence(b.pub) }); } // review R25
|
|
1528
|
+
// review R6: a /room request already pending for this group within the hour is the same request — do not queue a twin.
|
|
1529
|
+
const pendReq = await this.get<{ at: number }>(`roomreq:${b.pub}:${p.groupId}`);
|
|
1530
|
+
if (pendReq && Date.now() - pendReq.at < ROOMREQ_TTL_MS) return c.json({ ok: true, seq: 0, duplicate: true, presence: await this.presence(b.pub) });
|
|
1531
|
+
// v0.9.9 T4: checked BEFORE queueing, so the agent never opens a room it will not be allowed to wire.
|
|
1532
|
+
const held = await this.wiredByOther(p.groupId, p.userId);
|
|
1533
|
+
if (held) return c.json({ error: "this group is already connected by someone else; ask them to /unmirror first", by: held.by }, 403);
|
|
1534
|
+
const g = await this.noteGroup(b.pub, p.groupId, p.groupName);
|
|
1535
|
+
await this.put(`lastGroup:${b.pub}`, p.groupId);
|
|
1536
|
+
const name = (p.name ?? "").slice(0, 80);
|
|
1537
|
+
const by = (p.displayName ?? "").slice(0, 40);
|
|
1538
|
+
const seq = await this.appendInbox(b.pub, {
|
|
1539
|
+
at: new Date().toISOString(), via: "line-group", group: p.groupId, groupAlias: g.alias, ...(g.name ? { groupName: g.name } : {}),
|
|
1540
|
+
roomRequest: { ...(name ? { name } : {}), group: p.groupId },
|
|
1541
|
+
text: `OPEN A CAN2CUP ROOM${name ? ` "${name}"` : ""} for LINE group ${g.name ? `「${g.name}」` : g.alias} — your principal typed /room there. A current client handles this automatically and posts the invite back into the group; if you are reading this as plain text, create the room yourself, run can2cup invite <room> --line, and hand the code to the group.`,
|
|
1542
|
+
});
|
|
1543
|
+
await this.put(`roomreq:${b.pub}:${p.groupId}`, { name, by, at: Date.now() });
|
|
1544
|
+
if (p.eventId) await this.put(`evt:${p.eventId}`, { seq, at: Date.now() });
|
|
1545
|
+
const pres = await this.presence(b.pub);
|
|
1546
|
+
await this.markToldOffline(b.pub, pres);
|
|
1547
|
+
return c.json({ ok: true, seq, presence: pres });
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
app.get("/bridge/show/:userId/:room", async (c) => {
|
|
1551
|
+
const b = await this.bindingByUser(c.req.param("userId"));
|
|
1552
|
+
if (!b) return c.json({ error: "not bound" }, 404);
|
|
1553
|
+
const room = c.req.param("room");
|
|
1554
|
+
const known = await this.get<RoomKnown>(`room:${room}`);
|
|
1555
|
+
if (!known || !known.participants[b.pub]) return c.json({ error: "you are not in that room" }, 404);
|
|
1556
|
+
const n = Math.min(50, Number(c.req.query("n") ?? 15) || 15);
|
|
1557
|
+
const recent = ((await this.get<Envelope[]>(`recent:${room}`)) ?? []).slice(-n);
|
|
1558
|
+
const names = { ...known.participants, [b.pub]: `${known.participants[b.pub]}(你的)` };
|
|
1559
|
+
return c.json({ room, name: known.name, state: known.state, lastSeq: known.lastSeq, text: recent.map((e) => this.fmtEnvelope(e, names)).join("\n———\n") });
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
app.post("/bridge/mirror", async (c) => {
|
|
1563
|
+
const p = (await c.req.json().catch(() => ({}))) as { userId?: string; groupId?: string; room?: string; all?: boolean };
|
|
1564
|
+
if (!p.userId || !p.groupId) return c.json({ error: "userId and groupId required" }, 400);
|
|
1565
|
+
const b = await this.bindingByUser(p.userId);
|
|
1566
|
+
if (!b) return c.json({ error: "not bound" }, 404);
|
|
1567
|
+
const rooms = (await this.get<Record<string, RoomKnown>>(`rooms:${b.pub}`)) ?? {};
|
|
1568
|
+
// default: the newest open room this user is in
|
|
1569
|
+
const room = p.room ?? Object.entries(rooms).filter(([, r]) => r.state === "open").sort((x, y) => y[1].lastSeq - x[1].lastSeq)[0]?.[0];
|
|
1570
|
+
if (!room || !rooms[room] || !(await this.inRoom(b.pub, room))) return c.json({ error: "no such room for you" }, 404);
|
|
1571
|
+
const held = await this.wiredByOther(p.groupId, p.userId); // v0.9.9 T4
|
|
1572
|
+
if (held) return c.json({ error: "this group is already connected by someone else; ask them to /unmirror first", by: held.by }, 403);
|
|
1573
|
+
const m: Mirror = { room, all: !!p.all, by: p.userId, at: new Date().toISOString() };
|
|
1574
|
+
await this.setMirror(p.groupId, m);
|
|
1575
|
+
return c.json({ ok: true, room, name: rooms[room].name, all: m.all });
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
app.delete("/bridge/mirror/:groupId", async (c) => {
|
|
1579
|
+
const gid = c.req.param("groupId");
|
|
1580
|
+
const m = await this.get<Mirror>(`mirror:${gid}`);
|
|
1581
|
+
if (m) {
|
|
1582
|
+
await this.ctx.storage.delete(`mirror:${gid}`);
|
|
1583
|
+
const list = ((await this.get<string[]>(`mirrors:${m.room}`)) ?? []).filter((g) => g !== gid);
|
|
1584
|
+
await this.put(`mirrors:${m.room}`, list);
|
|
1585
|
+
if (!list.length) await this.keepAlive(m.room, false); // no group points at it any more
|
|
1586
|
+
}
|
|
1587
|
+
return c.json({ ok: true, was: m?.room });
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
// v0.9.5: the way out from the phone. The bot does the confirming; by the time it calls this,
|
|
1591
|
+
// the human has typed the word twice.
|
|
1592
|
+
app.post("/bridge/erase", async (c) => {
|
|
1593
|
+
const p = (await c.req.json().catch(() => ({}))) as { userId?: string; scope?: string };
|
|
1594
|
+
if (!p.userId) return c.json({ error: "userId required" }, 400);
|
|
1595
|
+
const b = await this.bindingByUser(p.userId);
|
|
1596
|
+
if (!b) return c.json({ error: "not bound" }, 404);
|
|
1597
|
+
const scope = p.scope === "all" ? "all" : "binding";
|
|
1598
|
+
const deleted = await this.erase(b.pub, scope);
|
|
1599
|
+
return c.json({ ok: true, scope, pub: b.pub, name: b.name, deleted });
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
app.get("/bridge/quota", async (c) => c.json(await this.pushCount()));
|
|
1603
|
+
// v0.9.13: the debug routes exist for the smoke suite. They answer only to the bridge key, but one of them
|
|
1604
|
+
// (`sweep {all:true}`) can expire every idle binding on the relay in a single call — that is not something a
|
|
1605
|
+
// production relay should expose to a leaked bot key. Off unless DEBUG_ROUTES=1 (set in .dev.vars, never in wrangler.toml).
|
|
1606
|
+
app.use("/bridge/debug/*", async (c, next) => { if (this.env.DEBUG_ROUTES !== "1") return c.json({ error: "not found" }, 404); await next(); });
|
|
1607
|
+
app.get("/bridge/debug/pushes", async (c) => c.json({ pushes: (await this.get<Pushed[]>("pushes")) ?? [] }));
|
|
1608
|
+
// v0.9.12 (smoke): run one idle sweep now, narrowed to one agent or one group so nothing else in the run is touched.
|
|
1609
|
+
app.post("/bridge/debug/sweep", async (c) => {
|
|
1610
|
+
const p = (await c.req.json().catch(() => ({}))) as { pub?: string; gid?: string; all?: boolean };
|
|
1611
|
+
if (!p.pub && !p.gid && p.all !== true) return c.json({ error: "pub or gid required (all: true sweeps everything)" }, 400);
|
|
1612
|
+
return c.json(await this.sweepIdle({ pub: p.pub || undefined, gid: p.gid || undefined }));
|
|
1613
|
+
});
|
|
1614
|
+
// v0.9.12: /keep from LINE — show, set 7..365, or forever.
|
|
1615
|
+
app.post("/bridge/keep", async (c) => {
|
|
1616
|
+
const p = (await c.req.json().catch(() => ({}))) as { userId?: string; days?: unknown; forever?: unknown };
|
|
1617
|
+
if (!p.userId) return c.json({ error: "userId required" }, 400);
|
|
1618
|
+
const b = await this.bindingByUser(p.userId);
|
|
1619
|
+
if (!b) return c.json({ error: "not bound" }, 404);
|
|
1620
|
+
const r = await this.setIdle(b.pub, p, "line");
|
|
1621
|
+
if ("error" in r) return c.json(r, 400);
|
|
1622
|
+
const st = await this.idleOf(b.pub, b);
|
|
1623
|
+
return c.json({ ok: true, name: b.name, ...st });
|
|
1624
|
+
});
|
|
1625
|
+
// v0.8.1 support view: the delivery ledger of one principal's inbox + when this DO's alarm is due.
|
|
1626
|
+
app.get("/bridge/debug/inbox/:userId", async (c) => { const b = await this.bindingByUser(c.req.param("userId")); if (!b) return c.json({ error: "not bound" }, 404); return c.json({ pub: b.pub, alarmAt: await this.ctx.storage.getAlarm(), leaseMs: this.leaseMs(), items: ((await this.get<InboxItem[]>(`inbox:${b.pub}`)) ?? []).map((i) => ({ seq: i.seq, at: i.at, deliveredAt: i.deliveredAt, ackedAt: i.ackedAt, remindedAt: i.remindedAt, text: i.text.slice(0, 40) })) }); });
|
|
1627
|
+
app.get("/bridge/report/:id", async (c) => { const r = await this.get<unknown>(`report:${c.req.param("id")}`); return r ? c.json(r) : c.json({ error: "no such report" }, 404); });
|
|
1628
|
+
app.get("/bridge/reports", async (c) => { const out: unknown[] = []; for (const [, v] of await this.ctx.storage.list<{ id: string; at: string; agent: string; note: string; version: string }>({ prefix: "report:" })) out.push({ id: v.id, at: v.at, agent: v.agent, version: v.version, note: v.note.slice(0, 120) }); return c.json({ reports: out.slice(-100) }); });
|
|
1629
|
+
|
|
1630
|
+
// ---- principal-signed (v0.3) -------------------------------------------
|
|
1631
|
+
// The body IS the authentication: a SignedPrincipalMsg addressed to one agent, signed by the
|
|
1632
|
+
// key that agent registered. No bridge key, no LINE, no operator in the path.
|
|
1633
|
+
const principalMsg = async (c: { req: { json: () => Promise<unknown> } }): Promise<SignedPrincipalMsg | Response> => {
|
|
1634
|
+
const m = (await c.req.json().catch(() => null)) as SignedPrincipalMsg | null;
|
|
1635
|
+
if (!m || typeof m !== "object" || !/^[0-9a-f]{64}$/.test(m.agent ?? "")) return Response.json({ error: "signed principal message required" }, { status: 400 });
|
|
1636
|
+
const expected = await this.get<string>(`principal:${m.agent}`);
|
|
1637
|
+
if (!expected) return Response.json({ error: "that agent has not registered a principal key" }, { status: 404 });
|
|
1638
|
+
const v = verifyPrincipal(m, expected, m.agent);
|
|
1639
|
+
if (!v.ok) return Response.json({ error: `principal signature: ${v.error}` }, { status: 401 });
|
|
1640
|
+
return m;
|
|
1641
|
+
};
|
|
1642
|
+
|
|
1643
|
+
app.post("/principal/say", async (c) => {
|
|
1644
|
+
const m = await principalMsg(c);
|
|
1645
|
+
if (m instanceof Response) return m;
|
|
1646
|
+
if (m.kind !== "say") return c.json({ error: "kind must be say" }, 400);
|
|
1647
|
+
const seq = await this.appendInbox(m.agent, { at: m.at, text: String(m.text ?? "").slice(0, 4000), signed: m, via: "principal-key" });
|
|
1648
|
+
return c.json({ ok: true, seq });
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
app.post("/principal/pause", async (c) => {
|
|
1652
|
+
const m = await principalMsg(c);
|
|
1653
|
+
if (m instanceof Response) return m;
|
|
1654
|
+
if (m.kind !== "pause") return c.json({ error: "kind must be pause" }, 400);
|
|
1655
|
+
const cur = await this.get<SignedPrincipalMsg>(`spause:${m.agent}`);
|
|
1656
|
+
if (cur && Date.parse(cur.at) >= Date.parse(m.at)) return c.json({ ok: false, error: "a newer signed pause statement already exists", current: cur.paused }, 409);
|
|
1657
|
+
await this.put(`spause:${m.agent}`, m);
|
|
1658
|
+
return c.json({ ok: true, paused: m.paused });
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
// ---- internal: RoomDO tells us about every stored envelope --------------
|
|
1662
|
+
app.post("/internal/event", async (c) => {
|
|
1663
|
+
const ev = (await c.req.json()) as RoomEvent;
|
|
1664
|
+
const names: Record<string, string> = {};
|
|
1665
|
+
for (const [pk, p] of Object.entries(ev.participants)) names[pk] = p.name || short(pk);
|
|
1666
|
+
const known: RoomKnown = { name: ev.name, state: ev.state, lastSeq: ev.envelope.seq, participants: names };
|
|
1667
|
+
await this.put(`room:${ev.room}`, known);
|
|
1668
|
+
const recent = (await this.get<Envelope[]>(`recent:${ev.room}`)) ?? [];
|
|
1669
|
+
recent.push(ev.envelope);
|
|
1670
|
+
await this.put(`recent:${ev.room}`, recent.slice(-50));
|
|
1671
|
+
for (const pk of Object.keys(names)) {
|
|
1672
|
+
const rooms = (await this.get<Record<string, RoomKnown>>(`rooms:${pk}`)) ?? {};
|
|
1673
|
+
rooms[ev.room] = known;
|
|
1674
|
+
await this.put(`rooms:${pk}`, rooms);
|
|
1675
|
+
}
|
|
1676
|
+
const e = ev.envelope;
|
|
1677
|
+
// 1:1 decision-point pushes to every bound participant except the sender
|
|
1678
|
+
if (DECISION_TYPES.has(e.type)) {
|
|
1679
|
+
for (const pk of Object.keys(names)) {
|
|
1680
|
+
if (pk === e.from) continue;
|
|
1681
|
+
const b = await this.bindingByPub(pk);
|
|
1682
|
+
if (!b) continue;
|
|
1683
|
+
const quick: Quick[] | undefined = e.type === "escalate" || e.type === "grant" || e.type === "proposal" || e.type === "question"
|
|
1684
|
+
? [{ label: "同意", data: `parley:ok:${ev.room}:${e.seq}` }, { label: "拒絕", data: `parley:no:${ev.room}:${e.seq}` }, { label: "回話", data: "parley:fill", fill: "/a " }, { label: "看全文", data: `parley:show:${ev.room}` }]
|
|
1685
|
+
: undefined;
|
|
1686
|
+
const pres = await this.presence(pk);
|
|
1687
|
+
await this.markToldOffline(pk, pres); // the warning below is the principal hearing it is away
|
|
1688
|
+
const note = this.offlineNote(pres);
|
|
1689
|
+
const bub = this.fmtBubble(e, names);
|
|
1690
|
+
// v0.9.10 B2: if this principal's agent is gated (mandate widened, gate on), the 同意 button below is
|
|
1691
|
+
// advice, not a commitment — say so on the bubble itself, where the button is.
|
|
1692
|
+
const tier = quick ? await this.get<{ widened: boolean; unsigned_may_commit: boolean }>(`tier:${pk}`) : undefined;
|
|
1693
|
+
const gated = tier?.widened && !tier.unsigned_may_commit ? "\n🔏 你的規則放寬過:同意鍵只是告訴 agent 你的意思,真正送出前要在電腦上 can2cup approve 簽核。" : "";
|
|
1694
|
+
await this.push(b.userId, `room:${e.type}`, `【${ev.name || ev.room}】\n${bub.text}${note ? `\n⚠️ ${note.replace(/^(|)$/g, "")}` : ""}${gated}`, quick, ev.room, undefined, bub.sender);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
// group mirrors
|
|
1698
|
+
const groups = (await this.get<string[]>(`mirrors:${ev.room}`)) ?? [];
|
|
1699
|
+
for (const gid of groups) {
|
|
1700
|
+
const m = await this.get<Mirror>(`mirror:${gid}`);
|
|
1701
|
+
if (!m || m.room !== ev.room) continue; // review R3: the group was re-pointed elsewhere
|
|
1702
|
+
if (e.type === "system" && !m.all) continue;
|
|
1703
|
+
if (!m.all && !DECISION_TYPES.has(e.type)) continue;
|
|
1704
|
+
const bub = this.fmtBubble(e, names);
|
|
1705
|
+
await this.push(gid, `mirror:${e.type}`, bub.text, undefined, ev.room, undefined, bub.sender);
|
|
1706
|
+
}
|
|
1707
|
+
return c.json({ ok: true });
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
}
|