openryoko 2026.9.2 → 2026.9.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/dist/package.json +1 -1
- package/dist/src/connectors/discord/engaged-threads.d.ts +24 -0
- package/dist/src/connectors/discord/engaged-threads.d.ts.map +1 -0
- package/dist/src/connectors/discord/engaged-threads.js +28 -0
- package/dist/src/connectors/discord/engaged-threads.js.map +1 -0
- package/dist/src/connectors/discord/index.d.ts +81 -1
- package/dist/src/connectors/discord/index.d.ts.map +1 -1
- package/dist/src/connectors/discord/index.js +326 -25
- package/dist/src/connectors/discord/index.js.map +1 -1
- package/dist/src/connectors/discord/remote.d.ts +5 -1
- package/dist/src/connectors/discord/remote.d.ts.map +1 -1
- package/dist/src/connectors/discord/remote.js +35 -0
- package/dist/src/connectors/discord/remote.js.map +1 -1
- package/dist/src/connectors/discord/respond-policy.d.ts +96 -0
- package/dist/src/connectors/discord/respond-policy.d.ts.map +1 -0
- package/dist/src/connectors/discord/respond-policy.js +85 -0
- package/dist/src/connectors/discord/respond-policy.js.map +1 -0
- package/dist/src/connectors/discord/threads.d.ts +20 -2
- package/dist/src/connectors/discord/threads.d.ts.map +1 -1
- package/dist/src/connectors/discord/threads.js +28 -1
- package/dist/src/connectors/discord/threads.js.map +1 -1
- package/dist/src/connectors/slack/agents-canvas.d.ts +1 -0
- package/dist/src/connectors/slack/agents-canvas.d.ts.map +1 -1
- package/dist/src/connectors/slack/agents-canvas.js +75 -22
- package/dist/src/connectors/slack/agents-canvas.js.map +1 -1
- package/dist/src/connectors/slack/format.d.ts +33 -0
- package/dist/src/connectors/slack/format.d.ts.map +1 -1
- package/dist/src/connectors/slack/format.js +40 -0
- package/dist/src/connectors/slack/format.js.map +1 -1
- package/dist/src/connectors/slack/index.d.ts.map +1 -1
- package/dist/src/connectors/slack/index.js +16 -7
- package/dist/src/connectors/slack/index.js.map +1 -1
- package/dist/src/gateway/server.d.ts.map +1 -1
- package/dist/src/gateway/server.js +1 -0
- package/dist/src/gateway/server.js.map +1 -1
- package/dist/src/sessions/callbacks.d.ts.map +1 -1
- package/dist/src/sessions/callbacks.js +39 -11
- package/dist/src/sessions/callbacks.js.map +1 -1
- package/dist/src/sessions/context.js +20 -4
- package/dist/src/sessions/context.js.map +1 -1
- package/dist/src/shared/types.d.ts +37 -0
- package/dist/src/shared/types.d.ts.map +1 -1
- package/dist/src/workflows/repository-mutex.d.ts +1 -1
- package/dist/web/404.html +1 -1
- package/dist/web/__next.__PAGE__.txt +1 -1
- package/dist/web/__next._full.txt +1 -1
- package/dist/web/__next._tree.txt +1 -1
- package/dist/web/_next/static/chunks/{0los6agi5yfk-.js → 1ydtstxdw369l.js} +1 -1
- package/dist/web/_not-found/__next._full.txt +1 -1
- package/dist/web/_not-found/__next._not-found.__PAGE__.txt +1 -1
- package/dist/web/_not-found/__next._tree.txt +1 -1
- package/dist/web/_not-found.html +1 -1
- package/dist/web/_not-found.txt +1 -1
- package/dist/web/chat/__next._full.txt +1 -1
- package/dist/web/chat/__next._tree.txt +1 -1
- package/dist/web/chat/__next.chat.__PAGE__.txt +1 -1
- package/dist/web/chat.html +1 -1
- package/dist/web/chat.txt +1 -1
- package/dist/web/cron/__next._full.txt +1 -1
- package/dist/web/cron/__next._tree.txt +1 -1
- package/dist/web/cron/__next.cron.__PAGE__.txt +1 -1
- package/dist/web/cron.html +1 -1
- package/dist/web/cron.txt +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/index.txt +1 -1
- package/dist/web/kanban/__next._full.txt +1 -1
- package/dist/web/kanban/__next._tree.txt +1 -1
- package/dist/web/kanban/__next.kanban.__PAGE__.txt +1 -1
- package/dist/web/kanban.html +1 -1
- package/dist/web/kanban.txt +1 -1
- package/dist/web/logs/__next._full.txt +1 -1
- package/dist/web/logs/__next._tree.txt +1 -1
- package/dist/web/logs/__next.logs.__PAGE__.txt +1 -1
- package/dist/web/logs.html +1 -1
- package/dist/web/logs.txt +1 -1
- package/dist/web/org/__next._full.txt +1 -1
- package/dist/web/org/__next._tree.txt +1 -1
- package/dist/web/org/__next.org.__PAGE__.txt +1 -1
- package/dist/web/org.html +1 -1
- package/dist/web/org.txt +1 -1
- package/dist/web/sessions/__next._full.txt +1 -1
- package/dist/web/sessions/__next._tree.txt +1 -1
- package/dist/web/sessions/__next.sessions.__PAGE__.txt +1 -1
- package/dist/web/sessions.html +1 -1
- package/dist/web/sessions.txt +1 -1
- package/dist/web/settings/__next._full.txt +2 -2
- package/dist/web/settings/__next._tree.txt +1 -1
- package/dist/web/settings/__next.settings.__PAGE__.txt +2 -2
- package/dist/web/settings.html +1 -1
- package/dist/web/settings.txt +2 -2
- package/dist/web/skills/__next._full.txt +1 -1
- package/dist/web/skills/__next._tree.txt +1 -1
- package/dist/web/skills/__next.skills.__PAGE__.txt +1 -1
- package/dist/web/skills.html +1 -1
- package/dist/web/skills.txt +1 -1
- package/package.json +1 -1
- package/template/docs/connectors.md +88 -1
- /package/dist/web/_next/static/{Kudq_QcLnSoz7UYDmluYu → vW8A0EhHuyi1vRgv7Qkyg}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{Kudq_QcLnSoz7UYDmluYu → vW8A0EhHuyi1vRgv7Qkyg}/_clientMiddlewareManifest.js +0 -0
- /package/dist/web/_next/static/{Kudq_QcLnSoz7UYDmluYu → vW8A0EhHuyi1vRgv7Qkyg}/_ssgManifest.js +0 -0
package/dist/package.json
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory record of conversation anchors the bot has engaged: threads it
|
|
3
|
+
* replied or reacted in, plus the IDs of eligible messages it sent or
|
|
4
|
+
* reacted to — a Discord public thread created from a message reuses that
|
|
5
|
+
* message's ID, so recording message IDs makes future threads rooted on the
|
|
6
|
+
* bot's own messages count as engaged from their first reply.
|
|
7
|
+
*
|
|
8
|
+
* Backs the `respondTo.engagedThreads` continuation. No eviction, matching
|
|
9
|
+
* the Slack ConversationTracker: engagement is a promise ("mention once,
|
|
10
|
+
* then converse"), and silently evicting it would re-arm the mention
|
|
11
|
+
* requirement mid-conversation. Memory stays bounded by what the connector
|
|
12
|
+
* feeds in (see `DiscordConnector.recordEngagement`): nothing at all unless
|
|
13
|
+
* a consumer exists, and only anchor-eligible IDs — DM and in-thread message
|
|
14
|
+
* IDs can never root a thread and are never recorded. At the ~60-70 bytes a
|
|
15
|
+
* Set entry really costs, even hundreds of thousands of anchors stay in the
|
|
16
|
+
* tens of megabytes. Entries live until the connector is recreated (gateway
|
|
17
|
+
* restart, config save, connector reload).
|
|
18
|
+
*/
|
|
19
|
+
export declare class EngagedThreadTracker {
|
|
20
|
+
private readonly ids;
|
|
21
|
+
record(id: string): void;
|
|
22
|
+
has(id: string): boolean;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=engaged-threads.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engaged-threads.d.ts","sourceRoot":"","sources":["../../../../src/connectors/discord/engaged-threads.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAqB;IAEzC,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAIxB,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO;CAGzB"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory record of conversation anchors the bot has engaged: threads it
|
|
3
|
+
* replied or reacted in, plus the IDs of eligible messages it sent or
|
|
4
|
+
* reacted to — a Discord public thread created from a message reuses that
|
|
5
|
+
* message's ID, so recording message IDs makes future threads rooted on the
|
|
6
|
+
* bot's own messages count as engaged from their first reply.
|
|
7
|
+
*
|
|
8
|
+
* Backs the `respondTo.engagedThreads` continuation. No eviction, matching
|
|
9
|
+
* the Slack ConversationTracker: engagement is a promise ("mention once,
|
|
10
|
+
* then converse"), and silently evicting it would re-arm the mention
|
|
11
|
+
* requirement mid-conversation. Memory stays bounded by what the connector
|
|
12
|
+
* feeds in (see `DiscordConnector.recordEngagement`): nothing at all unless
|
|
13
|
+
* a consumer exists, and only anchor-eligible IDs — DM and in-thread message
|
|
14
|
+
* IDs can never root a thread and are never recorded. At the ~60-70 bytes a
|
|
15
|
+
* Set entry really costs, even hundreds of thousands of anchors stay in the
|
|
16
|
+
* tens of megabytes. Entries live until the connector is recreated (gateway
|
|
17
|
+
* restart, config save, connector reload).
|
|
18
|
+
*/
|
|
19
|
+
export class EngagedThreadTracker {
|
|
20
|
+
ids = new Set();
|
|
21
|
+
record(id) {
|
|
22
|
+
this.ids.add(id);
|
|
23
|
+
}
|
|
24
|
+
has(id) {
|
|
25
|
+
return this.ids.has(id);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=engaged-threads.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engaged-threads.js","sourceRoot":"","sources":["../../../../src/connectors/discord/engaged-threads.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,OAAO,oBAAoB;IACd,GAAG,GAAG,IAAI,GAAG,EAAU,CAAC;IAEzC,MAAM,CAAC,EAAU;QACf,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACnB,CAAC;IAED,GAAG,CAAC,EAAU;QACZ,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAC1B,CAAC;CACF"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Connector, ConnectorCapabilities, ConnectorHealth, IncomingMessage, Target } from "../../shared/types.js";
|
|
1
|
+
import type { Connector, ConnectorCapabilities, ConnectorHealth, DiscordReplyStyle, DiscordRespondToConfig, IncomingMessage, Target } from "../../shared/types.js";
|
|
2
2
|
export interface DiscordConnectorConfig {
|
|
3
3
|
/** Unique instance identifier (e.g. "discord-vox") */
|
|
4
4
|
id?: string;
|
|
@@ -14,6 +14,10 @@ export interface DiscordConnectorConfig {
|
|
|
14
14
|
channelRouting?: Record<string, string>;
|
|
15
15
|
/** If set, this instance proxies all Discord operations through the primary instance at this URL */
|
|
16
16
|
proxyVia?: string;
|
|
17
|
+
/** Deterministic per-scope response gate (DM / channel). See DiscordRespondToConfig. */
|
|
18
|
+
respondTo?: DiscordRespondToConfig;
|
|
19
|
+
/** Where responses land in flat guild channels. Default: "channel". */
|
|
20
|
+
replyStyle?: DiscordReplyStyle;
|
|
17
21
|
}
|
|
18
22
|
export declare class DiscordConnector implements Connector {
|
|
19
23
|
name: string;
|
|
@@ -26,6 +30,16 @@ export declare class DiscordConnector implements Connector {
|
|
|
26
30
|
private status;
|
|
27
31
|
private lastError;
|
|
28
32
|
private typingIntervals;
|
|
33
|
+
private engagedThreads;
|
|
34
|
+
private readonly replyStyle;
|
|
35
|
+
/**
|
|
36
|
+
* Channels where thread creation failed for a channel-level reason
|
|
37
|
+
* (missing permission / access). Both the session keying and the reply
|
|
38
|
+
* destination consult this, so after one failed turn the channel settles
|
|
39
|
+
* into consistent reply-style behavior instead of splitting every turn
|
|
40
|
+
* into a fresh thread-keyed session. In-memory; resets with the connector.
|
|
41
|
+
*/
|
|
42
|
+
private readonly threadStyleFailed;
|
|
29
43
|
constructor(config: DiscordConnectorConfig);
|
|
30
44
|
onMessage(handler: (msg: IncomingMessage) => void): void;
|
|
31
45
|
getEmployee(): string | undefined;
|
|
@@ -36,11 +50,77 @@ export declare class DiscordConnector implements Connector {
|
|
|
36
50
|
reconstructTarget(replyContext: Record<string, unknown> | null | undefined): Target;
|
|
37
51
|
sendMessage(target: Target, text: string): Promise<string | undefined>;
|
|
38
52
|
replyMessage(target: Target, text: string): Promise<string | undefined>;
|
|
53
|
+
/**
|
|
54
|
+
* Send a response in Discord-sized chunks. Only the first chunk carries
|
|
55
|
+
* the reply reference — one visual attachment per response, the rest
|
|
56
|
+
* reads as its continuation. Engagement is recorded per delivered chunk,
|
|
57
|
+
* so a partial send still counts. Failures are returned, not thrown, so
|
|
58
|
+
* the caller can see how far the send got.
|
|
59
|
+
*/
|
|
60
|
+
private sendChunks;
|
|
61
|
+
/**
|
|
62
|
+
* Where a response physically lands. Threads and DMs are already precise
|
|
63
|
+
* destinations and ignore replyStyle. In flat guild channels: "reply"
|
|
64
|
+
* attaches the response to the triggering message, "thread" opens (or
|
|
65
|
+
* reuses) the thread rooted on it, and "channel" keeps the legacy plain
|
|
66
|
+
* send. The thread path falls back to "reply" when the channel can't host
|
|
67
|
+
* message threads (voice/stage text chat) or when Discord refuses — a
|
|
68
|
+
* channel-level refusal (missing permission/access) is remembered so the
|
|
69
|
+
* session keying in handleMessage stops splitting turns into thread keys.
|
|
70
|
+
*/
|
|
71
|
+
private resolveReplyDestination;
|
|
39
72
|
editMessage(target: Target, text: string): Promise<void>;
|
|
40
73
|
addReaction(target: Target, emoji: string): Promise<void>;
|
|
41
74
|
removeReaction(target: Target, emoji: string): Promise<void>;
|
|
42
75
|
setTypingStatus(channelId: string, _threadTs: string | undefined, status: string): Promise<void>;
|
|
43
76
|
private handleMessage;
|
|
77
|
+
/**
|
|
78
|
+
* True when this flat-channel message's session should be keyed to the
|
|
79
|
+
* thread the reply will open (replyStyle=thread). Mirrors exactly the
|
|
80
|
+
* conditions under which resolveReplyDestination will actually attempt a
|
|
81
|
+
* thread, so the session key and the reply destination never diverge:
|
|
82
|
+
* channels that can't host message threads, channels where the bot lacks
|
|
83
|
+
* the thread permissions right now (checked per turn against the live
|
|
84
|
+
* permission cache), and channels where creation already failed at the
|
|
85
|
+
* channel level all stay channel-keyed from the first turn.
|
|
86
|
+
*/
|
|
87
|
+
private threadSessionEligible;
|
|
88
|
+
/**
|
|
89
|
+
* Pre-flight permission check for thread style, evaluated against the
|
|
90
|
+
* cached guild permissions so it costs no request. Optimistic when the
|
|
91
|
+
* answer is unknowable (no permissionsFor on the channel shape, member
|
|
92
|
+
* not cached) — the failure path in replyMessage self-heals those.
|
|
93
|
+
*/
|
|
94
|
+
private canOpenThreads;
|
|
95
|
+
/**
|
|
96
|
+
* Track engagement for the `respondTo.engagedThreads` continuation. Only
|
|
97
|
+
* anchor-eligible IDs whose engagement can actually be consumed are kept:
|
|
98
|
+
* the thread itself, or — in flat guild channels — the acted-on message,
|
|
99
|
+
* since a public thread created from a message reuses its ID. DM and
|
|
100
|
+
* in-thread message IDs can never root a thread and are never recorded,
|
|
101
|
+
* which keeps the unbounded tracker's real footprint small.
|
|
102
|
+
*/
|
|
103
|
+
private recordEngagement;
|
|
104
|
+
/**
|
|
105
|
+
* True when engagement recorded under this id can ever be read back. The
|
|
106
|
+
* local gate consults the tracker only when the channel scope is
|
|
107
|
+
* mention-gated with engagedThreads on (DMs have no threads, so dm=mention
|
|
108
|
+
* alone consumes nothing). The routed path consults it for messages whose
|
|
109
|
+
* channel id — or, for threads, parent channel id — is a channelRouting
|
|
110
|
+
* key; for a flat-channel anchor the future thread's parent is the channel
|
|
111
|
+
* itself, so its route counts too.
|
|
112
|
+
*/
|
|
113
|
+
private consumesEngagement;
|
|
114
|
+
/**
|
|
115
|
+
* Resolve who the message addresses. Reply attribution applies only to
|
|
116
|
+
* actual replies (`MessageType.Reply`) — a crosspost or forward also
|
|
117
|
+
* carries a `reference` but addresses nobody. `mentions.repliedUser` comes
|
|
118
|
+
* from the gateway's resolved reference and works whether or not the reply
|
|
119
|
+
* pinged; when Discord omitted the resolution and `allowFetch` is set,
|
|
120
|
+
* fall back to one fetch of the referenced message. A deleted reference
|
|
121
|
+
* stays undefined — no addressee.
|
|
122
|
+
*/
|
|
123
|
+
private resolveAddressing;
|
|
44
124
|
/** Forward a message to a remote Jinn instance via HTTP */
|
|
45
125
|
private proxyToRemote;
|
|
46
126
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/connectors/discord/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/connectors/discord/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { Client, GatewayIntentBits, Partials, } from "discord.js";
|
|
1
|
+
import { Client, GatewayIntentBits, MessageType, Partials, PermissionFlagsBits, } from "discord.js";
|
|
2
2
|
import { logger } from "../../shared/logger.js";
|
|
3
3
|
import { TMP_DIR } from "../../shared/paths.js";
|
|
4
4
|
import { formatResponse, downloadAttachment } from "./format.js";
|
|
5
|
-
import { deriveSessionKey, buildReplyContext, isOldMessage } from "./threads.js";
|
|
5
|
+
import { deriveSessionKey, buildReplyContext, isOldMessage, threadNameFor, supportsMessageThreads, } from "./threads.js";
|
|
6
|
+
import { evaluateRespondPolicy, resolveRespondMode, wasBotAddressed, addressesOnlyOthers, } from "./respond-policy.js";
|
|
7
|
+
import { EngagedThreadTracker } from "./engaged-threads.js";
|
|
6
8
|
export class DiscordConnector {
|
|
7
9
|
name;
|
|
8
10
|
instanceId;
|
|
@@ -14,10 +16,25 @@ export class DiscordConnector {
|
|
|
14
16
|
status = "starting";
|
|
15
17
|
lastError = null;
|
|
16
18
|
typingIntervals = new Map();
|
|
19
|
+
engagedThreads = new EngagedThreadTracker();
|
|
20
|
+
replyStyle;
|
|
21
|
+
/**
|
|
22
|
+
* Channels where thread creation failed for a channel-level reason
|
|
23
|
+
* (missing permission / access). Both the session keying and the reply
|
|
24
|
+
* destination consult this, so after one failed turn the channel settles
|
|
25
|
+
* into consistent reply-style behavior instead of splitting every turn
|
|
26
|
+
* into a fresh thread-keyed session. In-memory; resets with the connector.
|
|
27
|
+
*/
|
|
28
|
+
threadStyleFailed = new Set();
|
|
17
29
|
constructor(config) {
|
|
18
30
|
this.name = config.id || "discord";
|
|
19
31
|
this.instanceId = config.id || "discord";
|
|
20
32
|
this.config = config;
|
|
33
|
+
// Hand-edited YAML: an invalid value degrades to the legacy behavior.
|
|
34
|
+
this.replyStyle =
|
|
35
|
+
config.replyStyle === "reply" || config.replyStyle === "thread"
|
|
36
|
+
? config.replyStyle
|
|
37
|
+
: "channel";
|
|
21
38
|
// Normalize Discord IDs to strings (YAML may parse large snowflake IDs as numbers)
|
|
22
39
|
if (this.config.guildId)
|
|
23
40
|
this.config.guildId = String(this.config.guildId);
|
|
@@ -52,6 +69,9 @@ export class DiscordConnector {
|
|
|
52
69
|
this.client.on("ready", () => {
|
|
53
70
|
logger.info(`Discord connector ready as ${this.client.user?.tag}`);
|
|
54
71
|
this.status = "running";
|
|
72
|
+
// Permissions may have changed while disconnected — let thread style
|
|
73
|
+
// re-attempt channels it had given up on.
|
|
74
|
+
this.threadStyleFailed.clear();
|
|
55
75
|
});
|
|
56
76
|
this.client.on("messageCreate", async (message) => {
|
|
57
77
|
try {
|
|
@@ -101,13 +121,10 @@ export class DiscordConnector {
|
|
|
101
121
|
const channel = await this.client.channels.fetch(target.channel);
|
|
102
122
|
if (!channel || !channel.isTextBased())
|
|
103
123
|
return;
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
lastId = sent.id;
|
|
109
|
-
}
|
|
110
|
-
return lastId;
|
|
124
|
+
const result = await this.sendChunks({ channel: channel }, text);
|
|
125
|
+
if (result.error !== undefined)
|
|
126
|
+
throw result.error;
|
|
127
|
+
return result.lastId;
|
|
111
128
|
}
|
|
112
129
|
catch (err) {
|
|
113
130
|
logger.error(`Discord sendMessage error: ${err instanceof Error ? err.message : err}`);
|
|
@@ -118,18 +135,135 @@ export class DiscordConnector {
|
|
|
118
135
|
const channel = await this.client.channels.fetch(target.thread ?? target.channel);
|
|
119
136
|
if (!channel || !channel.isTextBased())
|
|
120
137
|
return;
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
138
|
+
const destination = await this.resolveReplyDestination(channel, target);
|
|
139
|
+
const result = await this.sendChunks(destination, text);
|
|
140
|
+
// A thread that accepts nothing (locked archive, missing
|
|
141
|
+
// SEND_MESSAGES_IN_THREADS, private-thread access) must not swallow
|
|
142
|
+
// the response: if not a single chunk landed in any thread — the one
|
|
143
|
+
// thread style just opened, or the thread the message arrived in —
|
|
144
|
+
// retry as a reply in the parent channel. A partial send stays where
|
|
145
|
+
// it is; resending would duplicate the delivered chunks.
|
|
146
|
+
if (result.error !== undefined && result.sentCount === 0 && destination.channel.isThread()) {
|
|
147
|
+
// In the thread-style path `channel` is the flat parent we fetched
|
|
148
|
+
// to resolve the thread; for a direct thread target the parent
|
|
149
|
+
// comes from the thread itself.
|
|
150
|
+
const parent = destination.viaThreadStyle
|
|
151
|
+
? channel
|
|
152
|
+
: await this.client.channels
|
|
153
|
+
.fetch(destination.channel.parentId ?? "")
|
|
154
|
+
.catch(() => null);
|
|
155
|
+
// A permission-shaped refusal will keep failing — remember it so
|
|
156
|
+
// thread style stops opening threads nobody can post in. The
|
|
157
|
+
// pre-flight permission check catches most of these; this covers
|
|
158
|
+
// API-level denials that slip through.
|
|
159
|
+
const code = result.error.code;
|
|
160
|
+
if (code === 50013 || code === 50001) {
|
|
161
|
+
const parentId = destination.viaThreadStyle
|
|
162
|
+
? channel.id
|
|
163
|
+
: destination.channel.parentId;
|
|
164
|
+
if (parentId)
|
|
165
|
+
this.threadStyleFailed.add(parentId);
|
|
166
|
+
}
|
|
167
|
+
logger.warn(`[discord] sending into thread ${destination.channel.id} failed (${result.error instanceof Error ? result.error.message : result.error}) — falling back to a reply in the parent channel`);
|
|
168
|
+
if (parent && parent.isTextBased() && !parent.isThread()) {
|
|
169
|
+
const retry = await this.sendChunks(
|
|
170
|
+
// A reply reference must live in the parent channel — in-thread
|
|
171
|
+
// message IDs are invisible there. A message-rooted thread
|
|
172
|
+
// shares its starter message's ID, so referencing the thread ID
|
|
173
|
+
// attaches the fallback to the conversation's root (and merely
|
|
174
|
+
// degrades to a plain post for channel-rooted threads, thanks
|
|
175
|
+
// to failIfNotExists: false).
|
|
176
|
+
{ channel: parent, replyTo: destination.channel.id }, text);
|
|
177
|
+
if (retry.error !== undefined)
|
|
178
|
+
throw retry.error;
|
|
179
|
+
return retry.lastId;
|
|
180
|
+
}
|
|
181
|
+
throw result.error;
|
|
126
182
|
}
|
|
127
|
-
|
|
183
|
+
if (result.error !== undefined && result.sentCount === 0)
|
|
184
|
+
throw result.error;
|
|
185
|
+
if (result.error !== undefined) {
|
|
186
|
+
logger.error(`Discord replyMessage partial send (${result.sentCount} chunk(s) delivered): ${result.error instanceof Error ? result.error.message : result.error}`);
|
|
187
|
+
}
|
|
188
|
+
return result.lastId;
|
|
128
189
|
}
|
|
129
190
|
catch (err) {
|
|
130
191
|
logger.error(`Discord replyMessage error: ${err instanceof Error ? err.message : err}`);
|
|
131
192
|
}
|
|
132
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Send a response in Discord-sized chunks. Only the first chunk carries
|
|
196
|
+
* the reply reference — one visual attachment per response, the rest
|
|
197
|
+
* reads as its continuation. Engagement is recorded per delivered chunk,
|
|
198
|
+
* so a partial send still counts. Failures are returned, not thrown, so
|
|
199
|
+
* the caller can see how far the send got.
|
|
200
|
+
*/
|
|
201
|
+
async sendChunks(destination, text) {
|
|
202
|
+
const chunks = formatResponse(text);
|
|
203
|
+
let lastId;
|
|
204
|
+
let sentCount = 0;
|
|
205
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
206
|
+
try {
|
|
207
|
+
const sent = destination.replyTo !== undefined && index === 0
|
|
208
|
+
? await destination.channel.send({
|
|
209
|
+
content: chunk,
|
|
210
|
+
reply: { messageReference: destination.replyTo, failIfNotExists: false },
|
|
211
|
+
})
|
|
212
|
+
: await destination.channel.send(chunk);
|
|
213
|
+
lastId = sent.id;
|
|
214
|
+
sentCount += 1;
|
|
215
|
+
this.recordEngagement(destination.channel, sent.id);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
return { lastId, sentCount, error };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { lastId, sentCount };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Where a response physically lands. Threads and DMs are already precise
|
|
225
|
+
* destinations and ignore replyStyle. In flat guild channels: "reply"
|
|
226
|
+
* attaches the response to the triggering message, "thread" opens (or
|
|
227
|
+
* reuses) the thread rooted on it, and "channel" keeps the legacy plain
|
|
228
|
+
* send. The thread path falls back to "reply" when the channel can't host
|
|
229
|
+
* message threads (voice/stage text chat) or when Discord refuses — a
|
|
230
|
+
* channel-level refusal (missing permission/access) is remembered so the
|
|
231
|
+
* session keying in handleMessage stops splitting turns into thread keys.
|
|
232
|
+
*/
|
|
233
|
+
async resolveReplyDestination(channel, target) {
|
|
234
|
+
if (channel.isThread() || channel.isDMBased())
|
|
235
|
+
return { channel };
|
|
236
|
+
if (!target.messageTs || this.replyStyle === "channel")
|
|
237
|
+
return { channel };
|
|
238
|
+
if (this.replyStyle === "reply" ||
|
|
239
|
+
!supportsMessageThreads(channel) ||
|
|
240
|
+
this.threadStyleFailed.has(channel.id) ||
|
|
241
|
+
!this.canOpenThreads(channel)) {
|
|
242
|
+
return { channel, replyTo: target.messageTs };
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
// A message's thread shares its ID — fetch it directly instead of
|
|
246
|
+
// trusting the starter's cache (`Message#thread` misses archived
|
|
247
|
+
// threads, which aren't synced on startup).
|
|
248
|
+
const existing = await this.client.channels.fetch(target.messageTs).catch(() => null);
|
|
249
|
+
if (existing?.isThread()) {
|
|
250
|
+
return { channel: existing, viaThreadStyle: true };
|
|
251
|
+
}
|
|
252
|
+
const starter = await channel.messages.fetch(target.messageTs);
|
|
253
|
+
const thread = await starter.startThread({ name: threadNameFor(starter.content) });
|
|
254
|
+
return { channel: thread, viaThreadStyle: true };
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
const code = err.code;
|
|
258
|
+
// 50013 Missing Permissions / 50001 Missing Access — channel-level,
|
|
259
|
+
// will keep failing; remember it. Anything else (deleted starter…)
|
|
260
|
+
// is message-level and the next turn starts clean.
|
|
261
|
+
if (code === 50013 || code === 50001)
|
|
262
|
+
this.threadStyleFailed.add(channel.id);
|
|
263
|
+
logger.warn(`[discord] could not open a thread on message ${target.messageTs} (${err instanceof Error ? err.message : err}) — falling back to a reply`);
|
|
264
|
+
return { channel, replyTo: target.messageTs };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
133
267
|
async editMessage(target, text) {
|
|
134
268
|
try {
|
|
135
269
|
if (!target.messageTs)
|
|
@@ -153,6 +287,7 @@ export class DiscordConnector {
|
|
|
153
287
|
return;
|
|
154
288
|
const msg = await channel.messages.fetch(target.messageTs);
|
|
155
289
|
await msg.react(emoji);
|
|
290
|
+
this.recordEngagement(channel, target.messageTs);
|
|
156
291
|
}
|
|
157
292
|
catch {
|
|
158
293
|
// non-fatal
|
|
@@ -199,8 +334,9 @@ export class DiscordConnector {
|
|
|
199
334
|
}
|
|
200
335
|
}
|
|
201
336
|
async handleMessage(message) {
|
|
202
|
-
// Ignore bots (including self)
|
|
203
|
-
|
|
337
|
+
// Ignore bots (including self), webhooks, and Discord-generated system
|
|
338
|
+
// messages (joins, pins, thread-created…) — none of these are user turns.
|
|
339
|
+
if (message.author.bot || message.system || message.webhookId)
|
|
204
340
|
return;
|
|
205
341
|
logger.debug(`Discord message from ${message.author.username} in channel ${message.channel.id}`);
|
|
206
342
|
// Ignore old messages on boot
|
|
@@ -210,22 +346,74 @@ export class DiscordConnector {
|
|
|
210
346
|
// Guild restriction
|
|
211
347
|
if (this.config.guildId && message.guild?.id !== this.config.guildId)
|
|
212
348
|
return;
|
|
213
|
-
// Channel routing — proxy messages to remote instances
|
|
214
|
-
|
|
349
|
+
// Channel routing — proxy messages to remote instances. Addressing and
|
|
350
|
+
// thread engagement are resolved here and forwarded: only this instance
|
|
351
|
+
// sees the Discord gateway, and proxied sends run through this
|
|
352
|
+
// connector, so its tracker is the source of truth for engagement.
|
|
353
|
+
// Threads inherit their parent channel's route: a thread routes by its
|
|
354
|
+
// own id when explicitly configured, else by the channel it lives in —
|
|
355
|
+
// without this, a thread opened in a routed channel (replyStyle=thread
|
|
356
|
+
// creates one per conversation) would silently fall back to local
|
|
357
|
+
// handling.
|
|
358
|
+
const parentChannelId = message.channel.isThread()
|
|
359
|
+
? message.channel.parentId ?? undefined
|
|
360
|
+
: undefined;
|
|
361
|
+
const routeTarget = this.config.channelRouting?.[message.channel.id] ??
|
|
362
|
+
(parentChannelId !== undefined ? this.config.channelRouting?.[parentChannelId] : undefined);
|
|
215
363
|
if (routeTarget) {
|
|
216
364
|
logger.debug(`Routing Discord message from channel ${message.channel.id} to ${routeTarget}`);
|
|
217
|
-
|
|
365
|
+
// References resolve only outside DMs here: the remote's dm policy is
|
|
366
|
+
// unknown, and a routed-DM reply matters only under its dm=mention —
|
|
367
|
+
// too rare to spend a REST fetch on every routed DM reply.
|
|
368
|
+
const addressing = await this.resolveAddressing(message, {
|
|
369
|
+
allowFetch: !message.channel.isDMBased(),
|
|
370
|
+
});
|
|
371
|
+
await this.proxyToRemote(routeTarget, message, addressing);
|
|
218
372
|
return;
|
|
219
373
|
}
|
|
220
|
-
// Channel restriction — only respond in a specific channel
|
|
221
|
-
|
|
374
|
+
// Channel restriction — only respond in a specific channel, including
|
|
375
|
+
// the threads that live in it (+ DMs always allowed)
|
|
376
|
+
if (this.config.channelId &&
|
|
377
|
+
message.channel.id !== this.config.channelId &&
|
|
378
|
+
parentChannelId !== this.config.channelId &&
|
|
379
|
+
!message.channel.isDMBased())
|
|
222
380
|
return;
|
|
223
381
|
// User allowlist
|
|
224
382
|
if (this.allowedUserIds.size > 0 && !this.allowedUserIds.has(message.author.id))
|
|
225
383
|
return;
|
|
384
|
+
// Deterministic respondTo gate — the Discord port of the Slack gate.
|
|
385
|
+
// Addressing resolves after the cheap filters above, so filtered
|
|
386
|
+
// messages never cost a reference fetch; in DMs the fetch only matters
|
|
387
|
+
// under dm=mention (the sibling rule below never applies to DMs).
|
|
388
|
+
// botUserId is set once the client is ready (messageCreate never fires
|
|
389
|
+
// earlier); if it were somehow missing, mention scopes fail closed.
|
|
390
|
+
const isDM = message.channel.isDMBased();
|
|
391
|
+
const addressing = await this.resolveAddressing(message, {
|
|
392
|
+
allowFetch: !isDM || resolveRespondMode(this.config.respondTo, "dm") === "mention",
|
|
393
|
+
});
|
|
394
|
+
const respondDecision = evaluateRespondPolicy({
|
|
395
|
+
config: this.config.respondTo,
|
|
396
|
+
isDM,
|
|
397
|
+
wasMentioned: wasBotAddressed(addressing),
|
|
398
|
+
isEngagedThread: message.channel.isThread() && this.engagedThreads.has(message.channel.id),
|
|
399
|
+
});
|
|
400
|
+
if (!respondDecision.allow) {
|
|
401
|
+
logger.info(`[discord] respondTo gate → silent (${respondDecision.reason}) for message ${message.id}`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// Cross-user traffic in shared channels: a message that @-mentions or
|
|
405
|
+
// replies to somebody else (and not us) is addressed to them, not the
|
|
406
|
+
// bot — stay silent regardless of respondTo mode or thread engagement,
|
|
407
|
+
// mirroring the Slack connector's sibling-mention rule.
|
|
408
|
+
if (!isDM && addressesOnlyOthers(addressing)) {
|
|
409
|
+
logger.info(`[discord] message addresses other user(s) — staying silent for message ${message.id}`);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
226
412
|
if (!this.handler)
|
|
227
413
|
return;
|
|
228
|
-
const sessionKey = deriveSessionKey(message, this.instanceId
|
|
414
|
+
const sessionKey = deriveSessionKey(message, this.instanceId, {
|
|
415
|
+
threadPerMessage: this.threadSessionEligible(message),
|
|
416
|
+
});
|
|
229
417
|
const replyContext = buildReplyContext(message);
|
|
230
418
|
// Download attachments
|
|
231
419
|
const attachments = await Promise.all(Array.from(message.attachments.values()).map(async (att) => {
|
|
@@ -265,8 +453,108 @@ export class DiscordConnector {
|
|
|
265
453
|
};
|
|
266
454
|
this.handler(incomingMessage);
|
|
267
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* True when this flat-channel message's session should be keyed to the
|
|
458
|
+
* thread the reply will open (replyStyle=thread). Mirrors exactly the
|
|
459
|
+
* conditions under which resolveReplyDestination will actually attempt a
|
|
460
|
+
* thread, so the session key and the reply destination never diverge:
|
|
461
|
+
* channels that can't host message threads, channels where the bot lacks
|
|
462
|
+
* the thread permissions right now (checked per turn against the live
|
|
463
|
+
* permission cache), and channels where creation already failed at the
|
|
464
|
+
* channel level all stay channel-keyed from the first turn.
|
|
465
|
+
*/
|
|
466
|
+
threadSessionEligible(message) {
|
|
467
|
+
return (this.replyStyle === "thread" &&
|
|
468
|
+
!message.channel.isDMBased() &&
|
|
469
|
+
!message.channel.isThread() &&
|
|
470
|
+
supportsMessageThreads(message.channel) &&
|
|
471
|
+
!this.threadStyleFailed.has(message.channel.id) &&
|
|
472
|
+
this.canOpenThreads(message.channel));
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Pre-flight permission check for thread style, evaluated against the
|
|
476
|
+
* cached guild permissions so it costs no request. Optimistic when the
|
|
477
|
+
* answer is unknowable (no permissionsFor on the channel shape, member
|
|
478
|
+
* not cached) — the failure path in replyMessage self-heals those.
|
|
479
|
+
*/
|
|
480
|
+
canOpenThreads(channel) {
|
|
481
|
+
const me = this.client.user;
|
|
482
|
+
if (!me || typeof channel.permissionsFor !== "function")
|
|
483
|
+
return true;
|
|
484
|
+
const perms = channel.permissionsFor(me);
|
|
485
|
+
if (!perms)
|
|
486
|
+
return true;
|
|
487
|
+
return (perms.has(PermissionFlagsBits.CreatePublicThreads) &&
|
|
488
|
+
perms.has(PermissionFlagsBits.SendMessagesInThreads));
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Track engagement for the `respondTo.engagedThreads` continuation. Only
|
|
492
|
+
* anchor-eligible IDs whose engagement can actually be consumed are kept:
|
|
493
|
+
* the thread itself, or — in flat guild channels — the acted-on message,
|
|
494
|
+
* since a public thread created from a message reuses its ID. DM and
|
|
495
|
+
* in-thread message IDs can never root a thread and are never recorded,
|
|
496
|
+
* which keeps the unbounded tracker's real footprint small.
|
|
497
|
+
*/
|
|
498
|
+
recordEngagement(channel, anchorMessageId) {
|
|
499
|
+
if (channel.isThread()) {
|
|
500
|
+
if (this.consumesEngagement(channel.id, channel.parentId ?? undefined)) {
|
|
501
|
+
this.engagedThreads.record(channel.id);
|
|
502
|
+
}
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (anchorMessageId &&
|
|
506
|
+
!channel.isDMBased() &&
|
|
507
|
+
this.consumesEngagement(anchorMessageId, channel.id)) {
|
|
508
|
+
this.engagedThreads.record(anchorMessageId);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* True when engagement recorded under this id can ever be read back. The
|
|
513
|
+
* local gate consults the tracker only when the channel scope is
|
|
514
|
+
* mention-gated with engagedThreads on (DMs have no threads, so dm=mention
|
|
515
|
+
* alone consumes nothing). The routed path consults it for messages whose
|
|
516
|
+
* channel id — or, for threads, parent channel id — is a channelRouting
|
|
517
|
+
* key; for a flat-channel anchor the future thread's parent is the channel
|
|
518
|
+
* itself, so its route counts too.
|
|
519
|
+
*/
|
|
520
|
+
consumesEngagement(id, parentChannelId) {
|
|
521
|
+
if (resolveRespondMode(this.config.respondTo, "channel") === "mention" &&
|
|
522
|
+
this.config.respondTo?.engagedThreads !== false) {
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
if (this.config.channelRouting?.[id] !== undefined)
|
|
526
|
+
return true;
|
|
527
|
+
return parentChannelId !== undefined && this.config.channelRouting?.[parentChannelId] !== undefined;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Resolve who the message addresses. Reply attribution applies only to
|
|
531
|
+
* actual replies (`MessageType.Reply`) — a crosspost or forward also
|
|
532
|
+
* carries a `reference` but addresses nobody. `mentions.repliedUser` comes
|
|
533
|
+
* from the gateway's resolved reference and works whether or not the reply
|
|
534
|
+
* pinged; when Discord omitted the resolution and `allowFetch` is set,
|
|
535
|
+
* fall back to one fetch of the referenced message. A deleted reference
|
|
536
|
+
* stays undefined — no addressee.
|
|
537
|
+
*/
|
|
538
|
+
async resolveAddressing(message, opts) {
|
|
539
|
+
const isReply = message.type === MessageType.Reply;
|
|
540
|
+
let repliedToUserId = isReply ? message.mentions.repliedUser?.id : undefined;
|
|
541
|
+
if (isReply && repliedToUserId === undefined && opts.allowFetch && message.reference?.messageId) {
|
|
542
|
+
try {
|
|
543
|
+
const referenced = await message.fetchReference();
|
|
544
|
+
repliedToUserId = referenced.author?.id;
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
// Referenced message deleted or inaccessible.
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
botUserId: this.client.user?.id,
|
|
552
|
+
mentionedUserIds: new Set(message.mentions.users.keys()),
|
|
553
|
+
repliedToUserId,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
268
556
|
/** Forward a message to a remote Jinn instance via HTTP */
|
|
269
|
-
async proxyToRemote(remoteUrl, message) {
|
|
557
|
+
async proxyToRemote(remoteUrl, message, addressing) {
|
|
270
558
|
try {
|
|
271
559
|
const attachments = Array.from(message.attachments.values()).map((att) => ({
|
|
272
560
|
name: att.name,
|
|
@@ -274,7 +562,11 @@ export class DiscordConnector {
|
|
|
274
562
|
mimeType: att.contentType ?? "application/octet-stream",
|
|
275
563
|
}));
|
|
276
564
|
const payload = {
|
|
277
|
-
|
|
565
|
+
// The primary's replyStyle renders every proxied send, so its
|
|
566
|
+
// thread-per-message session mapping applies to routed channels too.
|
|
567
|
+
sessionKey: deriveSessionKey(message, undefined, {
|
|
568
|
+
threadPerMessage: this.threadSessionEligible(message),
|
|
569
|
+
}),
|
|
278
570
|
channel: message.channel.id,
|
|
279
571
|
thread: message.channel.isThread() ? message.channel.id : undefined,
|
|
280
572
|
user: message.author.username,
|
|
@@ -289,6 +581,15 @@ export class DiscordConnector {
|
|
|
289
581
|
: "dm",
|
|
290
582
|
guildId: message.guild?.id ?? null,
|
|
291
583
|
isDM: message.channel.isDMBased(),
|
|
584
|
+
// Precomputed here (only this instance knows the bot user,
|
|
585
|
+
// resolves references, and tracks engagement) so the receiving
|
|
586
|
+
// instance can apply its own respondTo gate without another
|
|
587
|
+
// Discord round-trip.
|
|
588
|
+
...{
|
|
589
|
+
wasBotAddressed: wasBotAddressed(addressing),
|
|
590
|
+
addressesOnlyOthers: addressesOnlyOthers(addressing),
|
|
591
|
+
isEngagedThread: message.channel.isThread() && this.engagedThreads.has(message.channel.id),
|
|
592
|
+
},
|
|
292
593
|
},
|
|
293
594
|
};
|
|
294
595
|
const res = await fetch(`${remoteUrl.replace(/\/+$/, "")}/api/connectors/discord/incoming`, {
|