@timqi/pier 0.1.0 → 0.1.1
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 +58 -125
- package/dist/agent/config.js +6 -15
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +42 -64
- package/dist/agent/listing.js +39 -92
- package/dist/agent/pi.js +103 -252
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +7 -23
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +12 -34
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -264
- package/dist/core/types.js +4 -0
- package/dist/db.js +88 -272
- package/dist/drain.js +57 -50
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +4 -12
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +84 -183
- package/dist/paths.js +10 -26
- package/dist/secrets.js +18 -45
- package/dist/service.js +31 -73
- package/dist/settings.js +19 -63
- package/dist/tasks/agent.js +24 -45
- package/dist/tasks/callbacks.js +8 -16
- package/dist/tasks/command.js +2 -6
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +41 -39
- package/dist/tasks/groups.js +8 -11
- package/dist/tasks/messages.js +88 -155
- package/dist/tasks/outbox.js +33 -54
- package/dist/tasks/routes.js +4 -7
- package/dist/tasks/runs.js +4 -9
- package/dist/tasks/service.js +29 -42
- package/dist/tasks/store.js +36 -27
- package/dist/tasks/tool.js +59 -60
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +98 -325
- package/dist/update.js +20 -43
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +35 -82
- package/dist/web/providers.js +5 -11
- package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
- package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
- package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
- package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
- package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js +85 -0
- package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
- package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
- package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
- package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
- package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
- package/dist/web/public/index.html +30 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +109 -190
- package/dist/web/session-state.js +13 -53
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +23 -15
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
package/dist/channels/dedup.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
// At-least-once delivery, deduplicated:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// is in, so it must be bounded and time-limited, never grow-only.
|
|
7
|
-
/** Fraction of `max` a full map is cut back to, so the eviction walk is paid
|
|
8
|
-
* once per that many messages instead of once per message. */
|
|
1
|
+
// At-least-once delivery, deduplicated: Slack and Lark redeliver an event they
|
|
2
|
+
// did not see acknowledged. The map is fed by every message in every chat, so
|
|
3
|
+
// it must be bounded and time-limited, never grow-only.
|
|
4
|
+
/** A full map is cut back to this fraction of `max`, so the eviction walk is
|
|
5
|
+
* amortized instead of paid on every message at the bound. */
|
|
9
6
|
const KEEP = 0.9;
|
|
10
7
|
export class Dedup {
|
|
11
8
|
log;
|
|
@@ -26,15 +23,9 @@ export class Dedup {
|
|
|
26
23
|
if (now - at > this.ttlMs)
|
|
27
24
|
this.seen.delete(id);
|
|
28
25
|
}
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// which downstream handling tolerates); unbounded memory is worse.
|
|
33
|
-
// Map iterates in insertion order, so the front is the oldest.
|
|
34
|
-
//
|
|
35
|
-
// Evicting down to `KEEP` rather than to exactly `max` is what makes
|
|
36
|
-
// the walk amortized: at the bound, freeing one slot per message meant
|
|
37
|
-
// re-walking the whole map on every message from then on.
|
|
26
|
+
// `max` must be a real bound, so under a burst of live entries the
|
|
27
|
+
// oldest go too (Map iterates in insertion order); a redelivery slipping
|
|
28
|
+
// through under extreme load is tolerated, unbounded memory is not.
|
|
38
29
|
const keep = Math.floor(this.max * KEEP);
|
|
39
30
|
for (const [id] of this.seen) {
|
|
40
31
|
if (this.seen.size <= keep)
|
|
@@ -1,20 +1,13 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// through, and may this stranger be told how to
|
|
3
|
-
//
|
|
4
|
-
// Both were written twice before landing here, and both have a rule that is
|
|
5
|
-
// easy to get subtly wrong on the second copy — a drop must always name its
|
|
6
|
-
// verdict, and the throttle map must prune rather than grow. Keeping them in
|
|
7
|
-
// one place is what makes "log every drop" and "bound every map fed by
|
|
8
|
-
// strangers" true of every platform instead of one.
|
|
1
|
+
// The inbound decisions every adapter makes identically: may this message
|
|
2
|
+
// through, may this chat be remembered, and may this stranger be told how to
|
|
3
|
+
// bind.
|
|
9
4
|
import { gate } from "./config.js";
|
|
10
|
-
/** How often one unbound DM sender may be told how to bind. */
|
|
11
5
|
const BIND_HINT_EVERY_MS = 10 * 60_000;
|
|
12
6
|
export class Gatekeeper {
|
|
13
7
|
store;
|
|
14
8
|
platform;
|
|
15
9
|
log;
|
|
16
10
|
noun;
|
|
17
|
-
/** Last time each unbound DM sender was told how to bind. */
|
|
18
11
|
hints = new Map();
|
|
19
12
|
constructor(store, platform, log,
|
|
20
13
|
/** What the platform calls a conversation, for the drop log. */
|
|
@@ -24,11 +17,8 @@ export class Gatekeeper {
|
|
|
24
17
|
this.log = log;
|
|
25
18
|
this.noun = noun;
|
|
26
19
|
}
|
|
27
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
* A silently skipped branch is indistinguishable from a bug, so the verdict
|
|
30
|
-
* is always named.
|
|
31
|
-
*/
|
|
20
|
+
/** Every drop names its verdict: a silently skipped branch is
|
|
21
|
+
* indistinguishable from a bug. */
|
|
32
22
|
admit(what, chatId, req) {
|
|
33
23
|
const verdict = gate({
|
|
34
24
|
policy: this.store.policy(this.platform, chatId),
|
|
@@ -42,14 +32,14 @@ export class Gatekeeper {
|
|
|
42
32
|
this.log(`dropped ${what} in ${this.noun} ${chatId}: ${verdict}`);
|
|
43
33
|
return false;
|
|
44
34
|
}
|
|
45
|
-
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
*/
|
|
35
|
+
/** A group the bot was added to is something an operator must see, but a DM
|
|
36
|
+
* from a stranger may not write a row (or make the bot look its sender up):
|
|
37
|
+
* anyone can start one. */
|
|
38
|
+
mayDiscover(req) {
|
|
39
|
+
return !req.isDm || this.store.isBound(this.platform, req.userId);
|
|
40
|
+
}
|
|
41
|
+
/** A bot that answers every stranger is an echo amplifier. The map is fed by
|
|
42
|
+
* strangers, so expired entries are pruned on the way past. */
|
|
53
43
|
mayHint(userId, now = Date.now()) {
|
|
54
44
|
if (now - (this.hints.get(userId) ?? 0) < BIND_HINT_EVERY_MS)
|
|
55
45
|
return false;
|
|
@@ -1,23 +1,10 @@
|
|
|
1
|
-
// Thin Lark (Feishu) client: API shapes and the WebSocket transport, no policy
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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).
|
|
1
|
+
// Thin Lark (Feishu) client: API shapes and the WebSocket transport, no policy;
|
|
2
|
+
// the one file that talks to open.feishu.cn. The long connection is a
|
|
3
|
+
// protobuf-framed proprietary protocol, so the official SDK carries the
|
|
4
|
+
// transport, confined to this file. Domain is fixed to Feishu. ChannelConfig's
|
|
5
|
+
// `token` is the App ID and `appToken` the App Secret. The SDK acks a frame only
|
|
6
|
+
// after the handler resolves and Lark redelivers what it never saw answered, so
|
|
7
|
+
// handlers return once the event is queued.
|
|
21
8
|
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
22
9
|
/** Lark answers HTTP 200 with a business code; non-zero is the real error. */
|
|
23
10
|
function ok(what, res) {
|
|
@@ -28,8 +15,7 @@ function ok(what, res) {
|
|
|
28
15
|
export class LarkApi {
|
|
29
16
|
log;
|
|
30
17
|
client;
|
|
31
|
-
/**
|
|
32
|
-
* only ever touch a reaction *this* app made. */
|
|
18
|
+
/** Reaction removal must only touch a reaction this app made. */
|
|
33
19
|
me = "";
|
|
34
20
|
constructor(appId, appSecret, log = () => { }) {
|
|
35
21
|
this.log = log;
|
|
@@ -53,13 +39,8 @@ export class LarkApi {
|
|
|
53
39
|
return this.me;
|
|
54
40
|
}
|
|
55
41
|
// --- long connection ---------------------------------------------------------
|
|
56
|
-
/**
|
|
57
|
-
*
|
|
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
|
-
*/
|
|
42
|
+
/** `card.action.trigger` goes through `register`'s generic because IHandles
|
|
43
|
+
* types events only, not callbacks. */
|
|
63
44
|
connect(handlers) {
|
|
64
45
|
const dispatcher = new Lark.EventDispatcher({
|
|
65
46
|
loggerLevel: Lark.LoggerLevel.error,
|
|
@@ -79,8 +60,7 @@ export class LarkApi {
|
|
|
79
60
|
mentions: data.message.mentions,
|
|
80
61
|
},
|
|
81
62
|
});
|
|
82
|
-
//
|
|
83
|
-
// a turn outlives Lark's redelivery deadline.
|
|
63
|
+
// The SDK acks only after this returns; a turn outlives the redelivery deadline.
|
|
84
64
|
return Promise.resolve();
|
|
85
65
|
},
|
|
86
66
|
"card.action.trigger": (data) => {
|
|
@@ -102,12 +82,9 @@ export class LarkApi {
|
|
|
102
82
|
domain: Lark.Domain.Feishu,
|
|
103
83
|
loggerLevel: Lark.LoggerLevel.error,
|
|
104
84
|
});
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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.
|
|
85
|
+
// Not awaited: start() can sit in the SDK's retry loop for a long time,
|
|
86
|
+
// and ChannelRuntime serializes reloads, so one unreachable network would
|
|
87
|
+
// block every later Console save. botOpenId() already proved the credentials.
|
|
111
88
|
void ws.start({ eventDispatcher: dispatcher })
|
|
112
89
|
.catch((err) => this.log(`lark long connection failed: ${String(err)}`));
|
|
113
90
|
return Promise.resolve({
|
|
@@ -129,23 +106,15 @@ export class LarkApi {
|
|
|
129
106
|
data: {
|
|
130
107
|
msg_type: "interactive",
|
|
131
108
|
content: JSON.stringify(card),
|
|
132
|
-
//
|
|
133
|
-
// chat's main flow — Lark's equivalent of posting to a thread_ts.
|
|
109
|
+
// Lark's equivalent of posting to a thread_ts.
|
|
134
110
|
reply_in_thread: true,
|
|
135
111
|
},
|
|
136
112
|
});
|
|
137
113
|
return { messageId: ok("message.reply", res).data?.message_id ?? "" };
|
|
138
114
|
}
|
|
139
|
-
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* they render inline; everything else is a `stream` file, which is Lark's
|
|
143
|
-
* name for "a file whose type I am not claiming to know".
|
|
144
|
-
*
|
|
145
|
-
* The SDK unwraps an upload response to its `data`, so a business failure
|
|
146
|
-
* arrives as a missing key rather than as a code — hence the explicit throw
|
|
147
|
-
* instead of `ok()`.
|
|
148
|
-
*/
|
|
115
|
+
/** Images take the image endpoint so they render inline; `stream` is Lark's
|
|
116
|
+
* "type unknown". The SDK unwraps an upload response to its `data`, so a
|
|
117
|
+
* failure arrives as a missing key, not a code. */
|
|
149
118
|
async uploadFile(rootId, file) {
|
|
150
119
|
const bytes = Buffer.from(file.bytes);
|
|
151
120
|
let content;
|
|
@@ -190,12 +159,8 @@ export class LarkApi {
|
|
|
190
159
|
data: { reaction_type: { emoji_type: emojiType } },
|
|
191
160
|
}));
|
|
192
161
|
}
|
|
193
|
-
/**
|
|
194
|
-
*
|
|
195
|
-
* used the same emoji — so list, keep only the entry *this app* owns
|
|
196
|
-
* (`operator_type` alone is not ownership: another bot's 👀 is an app
|
|
197
|
-
* reaction too, and deleting it would strand our own), delete that.
|
|
198
|
-
*/
|
|
162
|
+
/** `operator_type` alone is not ownership: another bot's 👀 is an app
|
|
163
|
+
* reaction too, and deleting it would strand our own. */
|
|
199
164
|
async removeReaction(messageId, emojiType) {
|
|
200
165
|
let pageToken;
|
|
201
166
|
do {
|
|
@@ -208,7 +173,7 @@ export class LarkApi {
|
|
|
208
173
|
if (op?.operator_type !== "app")
|
|
209
174
|
continue;
|
|
210
175
|
// The list reports an app operator by open_id or app_id depending on
|
|
211
|
-
// surface; accept either of ours, never a blank
|
|
176
|
+
// surface; accept either of ours, never a blank.
|
|
212
177
|
const id = (op.operator_id ?? "").trim();
|
|
213
178
|
if (!id || (id !== this.me && id !== this.appId))
|
|
214
179
|
continue;
|
|
@@ -242,13 +207,8 @@ export class LarkApi {
|
|
|
242
207
|
}
|
|
243
208
|
}
|
|
244
209
|
// --- files ----------------------------------------------------------------------
|
|
245
|
-
/**
|
|
246
|
-
*
|
|
247
|
-
* `file_size`, so the metadata check upstream cannot be the only cap, and
|
|
248
|
-
* buffering an unbounded stream whole into memory is the exact failure the
|
|
249
|
-
* cap exists for. The error message carries "too large" — the adapter's
|
|
250
|
-
* lost-marker wording keys on it.
|
|
251
|
-
*/
|
|
210
|
+
/** Enforced while streaming: the receive event often omits `file_size`. The
|
|
211
|
+
* error carries "too large", which the lost-marker wording keys on. */
|
|
252
212
|
async download(messageId, fileKey, type, maxBytes) {
|
|
253
213
|
const res = await this.client.im.v1.messageResource.get({
|
|
254
214
|
path: { message_id: messageId, file_key: fileKey },
|
|
@@ -1,45 +1,25 @@
|
|
|
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.
|
|
1
|
+
// How a turn becomes cards in a Lark thread: one card per chunk, footer and
|
|
2
|
+
// buttons on the last, and what an empty turn still has to say.
|
|
8
3
|
import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
|
|
9
4
|
import { sendAttachments, splitAttachments } from "./attach.js";
|
|
10
5
|
import { button, buttonRow, card, chunk, footer, LARK_MAX, markdown, OFFER_PREFIX, withFooter, withoutButtons, } from "./lark-render.js";
|
|
11
|
-
/** How many sent cards the retire cache remembers (avibe keeps 200). */
|
|
12
6
|
const SENT_CACHE = 200;
|
|
13
7
|
export class LarkOutbound {
|
|
14
8
|
api;
|
|
15
9
|
log;
|
|
16
|
-
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* platform (see LarkActionValue), so what we sent is the only copy.
|
|
20
|
-
* In-memory and bounded, copied from avibe: purely cosmetic state. A click
|
|
21
|
-
* after a restart still *works* (the label rides in the value); the buttons
|
|
22
|
-
* merely stay up, and the skip is logged.
|
|
23
|
-
*/
|
|
10
|
+
/** A 2.0 card cannot be read back (LarkActionValue), so what we sent is the
|
|
11
|
+
* only copy to retire buttons from. Cosmetic: a click after a restart still
|
|
12
|
+
* works, the buttons merely stay up. */
|
|
24
13
|
sent = new Map();
|
|
25
14
|
constructor(api, log) {
|
|
26
15
|
this.api = api;
|
|
27
16
|
this.log = log;
|
|
28
17
|
}
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* which kind of nothing it was — total silence is indistinguishable from a
|
|
33
|
-
* crash, and the person watching the 👀 come off has no way to tell.
|
|
34
|
-
*
|
|
35
|
-
* The footer folds into the last body chunk's own markdown element (a
|
|
36
|
-
* second element renders a blank gap); only a bodiless turn gets it as the
|
|
37
|
-
* standalone muted element. Buttons ride the last chunk, like every other
|
|
38
|
-
* platform — the card is remembered so retire() can rebuild it without them.
|
|
39
|
-
*/
|
|
18
|
+
/** An empty turn still posts its footer and says which kind of nothing (§5).
|
|
19
|
+
* The footer folds into the last chunk's element (a second element renders
|
|
20
|
+
* a blank gap); only a bodiless turn gets the standalone one. */
|
|
40
21
|
async reply(root, reply) {
|
|
41
|
-
// A file
|
|
42
|
-
// Lark: the bytes are uploaded instead and the label stays in the text.
|
|
22
|
+
// A local file link is dead in Lark: the bytes are uploaded instead.
|
|
43
23
|
const { text: spoken, paths } = splitAttachments(reply.text);
|
|
44
24
|
const text = spoken.trim();
|
|
45
25
|
const meta = reply.meta ? formatTurnMeta(reply.meta) : "";
|
|
@@ -66,17 +46,11 @@ export class LarkOutbound {
|
|
|
66
46
|
if (last && row && messageId)
|
|
67
47
|
this.remember(messageId, card(elements));
|
|
68
48
|
}
|
|
69
|
-
// Attachments follow the words, so the card introducing them is above
|
|
70
|
-
// them; anything that could not be sent says so in the thread.
|
|
71
49
|
const lost = await sendAttachments(paths, (file) => this.api.uploadFile(root, file), this.log);
|
|
72
50
|
if (lost)
|
|
73
51
|
await this.api.replyCard(root, card([markdown(lost)]));
|
|
74
52
|
}
|
|
75
|
-
/**
|
|
76
|
-
* Take the buttons off a card one option was just taken from — the rest
|
|
77
|
-
* answer a question the conversation has moved past. Best-effort by design:
|
|
78
|
-
* an unremembered card (sent before a restart) keeps its row, logged.
|
|
79
|
-
*/
|
|
53
|
+
/** Best-effort: a card sent before a restart keeps its row, logged. */
|
|
80
54
|
async retire(messageId) {
|
|
81
55
|
const known = this.sent.get(messageId);
|
|
82
56
|
if (!known) {
|
|
@@ -96,14 +70,8 @@ export class LarkOutbound {
|
|
|
96
70
|
this.sent.delete(oldest);
|
|
97
71
|
}
|
|
98
72
|
}
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
* plain — no buttons and no turn footer, because the turn this input
|
|
102
|
-
* triggers has not ended yet.
|
|
103
|
-
*
|
|
104
|
-
* Answers with the id of the last card it posted — where the caller puts the
|
|
105
|
-
* 👀 for that turn, at the foot of the topic the reply will land in.
|
|
106
|
-
*/
|
|
73
|
+
/** No footer: the turn this input triggers has not ended. Answers with the
|
|
74
|
+
* id of the last card posted, where the caller puts the 👀. */
|
|
107
75
|
async note(root, note) {
|
|
108
76
|
const body = note.text.split("\n").map((line) => `> ${line}`).join("\n");
|
|
109
77
|
let messageId;
|
|
@@ -1,17 +1,10 @@
|
|
|
1
|
-
// Lark's half of the settings panel
|
|
2
|
-
//
|
|
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.
|
|
1
|
+
// Lark's half of the settings panel (panel.ts has the rest). Lark has no modal
|
|
2
|
+
// a WebSocket app can open, so the typed answer is the panel patched into a
|
|
3
|
+
// form card; the submit button's `name` carries the thread root.
|
|
10
4
|
import { button as cardButton, buttonRow, card, footer, formInput, markdown, } from "./lark-render.js";
|
|
11
5
|
import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js";
|
|
12
6
|
/** A form-submit button name: `cwdgo:<thread root>`. */
|
|
13
7
|
export const CWD_SUBMIT_PREFIX = "cwdgo:";
|
|
14
|
-
/** The form input's field name, the key `form_value` answers under. */
|
|
15
8
|
const CWD_FIELD = "cwd";
|
|
16
9
|
export class LarkPanel extends ChatPanel {
|
|
17
10
|
deps;
|
|
@@ -21,8 +14,7 @@ export class LarkPanel extends ChatPanel {
|
|
|
21
14
|
super(deps);
|
|
22
15
|
this.deps = deps;
|
|
23
16
|
}
|
|
24
|
-
/** Lark's markdown
|
|
25
|
-
* escape syntax to apply (see lark-render.ts). */
|
|
17
|
+
/** Lark's markdown has no escape syntax (lark-render.ts). */
|
|
26
18
|
esc(text) {
|
|
27
19
|
return text;
|
|
28
20
|
}
|
|
@@ -33,7 +25,6 @@ export class LarkPanel extends ChatPanel {
|
|
|
33
25
|
render(view, root, note) {
|
|
34
26
|
const elements = [
|
|
35
27
|
...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
28
|
...(view.picks?.length ? [buttonRow(view.picks.map((p) => this.btn(p, root)))] : []),
|
|
38
29
|
...view.rows.filter((r) => r.length).map((row) => buttonRow(row.map((b) => this.btn(b, root)))),
|
|
39
30
|
];
|
|
@@ -41,7 +32,6 @@ export class LarkPanel extends ChatPanel {
|
|
|
41
32
|
elements.push(footer(note));
|
|
42
33
|
return card(elements);
|
|
43
34
|
}
|
|
44
|
-
/** Open a fresh panel, replacing whichever one this conversation had. */
|
|
45
35
|
async open(key, chatId, root) {
|
|
46
36
|
const sent = await this.deps.api.replyCard(root, this.render(await this.view(key, chatId), root));
|
|
47
37
|
this.remember(key, { chatId, root, messageId: sent.messageId, models: [] });
|
|
@@ -55,10 +45,7 @@ export class LarkPanel extends ChatPanel {
|
|
|
55
45
|
.catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
|
|
56
46
|
}
|
|
57
47
|
// --- 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
|
-
*/
|
|
48
|
+
/** Returns false when the action is not ours. */
|
|
62
49
|
async onAction(action, key, payload, root) {
|
|
63
50
|
return this.dispatch(key, payload, action, () => this.open(key, action.chatId, root));
|
|
64
51
|
}
|
|
@@ -81,11 +68,8 @@ export class LarkPanel extends ChatPanel {
|
|
|
81
68
|
};
|
|
82
69
|
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
70
|
}
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
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
|
-
*/
|
|
71
|
+
/** A panel that outlived its process is re-remembered from the event, so
|
|
72
|
+
* the outcome lands on the card the user is looking at. */
|
|
89
73
|
async onCwdSubmit(key, action, root) {
|
|
90
74
|
if (!this.state(key)) {
|
|
91
75
|
this.remember(key, { chatId: action.chatId, root, messageId: action.messageId, models: [] });
|
|
@@ -1,62 +1,29 @@
|
|
|
1
1
|
// How a reply looks on Lark: an interactive card whose body is a markdown
|
|
2
|
-
// element
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// panel), edit-in-place (the panel's one-message contract, via message.patch),
|
|
8
|
-
// and the muted footer. The costs are known and accepted: the chat list
|
|
9
|
-
// previews a card as「卡片」rather than its first line, and card interactions
|
|
10
|
-
// expire after 30 days — fine for buttons that answer the turn they rode on.
|
|
11
|
-
//
|
|
12
|
-
// The markdown element takes the agent's markdown near-unmodified — Lark's
|
|
13
|
-
// dialect covers emphasis, fences, lists, links and quotes — so unlike
|
|
14
|
-
// Telegram (HTML) and Slack's mrkdwn fallback there is no translation layer,
|
|
15
|
-
// and no escaping either: Lark treats what it cannot parse as literal text
|
|
16
|
-
// rather than rejecting the message (avibe ships the same identity escape).
|
|
2
|
+
// element. Always a card, never plain `text`: buttons, edit-in-place and the
|
|
3
|
+
// muted footer all need one. Costs accepted: the chat list previews a card as
|
|
4
|
+
// 「卡片」, and card interactions expire after 30 days. Lark's markdown dialect
|
|
5
|
+
// takes the agent's markdown near-unmodified and treats what it cannot parse as
|
|
6
|
+
// literal text, so there is no translation and no escaping.
|
|
17
7
|
import { balanceFences, chunkText } from "./chunk.js";
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
20
|
-
* cap in *bytes*; a CJK character spends three, plus JSON string overhead, so
|
|
21
|
-
* 7000 chars keeps the worst all-CJK turn near 21KB with room for the card
|
|
22
|
-
* scaffolding around it.
|
|
23
|
-
*/
|
|
8
|
+
/** The binding limit is the card's 30KB request cap in bytes; a CJK character
|
|
9
|
+
* spends three, so 7000 chars keeps an all-CJK turn near 21KB plus scaffolding. */
|
|
24
10
|
export const LARK_MAX = 7000;
|
|
25
11
|
// Lark truncates a button label around this, mid-word.
|
|
26
12
|
const BUTTON_MAX = 60;
|
|
27
|
-
/**
|
|
28
|
-
* Cut at the last break that fits, then re-balance code fences across the cut:
|
|
29
|
-
* Lark, like Slack, lets an unterminated ``` swallow the rest of the message.
|
|
30
|
-
*/
|
|
13
|
+
/** Fences re-balanced across the cut: an unterminated ``` swallows the rest. */
|
|
31
14
|
export const chunk = (text, max) => balanceFences(chunkText(text, max));
|
|
32
|
-
/** The body of a turn, rendered by Lark's own markdown dialect. */
|
|
33
15
|
export const markdown = (content) => ({ tag: "markdown", content });
|
|
34
|
-
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* from the inline font tag because 2.0's markdown element rejects a
|
|
38
|
-
* `text_color` property. Both verified against the live API (by avibe).
|
|
39
|
-
*
|
|
40
|
-
* Standalone cards only (a quiet turn, an options row): beside a body it
|
|
41
|
-
* would render a blank gap — elements space themselves apart and no spacing
|
|
42
|
-
* knob is documented — so there the footer folds into the body's own element
|
|
43
|
-
* (`withFooter`), where a newline is just a newline.
|
|
44
|
-
*/
|
|
16
|
+
/** Schema 2.0 has no `note`, and its markdown element rejects `text_color`, so
|
|
17
|
+
* the grey is an inline font tag. Standalone cards only: beside a body it
|
|
18
|
+
* renders a blank gap, so there the footer folds into the body (`withFooter`). */
|
|
45
19
|
export const footer = (content) => ({
|
|
46
20
|
tag: "markdown",
|
|
47
21
|
content: `<font color='grey'>${content}</font>`,
|
|
48
22
|
text_size: "notation",
|
|
49
23
|
});
|
|
50
|
-
/**
|
|
51
|
-
*
|
|
52
|
-
* item, a blockquote, a table row. After one of these a single newline is
|
|
53
|
-
* lazy continuation — the footer rendered glued onto "5. 麻辣烫:…" in the
|
|
54
|
-
* field — so the footer needs the blank line that ends the construct. After a
|
|
55
|
-
* plain paragraph the single newline stays, because that is the tight spacing
|
|
56
|
-
* this helper exists for.
|
|
57
|
-
*/
|
|
24
|
+
/** After a list item, a quote or a table row a single newline is lazy
|
|
25
|
+
* continuation and the footer glues onto the line; those need a blank line. */
|
|
58
26
|
const LAZY_LINE = /^\s*(?:[-*+]\s|\d+[.)]\s|>|\|)/;
|
|
59
|
-
/** A body and its footer in one markdown element — grey, gapless. */
|
|
60
27
|
export const withFooter = (body, note) => {
|
|
61
28
|
const last = body.trimEnd().split("\n").at(-1) ?? "";
|
|
62
29
|
const brk = LAZY_LINE.test(last) ? "\n\n" : "\n";
|
|
@@ -69,11 +36,7 @@ export const button = (label, value) => ({
|
|
|
69
36
|
type: "default",
|
|
70
37
|
behaviors: [{ type: "callback", value }],
|
|
71
38
|
});
|
|
72
|
-
/**
|
|
73
|
-
* One row of buttons as a `flow` column set: each column sizes to its button
|
|
74
|
-
* and the row wraps on narrow screens, so a long label beside a short one
|
|
75
|
-
* costs nothing (the same property Slack's actions row has natively).
|
|
76
|
-
*/
|
|
39
|
+
/** A `flow` column set sizes each column to its button and wraps on narrow screens. */
|
|
77
40
|
export const buttonRow = (buttons) => ({
|
|
78
41
|
tag: "column_set",
|
|
79
42
|
flex_mode: "flow",
|
|
@@ -84,7 +47,7 @@ export const card = (elements) => ({
|
|
|
84
47
|
schema: "2.0",
|
|
85
48
|
body: { direction: "vertical", elements },
|
|
86
49
|
});
|
|
87
|
-
/**
|
|
50
|
+
/** Lark's stand-in for a modal. */
|
|
88
51
|
export const formInput = (name, label, placeholder) => ({
|
|
89
52
|
tag: "input",
|
|
90
53
|
name,
|
|
@@ -93,15 +56,8 @@ export const formInput = (name, label, placeholder) => ({
|
|
|
93
56
|
placeholder: { tag: "plain_text", content: placeholder },
|
|
94
57
|
});
|
|
95
58
|
// --- next-step buttons -----------------------------------------------------------
|
|
96
|
-
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* echoes only the value, and `message.get` cannot return a 2.0 card at all
|
|
100
|
-
* (see LarkActionValue), so the value is this platform's "read it back off
|
|
101
|
-
* the message" — still platform state, never adapter memory, so a button
|
|
102
|
-
* survives a restart and the Console's reload the same way (avibe ships the
|
|
103
|
-
* label in the value too: `quick_reply:<label>`).
|
|
104
|
-
*/
|
|
59
|
+
/** The value carries the key and the label: Lark echoes only the value, and
|
|
60
|
+
* `message.get` cannot return a 2.0 card (LarkActionValue), so this is the one
|
|
61
|
+
* place a button's meaning survives a restart. */
|
|
105
62
|
export const OFFER_PREFIX = "sg:";
|
|
106
|
-
/** The card minus its button rows — how taken options are retired. */
|
|
107
63
|
export const withoutButtons = (cardIn) => card(cardIn.body.elements.filter((el) => el.tag !== "column_set" && el.tag !== "button"));
|