@timqi/pier 0.0.5 → 0.0.7
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 +1 -0
- package/dist/agent/events.js +6 -1
- package/dist/agent/pi.js +28 -3
- package/dist/channels/chunk.js +34 -0
- package/dist/channels/control.js +6 -12
- package/dist/channels/dedup.js +45 -0
- package/dist/channels/lark-api.js +233 -0
- package/dist/channels/lark-outbound.js +101 -0
- package/dist/channels/lark-panel.js +95 -0
- package/dist/channels/lark-render.js +107 -0
- package/dist/channels/lark.js +501 -0
- package/dist/channels/lines.js +19 -0
- package/dist/channels/panel.js +4 -0
- package/dist/channels/receipts.js +15 -0
- package/dist/channels/routes.js +0 -1
- package/dist/channels/runtime.js +5 -1
- package/dist/channels/slack-api.js +4 -2
- package/dist/channels/slack-panel.js +3 -6
- package/dist/channels/slack-render.js +3 -23
- package/dist/channels/slack.js +39 -72
- package/dist/channels/telegram-api.js +4 -2
- package/dist/channels/telegram-panel.js +3 -3
- package/dist/channels/telegram.js +55 -51
- package/dist/channels/types.js +9 -0
- package/dist/core/identity.js +21 -0
- package/dist/core/inbox.js +67 -1
- package/dist/core/types.js +4 -0
- package/dist/db.js +6 -0
- package/dist/main.js +5 -0
- package/dist/web/auth.js +36 -9
- package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
- package/dist/web/public/assets/ghostty-web-BhZV0Vvv.js +13 -0
- package/dist/web/public/assets/{index-BAW9Nhaa.js → index-BbwoGR-O.js} +17 -17
- package/dist/web/public/assets/index-BlHvP59B.css +2 -0
- package/dist/web/public/index.html +16 -6
- package/dist/web/server.js +86 -19
- package/dist/web/session-state.js +59 -25
- package/dist/web/terminal.js +334 -0
- package/docs/deploy.md +5 -1
- package/package.json +10 -2
- package/dist/web/public/assets/index-CwBoxtXP.css +0 -2
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ versioned from `0.0.1` on — earlier databases are not migrated. Read
|
|
|
17
17
|
## Requirements
|
|
18
18
|
|
|
19
19
|
- Node 24 or newer (`node:sqlite` is used unflagged)
|
|
20
|
+
- Linux: Python 3, `make` and a C/C++ compiler for `node-pty`
|
|
20
21
|
- A provider account (Anthropic, OpenAI, …) — configure its API key or OAuth
|
|
21
22
|
login from Console → Settings → Providers after signing in
|
|
22
23
|
- A user-writable global npm prefix if `pier update` should update a service
|
package/dist/agent/events.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// purpose: no @earendil-works/pi-* imports, so it stays unit-testable without Pi
|
|
3
3
|
// and Pi types never leak past the seam. The golden-table test in
|
|
4
4
|
// events.test.ts is the mapping's spec; extend types.ts before adding events.
|
|
5
|
+
import { MAX_STEP_OUTPUT } from "../core/types.js";
|
|
5
6
|
export function textOf(content) {
|
|
6
7
|
if (typeof content === "string")
|
|
7
8
|
return content;
|
|
@@ -101,8 +102,12 @@ export function toChatTurns(messages) {
|
|
|
101
102
|
if (m.role === "toolResult") {
|
|
102
103
|
const step = pendingTools.get(m.toolCallId ?? "");
|
|
103
104
|
if (step) {
|
|
104
|
-
|
|
105
|
+
// Capped where the transcript is rebuilt, not where it is rendered: a
|
|
106
|
+
// long session's tool results are megabytes nobody ever sees.
|
|
107
|
+
const output = textOf(m.content);
|
|
108
|
+
step.output = output.length > MAX_STEP_OUTPUT ? output.slice(0, MAX_STEP_OUTPUT) + "…" : output;
|
|
105
109
|
step.isError = m.isError ?? false;
|
|
110
|
+
step.done = true;
|
|
106
111
|
pendingTools.delete(m.toolCallId ?? "");
|
|
107
112
|
}
|
|
108
113
|
continue;
|
package/dist/agent/pi.js
CHANGED
|
@@ -203,6 +203,11 @@ export class PiAgentFactory {
|
|
|
203
203
|
}
|
|
204
204
|
/** One runtime for the whole process; catalogs are global, not per session. */
|
|
205
205
|
catalog;
|
|
206
|
+
/** Where each listed session lives. `listAll` reads the head of every
|
|
207
|
+
* session file on disk (~250ms at 200 sessions, and it only grows), which
|
|
208
|
+
* `resume` paid on every cold open — web selection, an IM message, a task
|
|
209
|
+
* run. The sidebar's own listing keeps this warm; a miss still lists. */
|
|
210
|
+
located = new Map();
|
|
206
211
|
refreshQueue = Promise.resolve();
|
|
207
212
|
builtinProviderIds;
|
|
208
213
|
/** Structural fit: CredentialStore mirrors pi-ai's interface of the same
|
|
@@ -414,7 +419,7 @@ export class PiAgentFactory {
|
|
|
414
419
|
return this.open(opts.cwd, SessionManager.create(opts.cwd), opts);
|
|
415
420
|
}
|
|
416
421
|
async fork(sourceSessionId, opts) {
|
|
417
|
-
const infos = await
|
|
422
|
+
const infos = await this.listed();
|
|
418
423
|
const source = infos.find((session) => session.id === sourceSessionId);
|
|
419
424
|
if (!source)
|
|
420
425
|
throw new Error(`unknown session: ${sourceSessionId}`);
|
|
@@ -433,14 +438,34 @@ export class PiAgentFactory {
|
|
|
433
438
|
return this.open(opts.cwd, manager, opts);
|
|
434
439
|
}
|
|
435
440
|
async resume(sessionId) {
|
|
436
|
-
const
|
|
441
|
+
const known = this.located.get(sessionId);
|
|
442
|
+
if (known) {
|
|
443
|
+
try {
|
|
444
|
+
return await this.open(known.cwd, SessionManager.open(known.path));
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
// The file moved or went away under us: the cache was the only thing
|
|
448
|
+
// that claimed otherwise, so drop it and take the slow, true path.
|
|
449
|
+
log.warn(`cached path for session ${sessionId} did not open; re-listing`, err);
|
|
450
|
+
this.located.delete(sessionId);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const infos = await this.listed();
|
|
437
454
|
const info = infos.find((s) => s.id === sessionId);
|
|
438
455
|
if (!info)
|
|
439
456
|
throw new Error(`unknown session: ${sessionId}`);
|
|
440
457
|
return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
|
|
441
458
|
}
|
|
442
|
-
|
|
459
|
+
/** Every listing goes through here, so it also refreshes `located`. */
|
|
460
|
+
async listed() {
|
|
443
461
|
const infos = await SessionManager.listAll();
|
|
462
|
+
for (const s of infos) {
|
|
463
|
+
this.located.set(s.id, { path: s.path, cwd: s.cwd || process.cwd() });
|
|
464
|
+
}
|
|
465
|
+
return infos;
|
|
466
|
+
}
|
|
467
|
+
async list() {
|
|
468
|
+
const infos = await this.listed();
|
|
444
469
|
return infos.map((s) => ({
|
|
445
470
|
id: s.id,
|
|
446
471
|
cwd: s.cwd,
|
package/dist/channels/chunk.js
CHANGED
|
@@ -26,3 +26,37 @@ export function chunkText(text, max) {
|
|
|
26
26
|
parts.push(rest);
|
|
27
27
|
return parts;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Close a fence a chunk left open, and reopen it on the next one. Telegram can
|
|
31
|
+
* be cut mid-`<pre>` and shrug — its parser closes the tag itself — but Slack
|
|
32
|
+
* and Lark both swallow the rest of a message after an unterminated ```, and
|
|
33
|
+
* the next chunk starts *outside* a fence, so the tail of a long code block
|
|
34
|
+
* renders as prose.
|
|
35
|
+
*
|
|
36
|
+
* Fences are tracked by line-leading runs with their *length*, per CommonMark:
|
|
37
|
+
* a ```` fence (used to quote a ``` block) only closes on a run at least as
|
|
38
|
+
* long, so counting bare ``` occurrences would see the inner block close the
|
|
39
|
+
* outer one and mangle both halves of the cut.
|
|
40
|
+
*/
|
|
41
|
+
export function balanceFences(parts) {
|
|
42
|
+
/** Backticks of the fence currently open across the boundary; 0 = closed. */
|
|
43
|
+
let open = 0;
|
|
44
|
+
const fence = (n) => "`".repeat(n);
|
|
45
|
+
return parts.map((part) => {
|
|
46
|
+
const reopened = open ? `${fence(open)}\n${part}` : part;
|
|
47
|
+
// The prepended fence counts too — the scan restarts from "closed" and
|
|
48
|
+
// reads it as the opener, so a chunk that closes the block it inherited
|
|
49
|
+
// comes out even.
|
|
50
|
+
open = 0;
|
|
51
|
+
for (const line of reopened.split("\n")) {
|
|
52
|
+
const run = /^\s*(`{3,})/.exec(line)?.[1]?.length ?? 0;
|
|
53
|
+
if (!run)
|
|
54
|
+
continue;
|
|
55
|
+
if (!open)
|
|
56
|
+
open = run;
|
|
57
|
+
else if (run >= open)
|
|
58
|
+
open = 0; // a closing run must match the opener
|
|
59
|
+
}
|
|
60
|
+
return open ? `${reopened}\n${fence(open)}` : reopened;
|
|
61
|
+
});
|
|
62
|
+
}
|
package/dist/channels/control.js
CHANGED
|
@@ -5,21 +5,15 @@
|
|
|
5
5
|
// prompt and must not become a second seam. So the channel layer — which owns
|
|
6
6
|
// the router already — hands adapters this narrow, platform-blind interface.
|
|
7
7
|
// Everything here is a thin wrapper over core; no policy lives in it.
|
|
8
|
-
import {
|
|
9
|
-
import { parseConversation as parseTelegram } from "./telegram.js";
|
|
8
|
+
import { chatOf, isChannelPlatform } from "./types.js";
|
|
10
9
|
export function createControl({ router, factory, conversations, store }) {
|
|
11
10
|
const launchFor = (key) => {
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
? parseTelegram(key.conversationId).chatId
|
|
17
|
-
: platform === "slack"
|
|
18
|
-
? parseSlack(key.conversationId).channel
|
|
19
|
-
: undefined;
|
|
20
|
-
if (chatId === undefined)
|
|
11
|
+
// Decoding a conversation id back to a chat id is the channel layer's
|
|
12
|
+
// business, never core's; the chat half of every platform's id has one
|
|
13
|
+
// decoder (chatOf), so no adapter import is needed here.
|
|
14
|
+
if (!isChannelPlatform(key.channelId))
|
|
21
15
|
return {};
|
|
22
|
-
const policy = store.policy(
|
|
16
|
+
const policy = store.policy(key.channelId, chatOf(key.conversationId));
|
|
23
17
|
return {
|
|
24
18
|
cwd: policy.cwd || undefined,
|
|
25
19
|
model: policy.model ?? undefined,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// At-least-once delivery, deduplicated: both push transports (Slack Socket
|
|
2
|
+
// Mode, Lark's long connection) redeliver an event the platform did not see
|
|
3
|
+
// acknowledged, so the same id can arrive twice. One implementation, because
|
|
4
|
+
// the second copy had already appeared and the map has an invariant that is
|
|
5
|
+
// easy to lose on a rewrite: it is fed by every message in every chat the bot
|
|
6
|
+
// is in, so it must be bounded and time-limited, never grow-only.
|
|
7
|
+
export class Dedup {
|
|
8
|
+
log;
|
|
9
|
+
ttlMs;
|
|
10
|
+
max;
|
|
11
|
+
seen = new Map();
|
|
12
|
+
constructor(log, ttlMs, max) {
|
|
13
|
+
this.log = log;
|
|
14
|
+
this.ttlMs = ttlMs;
|
|
15
|
+
this.max = max;
|
|
16
|
+
}
|
|
17
|
+
/** True when this id was already delivered inside the TTL. */
|
|
18
|
+
duplicate(eventId, now = Date.now()) {
|
|
19
|
+
if (!eventId)
|
|
20
|
+
return false;
|
|
21
|
+
if (this.seen.size >= this.max) {
|
|
22
|
+
for (const [id, at] of this.seen) {
|
|
23
|
+
if (now - at > this.ttlMs)
|
|
24
|
+
this.seen.delete(id);
|
|
25
|
+
}
|
|
26
|
+
// Pruning expired entries is not enough under a burst of live ones —
|
|
27
|
+
// `max` must be a real bound, so the oldest entries go next. The cost
|
|
28
|
+
// is a forgotten id under extreme load (a redelivery slips through,
|
|
29
|
+
// which downstream handling tolerates); unbounded memory is worse.
|
|
30
|
+
// Map iterates in insertion order, so the front is the oldest.
|
|
31
|
+
for (const [id] of this.seen) {
|
|
32
|
+
if (this.seen.size < this.max)
|
|
33
|
+
break;
|
|
34
|
+
this.seen.delete(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const at = this.seen.get(eventId);
|
|
38
|
+
if (at !== undefined && now - at <= this.ttlMs) {
|
|
39
|
+
this.log(`duplicate event ${eventId} ignored`);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
this.seen.set(eventId, now);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Thin Lark (Feishu) client: API shapes and the WebSocket transport, no policy.
|
|
2
|
+
// The one file in channels/ that talks to open.feishu.cn, so the adapter stays
|
|
3
|
+
// testable against `LarkClient`.
|
|
4
|
+
//
|
|
5
|
+
// Unlike Slack's Socket Mode — JSON frames a while loop can own — Lark's long
|
|
6
|
+
// connection is a protobuf-framed proprietary protocol with server-pushed
|
|
7
|
+
// reconnect/ping config, so the official SDK carries the transport (and its
|
|
8
|
+
// tenant-token refresh). It is confined to this file; nothing SDK-shaped leaks
|
|
9
|
+
// past `LarkClient`. Domain is fixed to Feishu (open.feishu.cn) on purpose:
|
|
10
|
+
// this instance's operator uses Feishu, and a Lark-international switch is a
|
|
11
|
+
// config field we would carry for nobody.
|
|
12
|
+
//
|
|
13
|
+
// Two credentials, like Slack but for a different reason: every call and the
|
|
14
|
+
// socket itself authenticate as `app_id` + `app_secret`, so ChannelConfig's
|
|
15
|
+
// `token` carries the App ID and `appToken` the App Secret.
|
|
16
|
+
//
|
|
17
|
+
// One transport fact that shaped the adapter: the SDK sends the WS response
|
|
18
|
+
// frame only *after* the registered handler resolves, and Lark redelivers what
|
|
19
|
+
// it never saw answered — so handlers here must return once the event is
|
|
20
|
+
// queued, never once it is handled ("ack is not handling", paid for on Slack).
|
|
21
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
22
|
+
/** Lark answers HTTP 200 with a business code; non-zero is the real error. */
|
|
23
|
+
function ok(what, res) {
|
|
24
|
+
if (res.code !== 0)
|
|
25
|
+
throw new Error(`lark ${what}: ${res.code} ${res.msg ?? ""}`.trim());
|
|
26
|
+
return res;
|
|
27
|
+
}
|
|
28
|
+
export class LarkApi {
|
|
29
|
+
log;
|
|
30
|
+
client;
|
|
31
|
+
/** Our own open_id, remembered from botOpenId() — reaction removal must
|
|
32
|
+
* only ever touch a reaction *this* app made. */
|
|
33
|
+
me = "";
|
|
34
|
+
constructor(appId, appSecret, log = () => { }) {
|
|
35
|
+
this.log = log;
|
|
36
|
+
this.client = new Lark.Client({
|
|
37
|
+
appId,
|
|
38
|
+
appSecret,
|
|
39
|
+
domain: Lark.Domain.Feishu,
|
|
40
|
+
loggerLevel: Lark.LoggerLevel.error,
|
|
41
|
+
});
|
|
42
|
+
this.appId = appId;
|
|
43
|
+
this.appSecret = appSecret;
|
|
44
|
+
}
|
|
45
|
+
appId;
|
|
46
|
+
appSecret;
|
|
47
|
+
async botOpenId() {
|
|
48
|
+
const res = await this.client.request({
|
|
49
|
+
url: "/open-apis/bot/v3/info",
|
|
50
|
+
method: "GET",
|
|
51
|
+
});
|
|
52
|
+
this.me = ok("bot info", res).bot?.open_id ?? "";
|
|
53
|
+
return this.me;
|
|
54
|
+
}
|
|
55
|
+
// --- long connection ---------------------------------------------------------
|
|
56
|
+
/**
|
|
57
|
+
* The SDK owns the loop: endpoint discovery, protobuf frames, ping/pong and
|
|
58
|
+
* the reconnect pacing the server itself pushes down. `card.action.trigger`
|
|
59
|
+
* is registered through `register`'s generic because IHandles types events
|
|
60
|
+
* only, not callbacks; its payload shape is pinned by the adapter's golden
|
|
61
|
+
* tests instead.
|
|
62
|
+
*/
|
|
63
|
+
connect(handlers) {
|
|
64
|
+
const dispatcher = new Lark.EventDispatcher({
|
|
65
|
+
loggerLevel: Lark.LoggerLevel.error,
|
|
66
|
+
}).register({
|
|
67
|
+
"im.message.receive_v1": (data) => {
|
|
68
|
+
handlers.onMessage({
|
|
69
|
+
eventId: data.event_id,
|
|
70
|
+
senderId: data.sender?.sender_id?.open_id,
|
|
71
|
+
senderType: data.sender?.sender_type,
|
|
72
|
+
message: {
|
|
73
|
+
messageId: data.message.message_id,
|
|
74
|
+
rootId: data.message.root_id,
|
|
75
|
+
chatId: data.message.chat_id,
|
|
76
|
+
chatType: data.message.chat_type,
|
|
77
|
+
messageType: data.message.message_type,
|
|
78
|
+
content: data.message.content,
|
|
79
|
+
mentions: data.message.mentions,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
// Resolve now: the SDK answers the frame only after this returns, and
|
|
83
|
+
// a turn outlives Lark's redelivery deadline.
|
|
84
|
+
return Promise.resolve();
|
|
85
|
+
},
|
|
86
|
+
"card.action.trigger": (data) => {
|
|
87
|
+
handlers.onCardAction({
|
|
88
|
+
eventId: data.event_id,
|
|
89
|
+
messageId: data.context?.open_message_id ?? data.open_message_id ?? "",
|
|
90
|
+
chatId: data.context?.open_chat_id ?? data.open_chat_id ?? "",
|
|
91
|
+
operatorId: data.operator?.open_id ?? "",
|
|
92
|
+
value: data.action?.value,
|
|
93
|
+
name: data.action?.name,
|
|
94
|
+
formValue: data.action?.form_value,
|
|
95
|
+
});
|
|
96
|
+
return Promise.resolve();
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const ws = new Lark.WSClient({
|
|
100
|
+
appId: this.appId,
|
|
101
|
+
appSecret: this.appSecret,
|
|
102
|
+
domain: Lark.Domain.Feishu,
|
|
103
|
+
loggerLevel: Lark.LoggerLevel.error,
|
|
104
|
+
});
|
|
105
|
+
// Fire and forget, deliberately: start() settles on the SDK's schedule —
|
|
106
|
+
// it retries a busy endpoint and can sit in that loop for a long time —
|
|
107
|
+
// and ChannelRuntime serializes reloads, so awaiting here would let one
|
|
108
|
+
// unreachable network block every later Console save. Credentials were
|
|
109
|
+
// already proven by botOpenId() before connect() is called; a transport
|
|
110
|
+
// failure after that is the reconnect loop's job, and is logged.
|
|
111
|
+
void ws.start({ eventDispatcher: dispatcher })
|
|
112
|
+
.catch((err) => this.log(`lark long connection failed: ${String(err)}`));
|
|
113
|
+
return Promise.resolve({
|
|
114
|
+
close: () => {
|
|
115
|
+
try {
|
|
116
|
+
ws.close();
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
this.log(`lark socket close failed: ${String(err)}`);
|
|
120
|
+
}
|
|
121
|
+
return Promise.resolve();
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// --- messages ------------------------------------------------------------------
|
|
126
|
+
async replyCard(messageId, card) {
|
|
127
|
+
const res = await this.client.im.v1.message.reply({
|
|
128
|
+
path: { message_id: messageId },
|
|
129
|
+
data: {
|
|
130
|
+
msg_type: "interactive",
|
|
131
|
+
content: JSON.stringify(card),
|
|
132
|
+
// What makes the reply land in the message's own topic instead of the
|
|
133
|
+
// chat's main flow — Lark's equivalent of posting to a thread_ts.
|
|
134
|
+
reply_in_thread: true,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
return { messageId: ok("message.reply", res).data?.message_id ?? "" };
|
|
138
|
+
}
|
|
139
|
+
async patchCard(messageId, card) {
|
|
140
|
+
ok("message.patch", await this.client.im.v1.message.patch({
|
|
141
|
+
path: { message_id: messageId },
|
|
142
|
+
data: { content: JSON.stringify(card) },
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
async deleteMessage(messageId) {
|
|
146
|
+
ok("message.delete", await this.client.im.v1.message.delete({ path: { message_id: messageId } }));
|
|
147
|
+
}
|
|
148
|
+
// --- reactions -------------------------------------------------------------------
|
|
149
|
+
async addReaction(messageId, emojiType) {
|
|
150
|
+
ok("reaction.create", await this.client.im.v1.messageReaction.create({
|
|
151
|
+
path: { message_id: messageId },
|
|
152
|
+
data: { reaction_type: { emoji_type: emojiType } },
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Lark deletes reactions by `reaction_id`, and several parties may have
|
|
157
|
+
* used the same emoji — so list, keep only the entry *this app* owns
|
|
158
|
+
* (`operator_type` alone is not ownership: another bot's 👀 is an app
|
|
159
|
+
* reaction too, and deleting it would strand our own), delete that.
|
|
160
|
+
*/
|
|
161
|
+
async removeReaction(messageId, emojiType) {
|
|
162
|
+
let pageToken;
|
|
163
|
+
do {
|
|
164
|
+
const res = ok("reaction.list", await this.client.im.v1.messageReaction.list({
|
|
165
|
+
path: { message_id: messageId },
|
|
166
|
+
params: { reaction_type: emojiType, page_size: 50, page_token: pageToken },
|
|
167
|
+
}));
|
|
168
|
+
for (const item of res.data?.items ?? []) {
|
|
169
|
+
const op = item.operator;
|
|
170
|
+
if (op?.operator_type !== "app")
|
|
171
|
+
continue;
|
|
172
|
+
// The list reports an app operator by open_id or app_id depending on
|
|
173
|
+
// surface; accept either of ours, never a blank (avibe's rule).
|
|
174
|
+
const id = (op.operator_id ?? "").trim();
|
|
175
|
+
if (!id || (id !== this.me && id !== this.appId))
|
|
176
|
+
continue;
|
|
177
|
+
if (item.reaction_id) {
|
|
178
|
+
ok("reaction.delete", await this.client.im.v1.messageReaction.delete({
|
|
179
|
+
path: { message_id: messageId, reaction_id: item.reaction_id },
|
|
180
|
+
}));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
pageToken = res.data?.has_more ? res.data?.page_token : undefined;
|
|
185
|
+
} while (pageToken);
|
|
186
|
+
}
|
|
187
|
+
// --- lookups ---------------------------------------------------------------------
|
|
188
|
+
async chatName(chatId) {
|
|
189
|
+
const res = ok("chat.get", await this.client.im.v1.chat.get({ path: { chat_id: chatId } }));
|
|
190
|
+
return res.data?.name || undefined;
|
|
191
|
+
}
|
|
192
|
+
async userName(openId) {
|
|
193
|
+
// Needs contact:user.base:readonly; without it the id is the honest label.
|
|
194
|
+
try {
|
|
195
|
+
const res = ok("user.get", await this.client.contact.v3.user.get({
|
|
196
|
+
path: { user_id: openId },
|
|
197
|
+
params: { user_id_type: "open_id" },
|
|
198
|
+
}));
|
|
199
|
+
return res.data?.user?.name || openId;
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
this.log(`lark user lookup failed for ${openId}: ${String(err)}`);
|
|
203
|
+
return openId;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// --- files ----------------------------------------------------------------------
|
|
207
|
+
/**
|
|
208
|
+
* `maxBytes` is enforced *while streaming*: the receive event often omits
|
|
209
|
+
* `file_size`, so the metadata check upstream cannot be the only cap, and
|
|
210
|
+
* buffering an unbounded stream whole into memory is the exact failure the
|
|
211
|
+
* cap exists for. The error message carries "too large" — the adapter's
|
|
212
|
+
* lost-marker wording keys on it.
|
|
213
|
+
*/
|
|
214
|
+
async download(messageId, fileKey, type, maxBytes) {
|
|
215
|
+
const res = await this.client.im.v1.messageResource.get({
|
|
216
|
+
path: { message_id: messageId, file_key: fileKey },
|
|
217
|
+
params: { type },
|
|
218
|
+
});
|
|
219
|
+
const stream = res.getReadableStream();
|
|
220
|
+
const parts = [];
|
|
221
|
+
let size = 0;
|
|
222
|
+
for await (const part of stream) {
|
|
223
|
+
const buf = Buffer.isBuffer(part) ? part : Buffer.from(part);
|
|
224
|
+
size += buf.length;
|
|
225
|
+
if (size > maxBytes) {
|
|
226
|
+
stream.destroy?.();
|
|
227
|
+
throw new Error(`lark resource ${fileKey}: too large (>${maxBytes} bytes)`);
|
|
228
|
+
}
|
|
229
|
+
parts.push(buf);
|
|
230
|
+
}
|
|
231
|
+
return { bytes: new Uint8Array(Buffer.concat(parts)) };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// How a turn becomes cards in a Lark thread.
|
|
2
|
+
//
|
|
3
|
+
// Split from the adapter the way slack-outbound.ts is: what a turn renders as
|
|
4
|
+
// — one card per chunk, footer and buttons on the last, and what an empty turn
|
|
5
|
+
// still has to say — is a different decision from routing inbound traffic. The
|
|
6
|
+
// adapter keeps the 👀 receipts, because those are about the turn ending, not
|
|
7
|
+
// about what was said.
|
|
8
|
+
import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
|
|
9
|
+
import { button, buttonRow, card, chunk, footer, LARK_MAX, markdown, OFFER_PREFIX, withFooter, withoutButtons, } from "./lark-render.js";
|
|
10
|
+
/** How many sent cards the retire cache remembers (avibe keeps 200). */
|
|
11
|
+
const SENT_CACHE = 200;
|
|
12
|
+
export class LarkOutbound {
|
|
13
|
+
api;
|
|
14
|
+
log;
|
|
15
|
+
/**
|
|
16
|
+
* The cards this process sent with buttons on them, for retiring the row
|
|
17
|
+
* once an option is taken — a 2.0 card cannot be read back from the
|
|
18
|
+
* platform (see LarkActionValue), so what we sent is the only copy.
|
|
19
|
+
* In-memory and bounded, copied from avibe: purely cosmetic state. A click
|
|
20
|
+
* after a restart still *works* (the label rides in the value); the buttons
|
|
21
|
+
* merely stay up, and the skip is logged.
|
|
22
|
+
*/
|
|
23
|
+
sent = new Map();
|
|
24
|
+
constructor(api, log) {
|
|
25
|
+
this.api = api;
|
|
26
|
+
this.log = log;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Post one turn as replies into the conversation's thread, empty text
|
|
30
|
+
* included: a turn that produced nothing still posts its footer and says
|
|
31
|
+
* which kind of nothing it was — total silence is indistinguishable from a
|
|
32
|
+
* crash, and the person watching the 👀 come off has no way to tell.
|
|
33
|
+
*
|
|
34
|
+
* The footer folds into the last body chunk's own markdown element (a
|
|
35
|
+
* second element renders a blank gap); only a bodiless turn gets it as the
|
|
36
|
+
* standalone muted element. Buttons ride the last chunk, like every other
|
|
37
|
+
* platform — the card is remembered so retire() can rebuild it without them.
|
|
38
|
+
*/
|
|
39
|
+
async reply(root, reply) {
|
|
40
|
+
const text = reply.text.trim();
|
|
41
|
+
const meta = reply.meta ? formatTurnMeta(reply.meta) : "";
|
|
42
|
+
const quiet = isSilentReply(reply) ? quietLabel(reply.silence) : "";
|
|
43
|
+
const note = [quiet, meta].filter(Boolean).join(" · ");
|
|
44
|
+
if (!(text || reply.suggestions.length || note))
|
|
45
|
+
return;
|
|
46
|
+
const row = reply.suggestions.length
|
|
47
|
+
? buttonRow(reply.suggestions.map((label, index) => button(label, { key: `${OFFER_PREFIX}${index}`, root, label })))
|
|
48
|
+
: undefined;
|
|
49
|
+
const parts = text ? chunk(text, LARK_MAX) : [""];
|
|
50
|
+
for (const [i, part] of parts.entries()) {
|
|
51
|
+
const last = i === parts.length - 1;
|
|
52
|
+
const elements = [];
|
|
53
|
+
if (part)
|
|
54
|
+
elements.push(last && note ? withFooter(part, note) : markdown(part));
|
|
55
|
+
else if (last && note)
|
|
56
|
+
elements.push(footer(note));
|
|
57
|
+
if (last && row)
|
|
58
|
+
elements.push(row);
|
|
59
|
+
if (!elements.length)
|
|
60
|
+
continue;
|
|
61
|
+
const { messageId } = await this.api.replyCard(root, card(elements));
|
|
62
|
+
if (last && row && messageId)
|
|
63
|
+
this.remember(messageId, card(elements));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Take the buttons off a card one option was just taken from — the rest
|
|
68
|
+
* answer a question the conversation has moved past. Best-effort by design:
|
|
69
|
+
* an unremembered card (sent before a restart) keeps its row, logged.
|
|
70
|
+
*/
|
|
71
|
+
async retire(messageId) {
|
|
72
|
+
const known = this.sent.get(messageId);
|
|
73
|
+
if (!known) {
|
|
74
|
+
this.log(`options on ${messageId} not retired: sent before this process`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.sent.delete(messageId);
|
|
78
|
+
const kept = withoutButtons(known);
|
|
79
|
+
await this.api.patchCard(messageId, kept.body.elements.length ? kept : card([footer("Option taken.")])).catch((err) => this.log(`retiring options failed: ${String(err)}`));
|
|
80
|
+
}
|
|
81
|
+
remember(messageId, sent) {
|
|
82
|
+
this.sent.set(messageId, sent);
|
|
83
|
+
while (this.sent.size > SENT_CACHE) {
|
|
84
|
+
const oldest = this.sent.keys().next().value;
|
|
85
|
+
if (oldest === undefined)
|
|
86
|
+
break;
|
|
87
|
+
this.sent.delete(oldest);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A system note: quoted, labelled with where it came from, and deliberately
|
|
92
|
+
* plain — no buttons and no turn footer, because the turn this input
|
|
93
|
+
* triggers has not ended yet.
|
|
94
|
+
*/
|
|
95
|
+
async note(root, note) {
|
|
96
|
+
const body = note.text.split("\n").map((line) => `> ${line}`).join("\n");
|
|
97
|
+
for (const part of chunk(`*${originLabel(note.origin)}*\n${body}`, LARK_MAX)) {
|
|
98
|
+
await this.api.replyCard(root, card([markdown(part)]));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Lark's half of the settings panel: card markup, flow button rows, and a form
|
|
2
|
+
// card for the one typed answer. The panel itself lives in `panel.ts`.
|
|
3
|
+
//
|
|
4
|
+
// Lark has no modal a WebSocket app can open — a "modal" here is the panel
|
|
5
|
+
// message patched into a form card (an input plus a submit button), which is
|
|
6
|
+
// avibe's verified pattern. The submit button's `name` carries the thread root
|
|
7
|
+
// the same way every other panel button's callback value does, so a submission
|
|
8
|
+
// needs no adapter-side state to find its conversation — the map below only
|
|
9
|
+
// remembers where the panel message itself lives.
|
|
10
|
+
import { button as cardButton, buttonRow, card, footer, formInput, markdown, } from "./lark-render.js";
|
|
11
|
+
import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js";
|
|
12
|
+
/** A form-submit button name: `cwdgo:<thread root>`. */
|
|
13
|
+
export const CWD_SUBMIT_PREFIX = "cwdgo:";
|
|
14
|
+
/** The form input's field name, the key `form_value` answers under. */
|
|
15
|
+
export const CWD_FIELD = "cwd";
|
|
16
|
+
export class LarkPanel extends ChatPanel {
|
|
17
|
+
deps;
|
|
18
|
+
platform = "lark";
|
|
19
|
+
fence = ["`", "`"];
|
|
20
|
+
constructor(deps) {
|
|
21
|
+
super(deps);
|
|
22
|
+
this.deps = deps;
|
|
23
|
+
}
|
|
24
|
+
/** Lark's markdown treats what it cannot parse as literal text; there is no
|
|
25
|
+
* escape syntax to apply (see lark-render.ts). */
|
|
26
|
+
esc(text) {
|
|
27
|
+
return text;
|
|
28
|
+
}
|
|
29
|
+
// --- rendering -------------------------------------------------------------
|
|
30
|
+
btn(b, root) {
|
|
31
|
+
return cardButton(b.label, { key: `${PANEL_PREFIX}${b.action}`, root });
|
|
32
|
+
}
|
|
33
|
+
render(view, root, note) {
|
|
34
|
+
const elements = [
|
|
35
|
+
...view.groups.map((g) => markdown([`**${g.title}**${g.suffix ?? ""}`, ...g.lines].join("\n"))),
|
|
36
|
+
// A flow row wraps, so a page of models lays out like Slack's one row.
|
|
37
|
+
...(view.picks?.length ? [buttonRow(view.picks.map((p) => this.btn(p, root)))] : []),
|
|
38
|
+
...view.rows.filter((r) => r.length).map((row) => buttonRow(row.map((b) => this.btn(b, root)))),
|
|
39
|
+
];
|
|
40
|
+
if (note)
|
|
41
|
+
elements.push(footer(note));
|
|
42
|
+
return card(elements);
|
|
43
|
+
}
|
|
44
|
+
/** Open a fresh panel, replacing whichever one this conversation had. */
|
|
45
|
+
async open(key, chatId, root) {
|
|
46
|
+
const sent = await this.deps.api.replyCard(root, this.render(await this.view(key, chatId), root));
|
|
47
|
+
this.remember(key, { chatId, root, messageId: sent.messageId, models: [] });
|
|
48
|
+
}
|
|
49
|
+
async draw(state, view, note) {
|
|
50
|
+
await this.deps.api.patchCard(state.messageId, this.render(view, state.root, note))
|
|
51
|
+
.catch((err) => this.deps.log(`panel edit failed: ${String(err)}`));
|
|
52
|
+
}
|
|
53
|
+
async erase(state) {
|
|
54
|
+
await this.deps.api.deleteMessage(state.messageId)
|
|
55
|
+
.catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
|
|
56
|
+
}
|
|
57
|
+
// --- actions ---------------------------------------------------------------
|
|
58
|
+
/**
|
|
59
|
+
* Handle a `cfg:` click. Returns false when the action is not ours, so the
|
|
60
|
+
* caller can treat it as one of the agent's next-step labels instead.
|
|
61
|
+
*/
|
|
62
|
+
async onAction(action, key, payload, root) {
|
|
63
|
+
return this.dispatch(key, payload, action, () => this.open(key, action.chatId, root));
|
|
64
|
+
}
|
|
65
|
+
// --- working directory (one typed answer, in a form card) -------------------
|
|
66
|
+
async promptCwd(key, state, _action) {
|
|
67
|
+
const form = {
|
|
68
|
+
tag: "form",
|
|
69
|
+
name: "cwd_form",
|
|
70
|
+
elements: [
|
|
71
|
+
formInput(CWD_FIELD, "Working directory", CWD_PLACEHOLDER),
|
|
72
|
+
markdown(`An absolute path. ${CWD_TAIL}`),
|
|
73
|
+
{
|
|
74
|
+
tag: "button",
|
|
75
|
+
text: { tag: "plain_text", content: "Start" },
|
|
76
|
+
type: "primary",
|
|
77
|
+
action_type: "form_submit",
|
|
78
|
+
name: `${CWD_SUBMIT_PREFIX}${state.root}`,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
await this.deps.api.patchCard(state.messageId, card([form, buttonRow([this.btn({ label: "Cancel", action: "panel" }, state.root)])])).catch((err) => this.deps.log(`cwd form failed: ${String(err)}`));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Consume a form submission. The adapter routes any `cwdgo:` submit here;
|
|
86
|
+
* a panel that outlived its process is re-remembered from the event itself,
|
|
87
|
+
* so the outcome still lands on the card the user is looking at.
|
|
88
|
+
*/
|
|
89
|
+
async onCwdSubmit(key, action, root) {
|
|
90
|
+
if (!this.state(key)) {
|
|
91
|
+
this.remember(key, { chatId: action.chatId, root, messageId: action.messageId, models: [] });
|
|
92
|
+
}
|
|
93
|
+
await this.startSessionIn(key, String(action.formValue?.[CWD_FIELD] ?? "").trim());
|
|
94
|
+
}
|
|
95
|
+
}
|