openryoko 2026.9.3 → 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.
Files changed (87) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/connectors/discord/engaged-threads.d.ts +24 -0
  3. package/dist/src/connectors/discord/engaged-threads.d.ts.map +1 -0
  4. package/dist/src/connectors/discord/engaged-threads.js +28 -0
  5. package/dist/src/connectors/discord/engaged-threads.js.map +1 -0
  6. package/dist/src/connectors/discord/index.d.ts +81 -1
  7. package/dist/src/connectors/discord/index.d.ts.map +1 -1
  8. package/dist/src/connectors/discord/index.js +326 -25
  9. package/dist/src/connectors/discord/index.js.map +1 -1
  10. package/dist/src/connectors/discord/remote.d.ts +5 -1
  11. package/dist/src/connectors/discord/remote.d.ts.map +1 -1
  12. package/dist/src/connectors/discord/remote.js +35 -0
  13. package/dist/src/connectors/discord/remote.js.map +1 -1
  14. package/dist/src/connectors/discord/respond-policy.d.ts +96 -0
  15. package/dist/src/connectors/discord/respond-policy.d.ts.map +1 -0
  16. package/dist/src/connectors/discord/respond-policy.js +85 -0
  17. package/dist/src/connectors/discord/respond-policy.js.map +1 -0
  18. package/dist/src/connectors/discord/threads.d.ts +20 -2
  19. package/dist/src/connectors/discord/threads.d.ts.map +1 -1
  20. package/dist/src/connectors/discord/threads.js +28 -1
  21. package/dist/src/connectors/discord/threads.js.map +1 -1
  22. package/dist/src/connectors/slack/agents-canvas.d.ts +1 -0
  23. package/dist/src/connectors/slack/agents-canvas.d.ts.map +1 -1
  24. package/dist/src/connectors/slack/agents-canvas.js +75 -22
  25. package/dist/src/connectors/slack/agents-canvas.js.map +1 -1
  26. package/dist/src/gateway/server.d.ts.map +1 -1
  27. package/dist/src/gateway/server.js +1 -0
  28. package/dist/src/gateway/server.js.map +1 -1
  29. package/dist/src/shared/types.d.ts +37 -0
  30. package/dist/src/shared/types.d.ts.map +1 -1
  31. package/dist/web/404.html +1 -1
  32. package/dist/web/__next.__PAGE__.txt +1 -1
  33. package/dist/web/__next._full.txt +1 -1
  34. package/dist/web/__next._tree.txt +1 -1
  35. package/dist/web/_next/static/chunks/{0los6agi5yfk-.js → 1ydtstxdw369l.js} +1 -1
  36. package/dist/web/_not-found/__next._full.txt +1 -1
  37. package/dist/web/_not-found/__next._not-found.__PAGE__.txt +1 -1
  38. package/dist/web/_not-found/__next._tree.txt +1 -1
  39. package/dist/web/_not-found.html +1 -1
  40. package/dist/web/_not-found.txt +1 -1
  41. package/dist/web/chat/__next._full.txt +1 -1
  42. package/dist/web/chat/__next._tree.txt +1 -1
  43. package/dist/web/chat/__next.chat.__PAGE__.txt +1 -1
  44. package/dist/web/chat.html +1 -1
  45. package/dist/web/chat.txt +1 -1
  46. package/dist/web/cron/__next._full.txt +1 -1
  47. package/dist/web/cron/__next._tree.txt +1 -1
  48. package/dist/web/cron/__next.cron.__PAGE__.txt +1 -1
  49. package/dist/web/cron.html +1 -1
  50. package/dist/web/cron.txt +1 -1
  51. package/dist/web/index.html +1 -1
  52. package/dist/web/index.txt +1 -1
  53. package/dist/web/kanban/__next._full.txt +1 -1
  54. package/dist/web/kanban/__next._tree.txt +1 -1
  55. package/dist/web/kanban/__next.kanban.__PAGE__.txt +1 -1
  56. package/dist/web/kanban.html +1 -1
  57. package/dist/web/kanban.txt +1 -1
  58. package/dist/web/logs/__next._full.txt +1 -1
  59. package/dist/web/logs/__next._tree.txt +1 -1
  60. package/dist/web/logs/__next.logs.__PAGE__.txt +1 -1
  61. package/dist/web/logs.html +1 -1
  62. package/dist/web/logs.txt +1 -1
  63. package/dist/web/org/__next._full.txt +1 -1
  64. package/dist/web/org/__next._tree.txt +1 -1
  65. package/dist/web/org/__next.org.__PAGE__.txt +1 -1
  66. package/dist/web/org.html +1 -1
  67. package/dist/web/org.txt +1 -1
  68. package/dist/web/sessions/__next._full.txt +1 -1
  69. package/dist/web/sessions/__next._tree.txt +1 -1
  70. package/dist/web/sessions/__next.sessions.__PAGE__.txt +1 -1
  71. package/dist/web/sessions.html +1 -1
  72. package/dist/web/sessions.txt +1 -1
  73. package/dist/web/settings/__next._full.txt +2 -2
  74. package/dist/web/settings/__next._tree.txt +1 -1
  75. package/dist/web/settings/__next.settings.__PAGE__.txt +2 -2
  76. package/dist/web/settings.html +1 -1
  77. package/dist/web/settings.txt +2 -2
  78. package/dist/web/skills/__next._full.txt +1 -1
  79. package/dist/web/skills/__next._tree.txt +1 -1
  80. package/dist/web/skills/__next.skills.__PAGE__.txt +1 -1
  81. package/dist/web/skills.html +1 -1
  82. package/dist/web/skills.txt +1 -1
  83. package/package.json +1 -1
  84. package/template/docs/connectors.md +88 -1
  85. /package/dist/web/_next/static/{Kudq_QcLnSoz7UYDmluYu → vW8A0EhHuyi1vRgv7Qkyg}/_buildManifest.js +0 -0
  86. /package/dist/web/_next/static/{Kudq_QcLnSoz7UYDmluYu → vW8A0EhHuyi1vRgv7Qkyg}/_clientMiddlewareManifest.js +0 -0
  87. /package/dist/web/_next/static/{Kudq_QcLnSoz7UYDmluYu → vW8A0EhHuyi1vRgv7Qkyg}/_ssgManifest.js +0 -0
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openryoko",
3
- "version": "2026.9.3",
3
+ "version": "2026.9.4",
4
4
  "description": "OpenRyoko — Slackで空気を読んで働くAIアシスタント・ゲートウェイ(Jinnベース、Claude Code/Codex/Gemini CLI対応)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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":"AASA,OAAO,KAAK,EACV,SAAS,EACT,qBAAqB,EACrB,eAAe,EACf,eAAe,EACf,MAAM,EACP,MAAM,uBAAuB,CAAC;AAM/B,MAAM,WAAW,sBAAsB;IACrC,sDAAsD;IACtD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC9B,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uFAAuF;IACvF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qEAAqE;IACrE,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,oGAAoG;IACpG,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,qBAAa,gBAAiB,YAAW,SAAS;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,MAAM,CAAyB;IACvC,OAAO,CAAC,OAAO,CAAiD;IAChE,OAAO,CAAC,UAAU,CAAc;IAChC,OAAO,CAAC,cAAc,CAAc;IACpC,OAAO,CAAC,MAAM,CAA4D;IAC1E,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,eAAe,CAAqD;gBAEhE,MAAM,EAAE,sBAAsB;IA+B1C,SAAS,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,IAAI,GAAG,IAAI;IAIxD,WAAW,IAAI,MAAM,GAAG,SAAS;IAI3B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAuBtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAM3B,eAAe,IAAI,qBAAqB;IASxC,SAAS,IAAI,eAAe;IAQ5B,iBAAiB,CAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM;IAS7E,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAgBtE,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAgBvE,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYxD,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYzD,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAY5D,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAwBxF,aAAa;IA2E3B,2DAA2D;YAC7C,aAAa;CAwC5B"}
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 chunks = formatResponse(text);
105
- let lastId;
106
- for (const chunk of chunks) {
107
- const sent = await channel.send(chunk);
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 chunks = formatResponse(text);
122
- let lastId;
123
- for (const chunk of chunks) {
124
- const sent = await channel.send(chunk);
125
- lastId = sent.id;
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
- return lastId;
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
- if (message.author.bot)
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
- const routeTarget = this.config.channelRouting?.[message.channel.id];
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
- await this.proxyToRemote(routeTarget, message);
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 (+ DMs always allowed)
221
- if (this.config.channelId && message.channel.id !== this.config.channelId && !message.channel.isDMBased())
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
- sessionKey: deriveSessionKey(message),
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`, {