@actagent/irc 2026.6.2
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/actagent.plugin.json +26 -0
- package/api.ts +11 -0
- package/channel-config-api.ts +2 -0
- package/channel-plugin-api.ts +3 -0
- package/configured-state.ts +9 -0
- package/contract-api.ts +5 -0
- package/index.test.ts +14 -0
- package/index.ts +21 -0
- package/package.json +44 -0
- package/runtime-api.test.ts +24 -0
- package/runtime-api.ts +3 -0
- package/secret-contract-api.ts +6 -0
- package/setup-entry.ts +14 -0
- package/src/accounts.test.ts +224 -0
- package/src/accounts.ts +240 -0
- package/src/channel-api.ts +7 -0
- package/src/channel-runtime.ts +4 -0
- package/src/channel.test.ts +17 -0
- package/src/channel.ts +367 -0
- package/src/client.test.ts +44 -0
- package/src/client.ts +443 -0
- package/src/config-schema.test.ts +117 -0
- package/src/config-schema.ts +97 -0
- package/src/config-ui-hints.ts +41 -0
- package/src/connect-options.test.ts +48 -0
- package/src/connect-options.ts +31 -0
- package/src/control-chars.test.ts +18 -0
- package/src/control-chars.ts +23 -0
- package/src/doctor.ts +55 -0
- package/src/gateway.ts +54 -0
- package/src/inbound.behavior.test.ts +247 -0
- package/src/inbound.ts +440 -0
- package/src/message-adapter.ts +29 -0
- package/src/monitor.test.ts +44 -0
- package/src/monitor.ts +150 -0
- package/src/normalize.test.ts +56 -0
- package/src/normalize.ts +111 -0
- package/src/outbound-base.ts +11 -0
- package/src/policy.test.ts +56 -0
- package/src/policy.ts +79 -0
- package/src/probe.test.ts +111 -0
- package/src/probe.ts +54 -0
- package/src/protocol.test.ts +49 -0
- package/src/protocol.ts +170 -0
- package/src/runtime-api.ts +42 -0
- package/src/runtime.ts +16 -0
- package/src/secret-contract.ts +104 -0
- package/src/send.test.ts +327 -0
- package/src/send.ts +122 -0
- package/src/setup-core.ts +152 -0
- package/src/setup-surface.ts +451 -0
- package/src/setup.test.ts +487 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +16 -0
package/src/inbound.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// Irc plugin module implements inbound behavior.
|
|
2
|
+
import { logInboundDrop } from "actagent/plugin-sdk/channel-inbound";
|
|
3
|
+
import {
|
|
4
|
+
channelIngressRoutes,
|
|
5
|
+
createChannelIngressResolver,
|
|
6
|
+
defineStableChannelIngressIdentity,
|
|
7
|
+
} from "actagent/plugin-sdk/channel-ingress-runtime";
|
|
8
|
+
import { createChannelPairingController } from "actagent/plugin-sdk/channel-pairing";
|
|
9
|
+
import type { ACTAgentConfig } from "actagent/plugin-sdk/config-contracts";
|
|
10
|
+
import { isDangerousNameMatchingEnabled } from "actagent/plugin-sdk/dangerous-name-runtime";
|
|
11
|
+
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "actagent/plugin-sdk/inbound-envelope";
|
|
12
|
+
import {
|
|
13
|
+
deliverFormattedTextWithAttachments,
|
|
14
|
+
type OutboundReplyPayload,
|
|
15
|
+
} from "actagent/plugin-sdk/reply-payload";
|
|
16
|
+
import type { RuntimeEnv } from "actagent/plugin-sdk/runtime";
|
|
17
|
+
import {
|
|
18
|
+
GROUP_POLICY_BLOCKED_LABEL,
|
|
19
|
+
resolveAllowlistProviderRuntimeGroupPolicy,
|
|
20
|
+
resolveDefaultGroupPolicy,
|
|
21
|
+
warnMissingProviderGroupPolicyFallbackOnce,
|
|
22
|
+
} from "actagent/plugin-sdk/runtime-group-policy";
|
|
23
|
+
import {
|
|
24
|
+
normalizeLowercaseStringOrEmpty,
|
|
25
|
+
normalizeOptionalString,
|
|
26
|
+
normalizeStringEntries,
|
|
27
|
+
} from "actagent/plugin-sdk/string-coerce-runtime";
|
|
28
|
+
import type { ResolvedIrcAccount } from "./accounts.js";
|
|
29
|
+
import { buildIrcAllowlistCandidates, normalizeIrcAllowEntry } from "./normalize.js";
|
|
30
|
+
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
|
|
31
|
+
import { getIrcRuntime } from "./runtime.js";
|
|
32
|
+
import { sendMessageIrc } from "./send.js";
|
|
33
|
+
import type { CoreConfig, IrcInboundMessage } from "./types.js";
|
|
34
|
+
|
|
35
|
+
const CHANNEL_ID = "irc" as const;
|
|
36
|
+
const IRC_NICK_KIND = "plugin:irc-nick" as const;
|
|
37
|
+
type IrcGroupPolicy = "open" | "allowlist" | "disabled";
|
|
38
|
+
|
|
39
|
+
const ircIngressIdentity = defineStableChannelIngressIdentity({
|
|
40
|
+
key: "irc-id",
|
|
41
|
+
normalizeEntry: normalizeIrcStableEntry,
|
|
42
|
+
normalizeSubject: normalizeLowercaseStringOrEmpty,
|
|
43
|
+
sensitivity: "pii",
|
|
44
|
+
aliases: [
|
|
45
|
+
...["irc-id-nick-user", "irc-id-nick-host"].map((key) => ({
|
|
46
|
+
key,
|
|
47
|
+
kind: "stable-id" as const,
|
|
48
|
+
normalizeEntry: () => null,
|
|
49
|
+
normalizeSubject: normalizeLowercaseStringOrEmpty,
|
|
50
|
+
sensitivity: "pii" as const,
|
|
51
|
+
})),
|
|
52
|
+
{
|
|
53
|
+
key: "irc-nick",
|
|
54
|
+
kind: IRC_NICK_KIND,
|
|
55
|
+
normalizeEntry: normalizeIrcNickEntry,
|
|
56
|
+
normalizeSubject: normalizeLowercaseStringOrEmpty,
|
|
57
|
+
dangerous: true,
|
|
58
|
+
sensitivity: "pii",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
isWildcardEntry: (entry) => normalizeIrcAllowEntry(entry) === "*",
|
|
62
|
+
resolveEntryId: ({ entryIndex, fieldKey }) =>
|
|
63
|
+
`irc-entry-${entryIndex + 1}:${fieldKey === "irc-nick" ? "nick" : "id"}`,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
67
|
+
|
|
68
|
+
function isBareNick(value: string): boolean {
|
|
69
|
+
return !value.includes("!") && !value.includes("@");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeIrcStableEntry(value: string): string | null {
|
|
73
|
+
const normalized = normalizeIrcAllowEntry(value);
|
|
74
|
+
if (!normalized || normalized === "*" || isBareNick(normalized)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return normalized;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeIrcNickEntry(value: string): string | null {
|
|
81
|
+
const normalized = normalizeIrcAllowEntry(value);
|
|
82
|
+
if (!normalized || normalized === "*" || !isBareNick(normalized)) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasEntries(entries: Array<string | number> | undefined): boolean {
|
|
89
|
+
return normalizeStringEntries(entries).some((entry) => normalizeIrcAllowEntry(entry));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createIrcIngressSubject(message: IrcInboundMessage) {
|
|
93
|
+
const candidates = buildIrcAllowlistCandidates(message, { allowNameMatching: true });
|
|
94
|
+
const stableCandidates = candidates.filter((candidate) => !isBareNick(candidate));
|
|
95
|
+
const nick = normalizeLowercaseStringOrEmpty(message.senderNick);
|
|
96
|
+
return {
|
|
97
|
+
stableId: stableCandidates[stableCandidates.length - 1] ?? nick,
|
|
98
|
+
aliases: {
|
|
99
|
+
"irc-id-nick-user": stableCandidates.find(
|
|
100
|
+
(candidate) => candidate.includes("!") && !candidate.includes("@"),
|
|
101
|
+
),
|
|
102
|
+
"irc-id-nick-host": stableCandidates.find(
|
|
103
|
+
(candidate) => !candidate.includes("!") && candidate.includes("@"),
|
|
104
|
+
),
|
|
105
|
+
"irc-nick": nick,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function routeDescriptorsForIrcGroup(params: {
|
|
111
|
+
isGroup: boolean;
|
|
112
|
+
groupPolicy: IrcGroupPolicy;
|
|
113
|
+
groupAllowed: boolean;
|
|
114
|
+
hasConfiguredGroups: boolean;
|
|
115
|
+
groupEnabled: boolean;
|
|
116
|
+
routeGroupAllowFrom: string[];
|
|
117
|
+
}) {
|
|
118
|
+
if (!params.isGroup) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
return channelIngressRoutes(
|
|
122
|
+
params.groupPolicy === "allowlist" && {
|
|
123
|
+
id: "irc:channel",
|
|
124
|
+
allowed: params.hasConfiguredGroups && params.groupAllowed,
|
|
125
|
+
precedence: 0,
|
|
126
|
+
matchId: "irc-channel",
|
|
127
|
+
blockReason: "channel_not_allowlisted",
|
|
128
|
+
},
|
|
129
|
+
!params.groupEnabled && {
|
|
130
|
+
id: "irc:channel-enabled",
|
|
131
|
+
enabled: false,
|
|
132
|
+
precedence: 10,
|
|
133
|
+
blockReason: "channel_disabled",
|
|
134
|
+
},
|
|
135
|
+
hasEntries(params.routeGroupAllowFrom) && {
|
|
136
|
+
id: "irc:channel-sender",
|
|
137
|
+
precedence: 20,
|
|
138
|
+
senderPolicy: "replace",
|
|
139
|
+
senderAllowFrom: params.routeGroupAllowFrom,
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function deliverIrcReply(params: {
|
|
145
|
+
payload: OutboundReplyPayload;
|
|
146
|
+
cfg: CoreConfig;
|
|
147
|
+
target: string;
|
|
148
|
+
accountId: string;
|
|
149
|
+
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
|
|
150
|
+
statusSink?: (patch: { lastOutboundAt?: number }) => void;
|
|
151
|
+
}) {
|
|
152
|
+
await deliverFormattedTextWithAttachments({
|
|
153
|
+
payload: params.payload,
|
|
154
|
+
send: async ({ text, replyToId }) => {
|
|
155
|
+
if (params.sendReply) {
|
|
156
|
+
await params.sendReply(params.target, text, replyToId);
|
|
157
|
+
} else {
|
|
158
|
+
await sendMessageIrc(params.target, text, {
|
|
159
|
+
cfg: params.cfg,
|
|
160
|
+
accountId: params.accountId,
|
|
161
|
+
replyTo: replyToId,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
params.statusSink?.({ lastOutboundAt: Date.now() });
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function handleIrcInbound(params: {
|
|
170
|
+
message: IrcInboundMessage;
|
|
171
|
+
account: ResolvedIrcAccount;
|
|
172
|
+
config: CoreConfig;
|
|
173
|
+
runtime: RuntimeEnv;
|
|
174
|
+
connectedNick?: string;
|
|
175
|
+
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
|
|
176
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
177
|
+
}): Promise<void> {
|
|
178
|
+
const { message, account, config, runtime, connectedNick, statusSink } = params;
|
|
179
|
+
const core = getIrcRuntime();
|
|
180
|
+
const pairing = createChannelPairingController({
|
|
181
|
+
core,
|
|
182
|
+
channel: CHANNEL_ID,
|
|
183
|
+
accountId: account.accountId,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const rawBody = message.text?.trim() ?? "";
|
|
187
|
+
if (!rawBody) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
statusSink?.({ lastInboundAt: message.timestamp });
|
|
192
|
+
|
|
193
|
+
const senderDisplay = message.senderHost
|
|
194
|
+
? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}`
|
|
195
|
+
: message.senderNick;
|
|
196
|
+
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
|
|
197
|
+
|
|
198
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
199
|
+
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
|
200
|
+
const { groupPolicy, providerMissingFallbackApplied } =
|
|
201
|
+
resolveAllowlistProviderRuntimeGroupPolicy({
|
|
202
|
+
providerConfigPresent: config.channels?.irc !== undefined,
|
|
203
|
+
groupPolicy: account.config.groupPolicy,
|
|
204
|
+
defaultGroupPolicy,
|
|
205
|
+
});
|
|
206
|
+
warnMissingProviderGroupPolicyFallbackOnce({
|
|
207
|
+
providerMissingFallbackApplied,
|
|
208
|
+
providerKey: "irc",
|
|
209
|
+
accountId: account.accountId,
|
|
210
|
+
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.channel,
|
|
211
|
+
log: (messageLocal) => runtime.log?.(messageLocal),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const groupMatch = resolveIrcGroupMatch({
|
|
215
|
+
groups: account.config.groups,
|
|
216
|
+
target: message.target,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
|
220
|
+
cfg: config as ACTAgentConfig,
|
|
221
|
+
surface: CHANNEL_ID,
|
|
222
|
+
});
|
|
223
|
+
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as ACTAgentConfig);
|
|
224
|
+
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as ACTAgentConfig);
|
|
225
|
+
const mentionNick = connectedNick?.trim() || account.nick;
|
|
226
|
+
const explicitMentionRegex = mentionNick
|
|
227
|
+
? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i")
|
|
228
|
+
: null;
|
|
229
|
+
const wasMentioned =
|
|
230
|
+
core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) ||
|
|
231
|
+
(explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false);
|
|
232
|
+
const requireMention = message.isGroup
|
|
233
|
+
? resolveIrcRequireMention({
|
|
234
|
+
groupConfig: groupMatch.groupConfig,
|
|
235
|
+
wildcardConfig: groupMatch.wildcardConfig,
|
|
236
|
+
})
|
|
237
|
+
: false;
|
|
238
|
+
const routeGroupAllowFrom = normalizeStringEntries(
|
|
239
|
+
groupMatch.groupConfig?.allowFrom?.length
|
|
240
|
+
? groupMatch.groupConfig.allowFrom
|
|
241
|
+
: groupMatch.wildcardConfig?.allowFrom,
|
|
242
|
+
);
|
|
243
|
+
const accessGroupPolicy: IrcGroupPolicy =
|
|
244
|
+
groupPolicy === "open" &&
|
|
245
|
+
(hasEntries(account.config.groupAllowFrom) || hasEntries(routeGroupAllowFrom))
|
|
246
|
+
? "allowlist"
|
|
247
|
+
: groupPolicy;
|
|
248
|
+
const access = await createChannelIngressResolver({
|
|
249
|
+
channelId: CHANNEL_ID,
|
|
250
|
+
accountId: account.accountId,
|
|
251
|
+
identity: ircIngressIdentity,
|
|
252
|
+
cfg: config as ACTAgentConfig,
|
|
253
|
+
readStoreAllowFrom: async () => await pairing.readAllowFromStore(),
|
|
254
|
+
}).message({
|
|
255
|
+
subject: createIrcIngressSubject(message),
|
|
256
|
+
conversation: {
|
|
257
|
+
kind: message.isGroup ? "group" : "direct",
|
|
258
|
+
id: message.target,
|
|
259
|
+
},
|
|
260
|
+
route: routeDescriptorsForIrcGroup({
|
|
261
|
+
isGroup: message.isGroup,
|
|
262
|
+
groupPolicy,
|
|
263
|
+
groupAllowed: groupMatch.allowed,
|
|
264
|
+
hasConfiguredGroups: groupMatch.hasConfiguredGroups,
|
|
265
|
+
groupEnabled:
|
|
266
|
+
groupMatch.groupConfig?.enabled !== false && groupMatch.wildcardConfig?.enabled !== false,
|
|
267
|
+
routeGroupAllowFrom,
|
|
268
|
+
}),
|
|
269
|
+
mentionFacts: message.isGroup
|
|
270
|
+
? {
|
|
271
|
+
canDetectMention: true,
|
|
272
|
+
wasMentioned,
|
|
273
|
+
hasAnyMention: wasMentioned,
|
|
274
|
+
}
|
|
275
|
+
: undefined,
|
|
276
|
+
dmPolicy,
|
|
277
|
+
groupPolicy: accessGroupPolicy,
|
|
278
|
+
policy: {
|
|
279
|
+
groupAllowFromFallbackToAllowFrom: false,
|
|
280
|
+
mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
|
|
281
|
+
activation: {
|
|
282
|
+
requireMention: message.isGroup && requireMention,
|
|
283
|
+
allowTextCommands,
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
allowFrom: account.config.allowFrom,
|
|
287
|
+
groupAllowFrom: account.config.groupAllowFrom,
|
|
288
|
+
command: {
|
|
289
|
+
allowTextCommands,
|
|
290
|
+
hasControlCommand,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
const commandAuthorized = access.commandAccess.authorized;
|
|
294
|
+
|
|
295
|
+
if (access.ingress.admission === "pairing-required") {
|
|
296
|
+
await pairing.issueChallenge({
|
|
297
|
+
senderId: normalizeLowercaseStringOrEmpty(senderDisplay),
|
|
298
|
+
senderIdLine: `Your IRC id: ${senderDisplay}`,
|
|
299
|
+
meta: { name: message.senderNick || undefined },
|
|
300
|
+
sendPairingReply: async (text) => {
|
|
301
|
+
await deliverIrcReply({
|
|
302
|
+
payload: { text },
|
|
303
|
+
cfg: config,
|
|
304
|
+
target: message.senderNick,
|
|
305
|
+
accountId: account.accountId,
|
|
306
|
+
sendReply: params.sendReply,
|
|
307
|
+
statusSink,
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
onReplyError: (err) => {
|
|
311
|
+
runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`);
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (access.ingress.admission === "skip") {
|
|
318
|
+
runtime.log?.(`irc: drop channel ${message.target} (missing-mention)`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (access.ingress.admission !== "dispatch") {
|
|
322
|
+
if (
|
|
323
|
+
message.isGroup &&
|
|
324
|
+
access.ingress.decisiveGateId === "command" &&
|
|
325
|
+
access.commandAccess.shouldBlockControlCommand
|
|
326
|
+
) {
|
|
327
|
+
logInboundDrop({
|
|
328
|
+
log: (line) => runtime.log?.(line),
|
|
329
|
+
channel: CHANNEL_ID,
|
|
330
|
+
reason: "control command (unauthorized)",
|
|
331
|
+
target: senderDisplay,
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (message.isGroup) {
|
|
336
|
+
if (access.routeAccess.reason === "channel_not_allowlisted") {
|
|
337
|
+
runtime.log?.(`irc: drop channel ${message.target} (not allowlisted)`);
|
|
338
|
+
} else if (access.routeAccess.reason === "channel_disabled") {
|
|
339
|
+
runtime.log?.(`irc: drop channel ${message.target} (disabled)`);
|
|
340
|
+
} else {
|
|
341
|
+
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const channelTarget =
|
|
350
|
+
message.target.startsWith("#") || message.target.startsWith("&")
|
|
351
|
+
? message.target
|
|
352
|
+
: `#${message.target}`;
|
|
353
|
+
const peerId = message.isGroup ? channelTarget : message.senderNick;
|
|
354
|
+
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
|
355
|
+
cfg: config as ACTAgentConfig,
|
|
356
|
+
channel: CHANNEL_ID,
|
|
357
|
+
accountId: account.accountId,
|
|
358
|
+
peer: {
|
|
359
|
+
kind: message.isGroup ? "group" : "direct",
|
|
360
|
+
id: peerId,
|
|
361
|
+
},
|
|
362
|
+
runtime: core.channel,
|
|
363
|
+
sessionStore: config.session?.store,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const fromLabel = message.isGroup ? message.target : senderDisplay;
|
|
367
|
+
const { storePath, body } = buildEnvelope({
|
|
368
|
+
channel: "IRC",
|
|
369
|
+
from: fromLabel,
|
|
370
|
+
timestamp: message.timestamp,
|
|
371
|
+
body: rawBody,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
|
|
375
|
+
|
|
376
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
377
|
+
Body: body,
|
|
378
|
+
RawBody: rawBody,
|
|
379
|
+
CommandBody: rawBody,
|
|
380
|
+
From: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
|
|
381
|
+
To: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
|
|
382
|
+
SessionKey: route.sessionKey,
|
|
383
|
+
AccountId: route.accountId,
|
|
384
|
+
ChatType: message.isGroup ? "group" : "direct",
|
|
385
|
+
ConversationLabel: fromLabel,
|
|
386
|
+
SenderName: message.senderNick || undefined,
|
|
387
|
+
SenderId: senderDisplay,
|
|
388
|
+
GroupSubject: message.isGroup ? message.target : undefined,
|
|
389
|
+
GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
|
|
390
|
+
Provider: CHANNEL_ID,
|
|
391
|
+
Surface: CHANNEL_ID,
|
|
392
|
+
WasMentioned: message.isGroup ? wasMentioned : undefined,
|
|
393
|
+
MessageSid: message.messageId,
|
|
394
|
+
Timestamp: message.timestamp,
|
|
395
|
+
OriginatingChannel: CHANNEL_ID,
|
|
396
|
+
OriginatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
|
|
397
|
+
CommandAuthorized: commandAuthorized,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
await core.channel.inbound.dispatchReply({
|
|
401
|
+
cfg: config as ACTAgentConfig,
|
|
402
|
+
channel: CHANNEL_ID,
|
|
403
|
+
accountId: account.accountId,
|
|
404
|
+
agentId: route.agentId,
|
|
405
|
+
routeSessionKey: route.sessionKey,
|
|
406
|
+
storePath,
|
|
407
|
+
ctxPayload,
|
|
408
|
+
recordInboundSession: core.channel.session.recordInboundSession,
|
|
409
|
+
dispatchReplyWithBufferedBlockDispatcher:
|
|
410
|
+
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
411
|
+
delivery: {
|
|
412
|
+
deliver: async (payload) => {
|
|
413
|
+
await deliverIrcReply({
|
|
414
|
+
payload,
|
|
415
|
+
cfg: config,
|
|
416
|
+
target: peerId,
|
|
417
|
+
accountId: account.accountId,
|
|
418
|
+
sendReply: params.sendReply,
|
|
419
|
+
statusSink,
|
|
420
|
+
});
|
|
421
|
+
},
|
|
422
|
+
onError: (err, info) => {
|
|
423
|
+
runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
replyPipeline: {},
|
|
427
|
+
replyOptions: {
|
|
428
|
+
skillFilter: groupMatch.groupConfig?.skills,
|
|
429
|
+
disableBlockStreaming:
|
|
430
|
+
typeof account.config.blockStreaming === "boolean"
|
|
431
|
+
? !account.config.blockStreaming
|
|
432
|
+
: undefined,
|
|
433
|
+
},
|
|
434
|
+
record: {
|
|
435
|
+
onRecordError: (err) => {
|
|
436
|
+
runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Irc plugin module implements message adapter behavior.
|
|
2
|
+
import { defineChannelMessageAdapter } from "actagent/plugin-sdk/channel-outbound";
|
|
3
|
+
import { sendMessageIrc } from "./send.js";
|
|
4
|
+
import type { CoreConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export const ircMessageAdapter = defineChannelMessageAdapter({
|
|
7
|
+
id: "irc",
|
|
8
|
+
durableFinal: {
|
|
9
|
+
capabilities: {
|
|
10
|
+
text: true,
|
|
11
|
+
media: true,
|
|
12
|
+
replyTo: true,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
send: {
|
|
16
|
+
text: async ({ cfg, to, text, accountId, replyToId }) =>
|
|
17
|
+
await sendMessageIrc(to, text, {
|
|
18
|
+
cfg: cfg as CoreConfig,
|
|
19
|
+
accountId: accountId ?? undefined,
|
|
20
|
+
replyTo: replyToId ?? undefined,
|
|
21
|
+
}),
|
|
22
|
+
media: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) =>
|
|
23
|
+
await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
|
|
24
|
+
cfg: cfg as CoreConfig,
|
|
25
|
+
accountId: accountId ?? undefined,
|
|
26
|
+
replyTo: replyToId ?? undefined,
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Irc tests cover monitor plugin behavior.
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { resolveIrcInboundTarget } from "./monitor.js";
|
|
4
|
+
|
|
5
|
+
describe("irc monitor inbound target", () => {
|
|
6
|
+
it("keeps channel target for group messages", () => {
|
|
7
|
+
expect(
|
|
8
|
+
resolveIrcInboundTarget({
|
|
9
|
+
target: "#actagent",
|
|
10
|
+
senderNick: "alice",
|
|
11
|
+
}),
|
|
12
|
+
).toEqual({
|
|
13
|
+
isGroup: true,
|
|
14
|
+
target: "#actagent",
|
|
15
|
+
rawTarget: "#actagent",
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("maps DM target to sender nick and preserves raw target", () => {
|
|
20
|
+
expect(
|
|
21
|
+
resolveIrcInboundTarget({
|
|
22
|
+
target: "actagent-bot",
|
|
23
|
+
senderNick: "alice",
|
|
24
|
+
}),
|
|
25
|
+
).toEqual({
|
|
26
|
+
isGroup: false,
|
|
27
|
+
target: "alice",
|
|
28
|
+
rawTarget: "actagent-bot",
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("falls back to raw target when sender nick is empty", () => {
|
|
33
|
+
expect(
|
|
34
|
+
resolveIrcInboundTarget({
|
|
35
|
+
target: "actagent-bot",
|
|
36
|
+
senderNick: " ",
|
|
37
|
+
}),
|
|
38
|
+
).toEqual({
|
|
39
|
+
isGroup: false,
|
|
40
|
+
target: "actagent-bot",
|
|
41
|
+
rawTarget: "actagent-bot",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Irc plugin module implements monitor behavior.
|
|
2
|
+
import { resolveLoggerBackedRuntime } from "actagent/plugin-sdk/extension-shared";
|
|
3
|
+
import { normalizeLowercaseStringOrEmpty } from "actagent/plugin-sdk/string-coerce-runtime";
|
|
4
|
+
import { resolveIrcAccount } from "./accounts.js";
|
|
5
|
+
import { connectIrcClient, type IrcClient } from "./client.js";
|
|
6
|
+
import { buildIrcConnectOptions } from "./connect-options.js";
|
|
7
|
+
import { handleIrcInbound } from "./inbound.js";
|
|
8
|
+
import { isChannelTarget } from "./normalize.js";
|
|
9
|
+
import { makeIrcMessageId } from "./protocol.js";
|
|
10
|
+
import type { RuntimeEnv } from "./runtime-api.js";
|
|
11
|
+
import { getIrcRuntime } from "./runtime.js";
|
|
12
|
+
import type { CoreConfig, IrcInboundMessage } from "./types.js";
|
|
13
|
+
|
|
14
|
+
type IrcMonitorOptions = {
|
|
15
|
+
accountId?: string;
|
|
16
|
+
config?: CoreConfig;
|
|
17
|
+
runtime?: RuntimeEnv;
|
|
18
|
+
abortSignal?: AbortSignal;
|
|
19
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
20
|
+
onMessage?: (message: IrcInboundMessage, client: IrcClient) => void | Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function resolveIrcInboundTarget(params: { target: string; senderNick: string }): {
|
|
24
|
+
isGroup: boolean;
|
|
25
|
+
target: string;
|
|
26
|
+
rawTarget: string;
|
|
27
|
+
} {
|
|
28
|
+
const rawTarget = params.target;
|
|
29
|
+
const isGroup = isChannelTarget(rawTarget);
|
|
30
|
+
if (isGroup) {
|
|
31
|
+
return { isGroup: true, target: rawTarget, rawTarget };
|
|
32
|
+
}
|
|
33
|
+
const senderNick = params.senderNick.trim();
|
|
34
|
+
return { isGroup: false, target: senderNick || rawTarget, rawTarget };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ stop: () => void }> {
|
|
38
|
+
const core = getIrcRuntime();
|
|
39
|
+
const cfg = opts.config ?? (core.config.current() as CoreConfig);
|
|
40
|
+
const account = resolveIrcAccount({
|
|
41
|
+
cfg,
|
|
42
|
+
accountId: opts.accountId,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const runtime: RuntimeEnv = resolveLoggerBackedRuntime(
|
|
46
|
+
opts.runtime,
|
|
47
|
+
core.logging.getChildLogger(),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (!account.configured) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const logger = core.logging.getChildLogger({
|
|
57
|
+
channel: "irc",
|
|
58
|
+
accountId: account.accountId,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let client: IrcClient | null = null;
|
|
62
|
+
|
|
63
|
+
client = await connectIrcClient(
|
|
64
|
+
buildIrcConnectOptions(account, {
|
|
65
|
+
channels: account.config.channels,
|
|
66
|
+
abortSignal: opts.abortSignal,
|
|
67
|
+
onLine: (line) => {
|
|
68
|
+
if (core.logging.shouldLogVerbose()) {
|
|
69
|
+
logger.debug?.(`[${account.accountId}] << ${line}`);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
onNotice: (text, target) => {
|
|
73
|
+
if (core.logging.shouldLogVerbose()) {
|
|
74
|
+
logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
onError: (error) => {
|
|
78
|
+
logger.error(`[${account.accountId}] IRC error: ${error.message}`);
|
|
79
|
+
},
|
|
80
|
+
onPrivmsg: async (event) => {
|
|
81
|
+
if (!client) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
normalizeLowercaseStringOrEmpty(event.senderNick) ===
|
|
86
|
+
normalizeLowercaseStringOrEmpty(client.nick)
|
|
87
|
+
) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const inboundTarget = resolveIrcInboundTarget({
|
|
92
|
+
target: event.target,
|
|
93
|
+
senderNick: event.senderNick,
|
|
94
|
+
});
|
|
95
|
+
const message: IrcInboundMessage = {
|
|
96
|
+
messageId: makeIrcMessageId(),
|
|
97
|
+
target: inboundTarget.target,
|
|
98
|
+
rawTarget: inboundTarget.rawTarget,
|
|
99
|
+
senderNick: event.senderNick,
|
|
100
|
+
senderUser: event.senderUser,
|
|
101
|
+
senderHost: event.senderHost,
|
|
102
|
+
text: event.text,
|
|
103
|
+
timestamp: Date.now(),
|
|
104
|
+
isGroup: inboundTarget.isGroup,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
core.channel.activity.record({
|
|
108
|
+
channel: "irc",
|
|
109
|
+
accountId: account.accountId,
|
|
110
|
+
direction: "inbound",
|
|
111
|
+
at: message.timestamp,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (opts.onMessage) {
|
|
115
|
+
await opts.onMessage(message, client);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await handleIrcInbound({
|
|
120
|
+
message,
|
|
121
|
+
account,
|
|
122
|
+
config: cfg,
|
|
123
|
+
runtime,
|
|
124
|
+
connectedNick: client.nick,
|
|
125
|
+
sendReply: async (target, text) => {
|
|
126
|
+
client?.sendPrivmsg(target, text);
|
|
127
|
+
opts.statusSink?.({ lastOutboundAt: Date.now() });
|
|
128
|
+
core.channel.activity.record({
|
|
129
|
+
channel: "irc",
|
|
130
|
+
accountId: account.accountId,
|
|
131
|
+
direction: "outbound",
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
statusSink: opts.statusSink,
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
logger.info(
|
|
141
|
+
`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
stop: () => {
|
|
146
|
+
client?.quit("shutdown");
|
|
147
|
+
client = null;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|