opencode-router 0.11.128 → 0.11.129
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/bridge.js +232 -68
- package/dist/cli.js +67 -11
- package/dist/delivery.js +108 -0
- package/dist/health.js +10 -3
- package/dist/media-store.js +125 -0
- package/dist/media.js +111 -0
- package/dist/slack.js +204 -22
- package/dist/telegram.js +238 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,6 +75,8 @@ Slack support uses Socket Mode and replies in threads when @mentioned in channel
|
|
|
75
75
|
- `chat:write`
|
|
76
76
|
- `app_mentions:read`
|
|
77
77
|
- `im:history`
|
|
78
|
+
- `files:read`
|
|
79
|
+
- `files:write`
|
|
78
80
|
4) Subscribe to events (bot events):
|
|
79
81
|
- `app_mention`
|
|
80
82
|
- `message.im`
|
|
@@ -115,6 +117,33 @@ curl -sS "http://127.0.0.1:${OPENCODE_ROUTER_HEALTH_PORT:-3005}/send" \
|
|
|
115
117
|
-d '{"channel":"telegram","directory":"/path/to/workdir","text":"hello"}'
|
|
116
118
|
```
|
|
117
119
|
|
|
120
|
+
Send text + media in one request:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
curl -sS "http://127.0.0.1:${OPENCODE_ROUTER_HEALTH_PORT:-3005}/send" \
|
|
124
|
+
-H 'Content-Type: application/json' \
|
|
125
|
+
-d '{
|
|
126
|
+
"channel":"slack",
|
|
127
|
+
"peerId":"D12345678",
|
|
128
|
+
"text":"Here is the export",
|
|
129
|
+
"parts":[
|
|
130
|
+
{"type":"file","filePath":"./artifacts/report.pdf"},
|
|
131
|
+
{"type":"image","filePath":"./artifacts/plot.png","caption":"latest trend"}
|
|
132
|
+
]
|
|
133
|
+
}'
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Supported media part types:
|
|
137
|
+
- `image`
|
|
138
|
+
- `audio`
|
|
139
|
+
- `file`
|
|
140
|
+
|
|
141
|
+
Each media part accepts:
|
|
142
|
+
- `filePath` (absolute path, or relative to the send directory/workspace root)
|
|
143
|
+
- optional `caption`
|
|
144
|
+
- optional `filename`
|
|
145
|
+
- optional `mimeType`
|
|
146
|
+
|
|
118
147
|
## Commands
|
|
119
148
|
|
|
120
149
|
```bash
|
|
@@ -129,6 +158,10 @@ opencode-router slack add <xoxb> <xapp> --id default
|
|
|
129
158
|
|
|
130
159
|
opencode-router bindings list
|
|
131
160
|
opencode-router bindings set --channel telegram --identity default --peer <chatId> --dir /path/to/workdir
|
|
161
|
+
|
|
162
|
+
opencode-router send --channel telegram --identity default --to <chatId> --message "hello"
|
|
163
|
+
opencode-router send --channel telegram --identity default --to <chatId> --image ./plot.png --caption "plot"
|
|
164
|
+
opencode-router send --channel slack --identity default --to D123 --file ./report.pdf
|
|
132
165
|
```
|
|
133
166
|
|
|
134
167
|
## Defaults
|
package/dist/bridge.js
CHANGED
|
@@ -4,8 +4,11 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
4
4
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { readConfigFile, writeConfigFile } from "./config.js";
|
|
6
6
|
import { BridgeStore } from "./db.js";
|
|
7
|
+
import { classifyDeliveryError } from "./delivery.js";
|
|
7
8
|
import { normalizeEvent } from "./events.js";
|
|
8
9
|
import { startHealthServer } from "./health.js";
|
|
10
|
+
import { normalizeOutboundParts, summarizeInboundPartsForPrompt, summarizeInboundPartsForReporter, textFromInboundParts } from "./media.js";
|
|
11
|
+
import { MediaStore } from "./media-store.js";
|
|
9
12
|
import { buildPermissionRules, createClient } from "./opencode.js";
|
|
10
13
|
import { chunkText, formatInputSummary, truncateText } from "./text.js";
|
|
11
14
|
import { createSlackAdapter } from "./slack.js";
|
|
@@ -128,6 +131,8 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
128
131
|
const clients = new Map();
|
|
129
132
|
const defaultDirectory = config.opencodeDirectory;
|
|
130
133
|
const workspaceRoot = resolve(defaultDirectory || process.cwd());
|
|
134
|
+
const mediaStore = new MediaStore(join(workspaceRoot, ".opencode-router", "media"));
|
|
135
|
+
await mediaStore.ensureReady();
|
|
131
136
|
const workspaceAgentFilePath = join(workspaceRoot, OPENCODE_ROUTER_AGENT_FILE_RELATIVE_PATH);
|
|
132
137
|
const agentPromptCache = new Map();
|
|
133
138
|
let latestAgentConfig = {
|
|
@@ -276,7 +281,7 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
276
281
|
for (const bot of enabledTelegram) {
|
|
277
282
|
const key = adapterKey("telegram", bot.id);
|
|
278
283
|
logger.debug({ identityId: bot.id }, "telegram adapter enabled");
|
|
279
|
-
const base = createTelegramAdapter(bot, config, logger, handleInbound);
|
|
284
|
+
const base = createTelegramAdapter(bot, config, logger, handleInbound, mediaStore);
|
|
280
285
|
adapters.set(key, { ...base, key });
|
|
281
286
|
}
|
|
282
287
|
const enabledSlack = config.slackApps.filter((app) => app.enabled !== false);
|
|
@@ -287,7 +292,7 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
287
292
|
for (const app of enabledSlack) {
|
|
288
293
|
const key = adapterKey("slack", app.id);
|
|
289
294
|
logger.debug({ identityId: app.id }, "slack adapter enabled");
|
|
290
|
-
const base = createSlackAdapter(app, config, logger, handleInbound);
|
|
295
|
+
const base = createSlackAdapter(app, config, logger, handleInbound, undefined, mediaStore);
|
|
291
296
|
adapters.set(key, { ...base, key });
|
|
292
297
|
}
|
|
293
298
|
}
|
|
@@ -448,6 +453,120 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
448
453
|
lastOutboundAt = now;
|
|
449
454
|
};
|
|
450
455
|
await loadMessagingAgentConfig();
|
|
456
|
+
const outboundMediaMaxBytesRaw = Number.parseInt(process.env.OPENCODE_ROUTER_MAX_MEDIA_BYTES ?? "", 10);
|
|
457
|
+
const outboundMediaMaxBytes = Number.isFinite(outboundMediaMaxBytesRaw) && outboundMediaMaxBytesRaw > 0
|
|
458
|
+
? outboundMediaMaxBytesRaw
|
|
459
|
+
: 50 * 1024 * 1024;
|
|
460
|
+
const resolveOutboundParts = async (baseDirectory, input) => {
|
|
461
|
+
const normalized = normalizeOutboundParts(input);
|
|
462
|
+
if (normalized.length === 0) {
|
|
463
|
+
const error = new Error("text or parts is required");
|
|
464
|
+
error.status = 400;
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
const resolved = [];
|
|
468
|
+
for (const part of normalized) {
|
|
469
|
+
if (part.type === "text") {
|
|
470
|
+
resolved.push(part);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
const file = await mediaStore.resolveOutboundFile({
|
|
474
|
+
filePath: part.filePath,
|
|
475
|
+
baseDirectory,
|
|
476
|
+
maxBytes: outboundMediaMaxBytes,
|
|
477
|
+
});
|
|
478
|
+
resolved.push({
|
|
479
|
+
...part,
|
|
480
|
+
filePath: file.filePath,
|
|
481
|
+
...(part.filename ? {} : { filename: file.filename }),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return resolved;
|
|
485
|
+
};
|
|
486
|
+
const deliverParts = async (channel, identityId, peerId, parts, options = {}) => {
|
|
487
|
+
const adapter = adapters.get(adapterKey(channel, identityId));
|
|
488
|
+
if (!adapter) {
|
|
489
|
+
return {
|
|
490
|
+
attemptedParts: parts.length,
|
|
491
|
+
sentParts: 0,
|
|
492
|
+
partResults: parts.map((part, index) => ({
|
|
493
|
+
index,
|
|
494
|
+
type: part.type,
|
|
495
|
+
sent: false,
|
|
496
|
+
error: "Adapter not running",
|
|
497
|
+
code: "not_found",
|
|
498
|
+
retryable: false,
|
|
499
|
+
})),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const kind = options.kind ?? "system";
|
|
503
|
+
if (options.display !== false) {
|
|
504
|
+
for (const part of parts) {
|
|
505
|
+
const preview = part.type === "text"
|
|
506
|
+
? truncateText(part.text, 240)
|
|
507
|
+
: `[${part.type}] ${part.filename || part.filePath}`;
|
|
508
|
+
reporter?.onOutbound?.({ channel, identityId, peerId, text: preview, kind });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
recordOutboundActivity(Date.now());
|
|
512
|
+
if (adapter.sendMessage) {
|
|
513
|
+
try {
|
|
514
|
+
return await adapter.sendMessage(peerId, { parts });
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
const classified = classifyDeliveryError(error);
|
|
518
|
+
return {
|
|
519
|
+
attemptedParts: parts.length,
|
|
520
|
+
sentParts: 0,
|
|
521
|
+
partResults: parts.map((part, index) => ({
|
|
522
|
+
index,
|
|
523
|
+
type: part.type,
|
|
524
|
+
sent: false,
|
|
525
|
+
error: classified.message,
|
|
526
|
+
code: classified.code,
|
|
527
|
+
retryable: classified.retryable,
|
|
528
|
+
})),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
const partResults = [];
|
|
533
|
+
let sentParts = 0;
|
|
534
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
535
|
+
const part = parts[index];
|
|
536
|
+
try {
|
|
537
|
+
if (part.type === "text") {
|
|
538
|
+
const chunks = chunkText(part.text, adapter.maxTextLength);
|
|
539
|
+
for (const chunk of chunks) {
|
|
540
|
+
await adapter.sendText(peerId, chunk);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
else if (adapter.sendFile) {
|
|
544
|
+
await adapter.sendFile(peerId, part.filePath, part.caption);
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
throw new Error(`Adapter does not support ${part.type} media`);
|
|
548
|
+
}
|
|
549
|
+
sentParts += 1;
|
|
550
|
+
partResults.push({ index, type: part.type, sent: true });
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
const classified = classifyDeliveryError(error);
|
|
554
|
+
partResults.push({
|
|
555
|
+
index,
|
|
556
|
+
type: part.type,
|
|
557
|
+
sent: false,
|
|
558
|
+
error: classified.message,
|
|
559
|
+
code: classified.code,
|
|
560
|
+
retryable: classified.retryable,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
attemptedParts: parts.length,
|
|
566
|
+
sentParts,
|
|
567
|
+
partResults,
|
|
568
|
+
};
|
|
569
|
+
};
|
|
451
570
|
let stopHealthServer = null;
|
|
452
571
|
if (!deps.disableHealthServer && config.healthPort) {
|
|
453
572
|
stopHealthServer = await startHealthServer(config.healthPort, () => ({
|
|
@@ -662,7 +781,7 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
662
781
|
...(runtimeAccess === "private" && runtimePairingCodeHash
|
|
663
782
|
? { pairingCodeHash: runtimePairingCodeHash }
|
|
664
783
|
: {}),
|
|
665
|
-
}, config, logger, handleInbound);
|
|
784
|
+
}, config, logger, handleInbound, mediaStore);
|
|
666
785
|
const adapter = { ...base, key };
|
|
667
786
|
adapters.set(key, adapter);
|
|
668
787
|
const startResult = await startAdapterBounded(adapter, {
|
|
@@ -840,7 +959,7 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
840
959
|
}
|
|
841
960
|
adapters.delete(key);
|
|
842
961
|
}
|
|
843
|
-
const base = createSlackAdapter({ id, botToken, appToken, enabled, ...(directoryInput ? { directory: directoryInput } : {}) }, config, logger, handleInbound);
|
|
962
|
+
const base = createSlackAdapter({ id, botToken, appToken, enabled, ...(directoryInput ? { directory: directoryInput } : {}) }, config, logger, handleInbound, undefined, mediaStore);
|
|
844
963
|
const adapter = { ...base, key };
|
|
845
964
|
adapters.set(key, adapter);
|
|
846
965
|
const startResult = await startAdapterBounded(adapter, {
|
|
@@ -977,10 +1096,6 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
977
1096
|
const directoryInput = (input.directory ?? "").trim();
|
|
978
1097
|
const peerId = (input.peerId ?? "").trim();
|
|
979
1098
|
const autoBind = input.autoBind === true;
|
|
980
|
-
const text = input.text ?? "";
|
|
981
|
-
if (!text.trim()) {
|
|
982
|
-
throw new Error("text is required");
|
|
983
|
-
}
|
|
984
1099
|
if (!directoryInput && !peerId) {
|
|
985
1100
|
throw new Error("directory or peerId is required");
|
|
986
1101
|
}
|
|
@@ -996,6 +1111,27 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
996
1111
|
}
|
|
997
1112
|
return scoped.directory;
|
|
998
1113
|
})() : "";
|
|
1114
|
+
const baseDirectory = normalizedDir || workspaceRoot;
|
|
1115
|
+
const outboundParts = await resolveOutboundParts(baseDirectory, {
|
|
1116
|
+
text: input.text,
|
|
1117
|
+
parts: input.parts,
|
|
1118
|
+
});
|
|
1119
|
+
const makeTargetError = (targetIdentityId, targetPeerId, errorMessage, errorCode = "not_found") => ({
|
|
1120
|
+
identityId: targetIdentityId,
|
|
1121
|
+
peerId: targetPeerId,
|
|
1122
|
+
attemptedParts: outboundParts.length,
|
|
1123
|
+
sentParts: 0,
|
|
1124
|
+
partResults: outboundParts.map((part, index) => ({
|
|
1125
|
+
index,
|
|
1126
|
+
type: part.type,
|
|
1127
|
+
sent: false,
|
|
1128
|
+
error: errorMessage,
|
|
1129
|
+
code: errorCode,
|
|
1130
|
+
retryable: false,
|
|
1131
|
+
})),
|
|
1132
|
+
});
|
|
1133
|
+
const deliveryFailed = (delivery) => delivery.attemptedParts > 0 && delivery.sentParts < delivery.attemptedParts;
|
|
1134
|
+
const primaryFailureMessage = (delivery) => delivery.partResults.find((part) => !part.sent)?.error || "Delivery failed";
|
|
999
1135
|
const resolveSendIdentityId = () => {
|
|
1000
1136
|
if (identityId)
|
|
1001
1137
|
return identityId;
|
|
@@ -1022,11 +1158,13 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1022
1158
|
attempted: 0,
|
|
1023
1159
|
sent: 0,
|
|
1024
1160
|
reason: `No ${channel} adapter is running for direct send`,
|
|
1161
|
+
targets: [],
|
|
1025
1162
|
};
|
|
1026
1163
|
}
|
|
1027
1164
|
if (peerId && targetIdentityId) {
|
|
1028
1165
|
const adapter = adapters.get(adapterKey(channel, targetIdentityId));
|
|
1029
1166
|
if (!adapter) {
|
|
1167
|
+
const target = makeTargetError(targetIdentityId, peerId, "Adapter not running");
|
|
1030
1168
|
return {
|
|
1031
1169
|
channel,
|
|
1032
1170
|
directory: normalizedDir || workspaceRootNormalized,
|
|
@@ -1035,6 +1173,7 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1035
1173
|
attempted: 1,
|
|
1036
1174
|
sent: 0,
|
|
1037
1175
|
failures: [{ identityId: targetIdentityId, peerId, error: "Adapter not running" }],
|
|
1176
|
+
targets: [target],
|
|
1038
1177
|
};
|
|
1039
1178
|
}
|
|
1040
1179
|
if (autoBind && normalizedDir) {
|
|
@@ -1042,32 +1181,39 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1042
1181
|
store.deleteSession(channel, targetIdentityId, peerId);
|
|
1043
1182
|
ensureEventSubscription(normalizedDir);
|
|
1044
1183
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1184
|
+
const delivery = await deliverParts(channel, targetIdentityId, peerId, outboundParts, {
|
|
1185
|
+
kind: "system",
|
|
1186
|
+
display: false,
|
|
1187
|
+
});
|
|
1188
|
+
const failed = deliveryFailed(delivery);
|
|
1189
|
+
return {
|
|
1190
|
+
channel,
|
|
1191
|
+
directory: normalizedDir || workspaceRootNormalized,
|
|
1192
|
+
identityId: targetIdentityId,
|
|
1193
|
+
peerId,
|
|
1194
|
+
attempted: 1,
|
|
1195
|
+
sent: failed ? 0 : 1,
|
|
1196
|
+
...(failed
|
|
1197
|
+
? {
|
|
1198
|
+
failures: [
|
|
1199
|
+
{
|
|
1200
|
+
identityId: targetIdentityId,
|
|
1201
|
+
peerId,
|
|
1202
|
+
error: primaryFailureMessage(delivery),
|
|
1203
|
+
},
|
|
1204
|
+
],
|
|
1205
|
+
}
|
|
1206
|
+
: {}),
|
|
1207
|
+
targets: [
|
|
1208
|
+
{
|
|
1209
|
+
identityId: targetIdentityId,
|
|
1210
|
+
peerId,
|
|
1211
|
+
attemptedParts: delivery.attemptedParts,
|
|
1212
|
+
sentParts: delivery.sentParts,
|
|
1213
|
+
partResults: delivery.partResults,
|
|
1214
|
+
},
|
|
1215
|
+
],
|
|
1216
|
+
};
|
|
1071
1217
|
}
|
|
1072
1218
|
const bindings = store.listBindings({
|
|
1073
1219
|
channel,
|
|
@@ -1082,9 +1228,11 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1082
1228
|
attempted: 0,
|
|
1083
1229
|
sent: 0,
|
|
1084
1230
|
reason: `No bound conversations for ${channel}${identityId ? `/${identityId}` : ""} at directory ${normalizedDir}`,
|
|
1231
|
+
targets: [],
|
|
1085
1232
|
};
|
|
1086
1233
|
}
|
|
1087
1234
|
const failures = [];
|
|
1235
|
+
const targets = [];
|
|
1088
1236
|
let attempted = 0;
|
|
1089
1237
|
let sent = 0;
|
|
1090
1238
|
for (const binding of bindings) {
|
|
@@ -1092,6 +1240,8 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1092
1240
|
if (channel === "telegram" && !isTelegramPeerId(binding.peer_id)) {
|
|
1093
1241
|
store.deleteBinding(channel, binding.identity_id, binding.peer_id);
|
|
1094
1242
|
store.deleteSession(channel, binding.identity_id, binding.peer_id);
|
|
1243
|
+
const target = makeTargetError(binding.identity_id, binding.peer_id, "Invalid Telegram peerId binding removed (expected numeric chat_id)", "invalid_target");
|
|
1244
|
+
targets.push(target);
|
|
1095
1245
|
failures.push({
|
|
1096
1246
|
identityId: binding.identity_id,
|
|
1097
1247
|
peerId: binding.peer_id,
|
|
@@ -1101,6 +1251,8 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1101
1251
|
}
|
|
1102
1252
|
const adapter = adapters.get(adapterKey(channel, binding.identity_id));
|
|
1103
1253
|
if (!adapter) {
|
|
1254
|
+
const target = makeTargetError(binding.identity_id, binding.peer_id, "Adapter not running");
|
|
1255
|
+
targets.push(target);
|
|
1104
1256
|
failures.push({
|
|
1105
1257
|
identityId: binding.identity_id,
|
|
1106
1258
|
peerId: binding.peer_id,
|
|
@@ -1108,17 +1260,27 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1108
1260
|
});
|
|
1109
1261
|
continue;
|
|
1110
1262
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1263
|
+
const delivery = await deliverParts(channel, binding.identity_id, binding.peer_id, outboundParts, {
|
|
1264
|
+
kind: "system",
|
|
1265
|
+
display: false,
|
|
1266
|
+
});
|
|
1267
|
+
targets.push({
|
|
1268
|
+
identityId: binding.identity_id,
|
|
1269
|
+
peerId: binding.peer_id,
|
|
1270
|
+
attemptedParts: delivery.attemptedParts,
|
|
1271
|
+
sentParts: delivery.sentParts,
|
|
1272
|
+
partResults: delivery.partResults,
|
|
1273
|
+
});
|
|
1274
|
+
if (deliveryFailed(delivery)) {
|
|
1116
1275
|
failures.push({
|
|
1117
1276
|
identityId: binding.identity_id,
|
|
1118
1277
|
peerId: binding.peer_id,
|
|
1119
|
-
error:
|
|
1278
|
+
error: primaryFailureMessage(delivery),
|
|
1120
1279
|
});
|
|
1121
1280
|
}
|
|
1281
|
+
else {
|
|
1282
|
+
sent += 1;
|
|
1283
|
+
}
|
|
1122
1284
|
}
|
|
1123
1285
|
return {
|
|
1124
1286
|
channel,
|
|
@@ -1127,6 +1289,7 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1127
1289
|
attempted,
|
|
1128
1290
|
sent,
|
|
1129
1291
|
...(failures.length ? { failures } : {}),
|
|
1292
|
+
targets,
|
|
1130
1293
|
};
|
|
1131
1294
|
},
|
|
1132
1295
|
});
|
|
@@ -1246,27 +1409,13 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1246
1409
|
};
|
|
1247
1410
|
ensureEventSubscription(defaultDirectory);
|
|
1248
1411
|
async function sendText(channel, identityId, peerId, text, options = {}) {
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
reporter?.onOutbound?.({ channel, identityId, peerId, text, kind });
|
|
1257
|
-
}
|
|
1258
|
-
// CHECK IF IT'S A FILE COMMAND
|
|
1259
|
-
if (text.startsWith("FILE:")) {
|
|
1260
|
-
const filePath = text.substring(5).trim();
|
|
1261
|
-
if (adapter.sendFile) {
|
|
1262
|
-
await adapter.sendFile(peerId, filePath);
|
|
1263
|
-
return; // Stop here, don't send text
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
const chunks = chunkText(text, adapter.maxTextLength);
|
|
1267
|
-
for (const chunk of chunks) {
|
|
1268
|
-
logger.info({ channel, peerId, length: chunk.length }, "sending message");
|
|
1269
|
-
await adapter.sendText(peerId, chunk);
|
|
1412
|
+
const parts = text.startsWith("FILE:") && text.substring(5).trim()
|
|
1413
|
+
? [{ type: "file", filePath: text.substring(5).trim() }]
|
|
1414
|
+
: [{ type: "text", text }];
|
|
1415
|
+
const delivery = await deliverParts(channel, identityId, peerId, parts, options);
|
|
1416
|
+
if (delivery.sentParts < delivery.attemptedParts) {
|
|
1417
|
+
const message = delivery.partResults.find((part) => !part.sent)?.error || "Failed to send message";
|
|
1418
|
+
throw new Error(message);
|
|
1270
1419
|
}
|
|
1271
1420
|
}
|
|
1272
1421
|
async function handleTelegramPairingGate(input) {
|
|
@@ -1318,16 +1467,27 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1318
1467
|
if (!adapter)
|
|
1319
1468
|
return;
|
|
1320
1469
|
recordInboundActivity(Date.now());
|
|
1321
|
-
|
|
1470
|
+
const normalizedParts = Array.isArray(message.parts) && message.parts.length
|
|
1471
|
+
? message.parts
|
|
1472
|
+
: message.text.trim()
|
|
1473
|
+
? [{ type: "text", text: message.text }]
|
|
1474
|
+
: [];
|
|
1475
|
+
const inboundText = textFromInboundParts(normalizedParts, message.text).trim();
|
|
1476
|
+
let inbound = {
|
|
1477
|
+
...message,
|
|
1478
|
+
text: inboundText,
|
|
1479
|
+
...(normalizedParts.length ? { parts: normalizedParts } : {}),
|
|
1480
|
+
};
|
|
1481
|
+
const reporterInboundText = inbound.text || summarizeInboundPartsForReporter(inbound.parts) || "[empty message]";
|
|
1322
1482
|
logger.debug({
|
|
1323
1483
|
channel: inbound.channel,
|
|
1324
1484
|
identityId: inbound.identityId,
|
|
1325
1485
|
peerId: inbound.peerId,
|
|
1326
1486
|
fromMe: inbound.fromMe,
|
|
1327
|
-
length:
|
|
1328
|
-
preview: truncateText(
|
|
1487
|
+
length: reporterInboundText.length,
|
|
1488
|
+
preview: truncateText(reporterInboundText.trim(), 120),
|
|
1329
1489
|
}, "inbound received");
|
|
1330
|
-
logger.info({ channel: inbound.channel, identityId: inbound.identityId, peerId: inbound.peerId, length:
|
|
1490
|
+
logger.info({ channel: inbound.channel, identityId: inbound.identityId, peerId: inbound.peerId, length: reporterInboundText.length }, "received message");
|
|
1331
1491
|
const peerKey = inbound.peerId;
|
|
1332
1492
|
const trimmedText = inbound.text.trim();
|
|
1333
1493
|
let binding = store.getBinding(inbound.channel, inbound.identityId, peerKey);
|
|
@@ -1356,7 +1516,7 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1356
1516
|
channel: inbound.channel,
|
|
1357
1517
|
identityId: inbound.identityId,
|
|
1358
1518
|
peerId: inbound.peerId,
|
|
1359
|
-
text:
|
|
1519
|
+
text: reporterInboundText,
|
|
1360
1520
|
fromMe: inbound.fromMe,
|
|
1361
1521
|
});
|
|
1362
1522
|
const identityDirectory = resolveIdentityDirectory(inbound.channel, inbound.identityId);
|
|
@@ -1416,6 +1576,8 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1416
1576
|
.map((value) => value.trim())
|
|
1417
1577
|
.filter(Boolean)
|
|
1418
1578
|
.join("\n\n");
|
|
1579
|
+
const attachmentSummary = summarizeInboundPartsForPrompt(inbound.parts);
|
|
1580
|
+
const incomingText = inbound.text || "(no text; user sent media)";
|
|
1419
1581
|
const promptText = [
|
|
1420
1582
|
"You are handling a Slack/Telegram message via OpenWork.",
|
|
1421
1583
|
`Workspace agent file: ${messagingAgent.filePath}`,
|
|
@@ -1424,7 +1586,8 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1424
1586
|
effectiveInstructions,
|
|
1425
1587
|
"",
|
|
1426
1588
|
"Incoming user message:",
|
|
1427
|
-
|
|
1589
|
+
incomingText,
|
|
1590
|
+
...(attachmentSummary.length ? ["", "Incoming attachments:", ...attachmentSummary] : []),
|
|
1428
1591
|
].join("\n");
|
|
1429
1592
|
logger.debug({
|
|
1430
1593
|
sessionID,
|
|
@@ -1700,7 +1863,8 @@ export async function startBridge(config, logger, reporter, deps = {}) {
|
|
|
1700
1863
|
channel: message.channel,
|
|
1701
1864
|
identityId,
|
|
1702
1865
|
peerId: message.peerId,
|
|
1703
|
-
text: message.text,
|
|
1866
|
+
text: message.text ?? "",
|
|
1867
|
+
...(Array.isArray(message.parts) ? { parts: message.parts } : {}),
|
|
1704
1868
|
raw: message.raw ?? null,
|
|
1705
1869
|
fromMe: message.fromMe,
|
|
1706
1870
|
});
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import { readFile as readFileAsync } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
import { Command } from "commander";
|
|
4
|
-
import { Bot } from "grammy";
|
|
6
|
+
import { Bot, InputFile } from "grammy";
|
|
5
7
|
import { WebClient } from "@slack/web-api";
|
|
6
8
|
import { startBridge } from "./bridge.js";
|
|
7
9
|
import { loadConfig, readConfigFile, writeConfigFile, } from "./config.js";
|
|
@@ -489,11 +491,15 @@ bindings
|
|
|
489
491
|
// -----------------------------------------------------------------------------
|
|
490
492
|
program
|
|
491
493
|
.command("send")
|
|
492
|
-
.description("Send a test message")
|
|
494
|
+
.description("Send a test message and/or media")
|
|
493
495
|
.requiredOption("--channel <channel>", "telegram or slack")
|
|
494
496
|
.requiredOption("--identity <id>", "Identity id")
|
|
495
497
|
.requiredOption("--to <recipient>", "Recipient ID (chat ID or peerId)")
|
|
496
|
-
.
|
|
498
|
+
.option("--message <text>", "Message text to send")
|
|
499
|
+
.option("--image <path>", "Image file path")
|
|
500
|
+
.option("--audio <path>", "Audio file path")
|
|
501
|
+
.option("--file <path>", "File path")
|
|
502
|
+
.option("--caption <text>", "Caption for media upload")
|
|
497
503
|
.action(async (opts) => {
|
|
498
504
|
const useJson = program.opts().json;
|
|
499
505
|
const channelRaw = opts.channel.trim().toLowerCase();
|
|
@@ -503,14 +509,46 @@ program
|
|
|
503
509
|
const config = loadConfig(process.env, { requireOpencode: false });
|
|
504
510
|
const identityId = normalizeIdentityId(opts.identity);
|
|
505
511
|
const to = opts.to.trim();
|
|
506
|
-
const message = opts.message;
|
|
512
|
+
const message = typeof opts.message === "string" ? opts.message : "";
|
|
513
|
+
const media = [
|
|
514
|
+
...(opts.image?.trim() ? [{ type: "image", filePath: path.resolve(opts.image.trim()) }] : []),
|
|
515
|
+
...(opts.audio?.trim() ? [{ type: "audio", filePath: path.resolve(opts.audio.trim()) }] : []),
|
|
516
|
+
...(opts.file?.trim() ? [{ type: "file", filePath: path.resolve(opts.file.trim()) }] : []),
|
|
517
|
+
];
|
|
518
|
+
const caption = typeof opts.caption === "string" ? opts.caption.trim() : "";
|
|
519
|
+
if (!message.trim() && media.length === 0) {
|
|
520
|
+
outputError("Provide at least one of --message, --image, --audio, or --file.");
|
|
521
|
+
}
|
|
507
522
|
try {
|
|
508
523
|
if (channelRaw === "telegram") {
|
|
509
524
|
const bot = config.telegramBots.find((b) => b.id === identityId);
|
|
510
525
|
if (!bot)
|
|
511
526
|
throw new Error(`Telegram identity not found: ${identityId}`);
|
|
512
527
|
const tg = new Bot(bot.token);
|
|
513
|
-
|
|
528
|
+
const chatId = Number(to);
|
|
529
|
+
if (!Number.isFinite(chatId)) {
|
|
530
|
+
throw new Error("Telegram recipient must be numeric chat_id.");
|
|
531
|
+
}
|
|
532
|
+
if (message.trim()) {
|
|
533
|
+
await tg.api.sendMessage(chatId, message);
|
|
534
|
+
}
|
|
535
|
+
for (const item of media) {
|
|
536
|
+
if (item.type === "image") {
|
|
537
|
+
await tg.api.sendPhoto(chatId, new InputFile(item.filePath), {
|
|
538
|
+
...(caption ? { caption } : {}),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
else if (item.type === "audio") {
|
|
542
|
+
await tg.api.sendAudio(chatId, new InputFile(item.filePath), {
|
|
543
|
+
...(caption ? { caption } : {}),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
await tg.api.sendDocument(chatId, new InputFile(item.filePath), {
|
|
548
|
+
...(caption ? { caption } : {}),
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
514
552
|
}
|
|
515
553
|
else {
|
|
516
554
|
const app = config.slackApps.find((a) => a.id === identityId);
|
|
@@ -520,14 +558,32 @@ program
|
|
|
520
558
|
const peer = parseSlackPeerId(to);
|
|
521
559
|
if (!peer.channelId)
|
|
522
560
|
throw new Error("Invalid recipient for Slack.");
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
561
|
+
if (message.trim()) {
|
|
562
|
+
await web.chat.postMessage({
|
|
563
|
+
channel: peer.channelId,
|
|
564
|
+
text: message,
|
|
565
|
+
...(peer.threadTs ? { thread_ts: peer.threadTs } : {}),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
for (const item of media) {
|
|
569
|
+
const fileData = await readFileAsync(item.filePath);
|
|
570
|
+
await web.files.uploadV2({
|
|
571
|
+
channel_id: peer.channelId,
|
|
572
|
+
file: fileData,
|
|
573
|
+
filename: path.basename(item.filePath),
|
|
574
|
+
...(peer.threadTs ? { thread_ts: peer.threadTs } : {}),
|
|
575
|
+
...(caption ? { initial_comment: caption } : {}),
|
|
576
|
+
});
|
|
577
|
+
}
|
|
528
578
|
}
|
|
529
579
|
if (useJson)
|
|
530
|
-
outputJson({
|
|
580
|
+
outputJson({
|
|
581
|
+
success: true,
|
|
582
|
+
sent: {
|
|
583
|
+
text: Boolean(message.trim()),
|
|
584
|
+
media: media.map((item) => ({ type: item.type, filePath: item.filePath })),
|
|
585
|
+
},
|
|
586
|
+
});
|
|
531
587
|
else
|
|
532
588
|
console.log("Message sent.");
|
|
533
589
|
process.exit(0);
|