opencode-router 0.11.128 → 0.11.130

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 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
- try {
1046
- await sendText(channel, targetIdentityId, peerId, text, { kind: "system", display: false });
1047
- return {
1048
- channel,
1049
- directory: normalizedDir || workspaceRootNormalized,
1050
- identityId: targetIdentityId,
1051
- peerId,
1052
- attempted: 1,
1053
- sent: 1,
1054
- };
1055
- }
1056
- catch (error) {
1057
- return {
1058
- channel,
1059
- directory: normalizedDir || workspaceRootNormalized,
1060
- identityId: targetIdentityId,
1061
- peerId,
1062
- attempted: 1,
1063
- sent: 0,
1064
- failures: [{
1065
- identityId: targetIdentityId,
1066
- peerId,
1067
- error: error instanceof Error ? error.message : String(error),
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
- try {
1112
- await sendText(channel, binding.identity_id, binding.peer_id, text, { kind: "system", display: false });
1113
- sent += 1;
1114
- }
1115
- catch (error) {
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: error instanceof Error ? error.message : String(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 adapter = adapters.get(adapterKey(channel, identityId));
1250
- if (!adapter)
1251
- return;
1252
- recordOutboundActivity(Date.now());
1253
- const kind = options.kind ?? "system";
1254
- logger.debug({ channel, identityId, peerId, kind, length: text.length }, "sendText requested");
1255
- if (options.display !== false) {
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,35 @@ export async function startBridge(config, logger, reporter, deps = {}) {
1318
1467
  if (!adapter)
1319
1468
  return;
1320
1469
  recordInboundActivity(Date.now());
1321
- let inbound = message;
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
+ if (inbound.fromMe) {
1482
+ logger.debug({
1483
+ channel: inbound.channel,
1484
+ identityId: inbound.identityId,
1485
+ peerId: inbound.peerId,
1486
+ }, "inbound ignored (self-authored)");
1487
+ return;
1488
+ }
1489
+ const reporterInboundText = inbound.text || summarizeInboundPartsForReporter(inbound.parts) || "[empty message]";
1322
1490
  logger.debug({
1323
1491
  channel: inbound.channel,
1324
1492
  identityId: inbound.identityId,
1325
1493
  peerId: inbound.peerId,
1326
1494
  fromMe: inbound.fromMe,
1327
- length: inbound.text.length,
1328
- preview: truncateText(inbound.text.trim(), 120),
1495
+ length: reporterInboundText.length,
1496
+ preview: truncateText(reporterInboundText.trim(), 120),
1329
1497
  }, "inbound received");
1330
- logger.info({ channel: inbound.channel, identityId: inbound.identityId, peerId: inbound.peerId, length: inbound.text.length }, "received message");
1498
+ logger.info({ channel: inbound.channel, identityId: inbound.identityId, peerId: inbound.peerId, length: reporterInboundText.length }, "received message");
1331
1499
  const peerKey = inbound.peerId;
1332
1500
  const trimmedText = inbound.text.trim();
1333
1501
  let binding = store.getBinding(inbound.channel, inbound.identityId, peerKey);
@@ -1356,7 +1524,7 @@ export async function startBridge(config, logger, reporter, deps = {}) {
1356
1524
  channel: inbound.channel,
1357
1525
  identityId: inbound.identityId,
1358
1526
  peerId: inbound.peerId,
1359
- text: inbound.text,
1527
+ text: reporterInboundText,
1360
1528
  fromMe: inbound.fromMe,
1361
1529
  });
1362
1530
  const identityDirectory = resolveIdentityDirectory(inbound.channel, inbound.identityId);
@@ -1416,6 +1584,8 @@ export async function startBridge(config, logger, reporter, deps = {}) {
1416
1584
  .map((value) => value.trim())
1417
1585
  .filter(Boolean)
1418
1586
  .join("\n\n");
1587
+ const attachmentSummary = summarizeInboundPartsForPrompt(inbound.parts);
1588
+ const incomingText = inbound.text || "(no text; user sent media)";
1419
1589
  const promptText = [
1420
1590
  "You are handling a Slack/Telegram message via OpenWork.",
1421
1591
  `Workspace agent file: ${messagingAgent.filePath}`,
@@ -1424,7 +1594,8 @@ export async function startBridge(config, logger, reporter, deps = {}) {
1424
1594
  effectiveInstructions,
1425
1595
  "",
1426
1596
  "Incoming user message:",
1427
- inbound.text,
1597
+ incomingText,
1598
+ ...(attachmentSummary.length ? ["", "Incoming attachments:", ...attachmentSummary] : []),
1428
1599
  ].join("\n");
1429
1600
  logger.debug({
1430
1601
  sessionID,
@@ -1700,7 +1871,8 @@ export async function startBridge(config, logger, reporter, deps = {}) {
1700
1871
  channel: message.channel,
1701
1872
  identityId,
1702
1873
  peerId: message.peerId,
1703
- text: message.text,
1874
+ text: message.text ?? "",
1875
+ ...(Array.isArray(message.parts) ? { parts: message.parts } : {}),
1704
1876
  raw: message.raw ?? null,
1705
1877
  fromMe: message.fromMe,
1706
1878
  });
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
- .requiredOption("--message <text>", "Message text to send")
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
- await tg.api.sendMessage(Number(to), message);
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
- await web.chat.postMessage({
524
- channel: peer.channelId,
525
- text: message,
526
- ...(peer.threadTs ? { thread_ts: peer.threadTs } : {}),
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({ success: true });
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);